diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 8a26503cda..2aa7c6c9c3 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -1,9 +1,12 @@ import { defineCommand } from "citty"; import type { Example } from "./_examples.js"; -import { spawn } from "node:child_process"; +import { spawn, type ChildProcessByStdio } from "node:child_process"; +import type { Readable } from "node:stream"; export const examples: Example[] = [ ["Preview the current project", "hyperframes preview"], + ["Print the current Studio selection as JSON", "hyperframes preview --selection --json"], + ["Print current Studio context as JSON", "hyperframes preview --context --json"], ["Preview a specific project directory", "hyperframes preview ./my-video"], ["Use a custom port", "hyperframes preview --port 8080"], ["Force a new server even if one is already running", "hyperframes preview --force-new"], @@ -24,6 +27,7 @@ import * as clack from "@clack/prompts"; import { c } from "../ui/colors.js"; import { isDevMode } from "../utils/env.js"; import { buildNpxCommand } from "../utils/npxCommand.js"; +import type { StudioSelectionSnapshot } from "@hyperframes/studio-server"; import { openBrowser, parseRemoteDebuggingPort, @@ -40,6 +44,40 @@ import { import { killOrphanedProcesses, killProcessTree } from "../utils/orphanCleanup.js"; import { resolveProject } from "../utils/project.js"; +interface BrowserLaunchOptions { + noOpen?: boolean; + browserPath?: string; + userDataDir?: string; + remoteDebuggingPort?: number; +} + +interface StudioLaunchOptions extends BrowserLaunchOptions { + projectName?: string; +} + +interface EmbeddedStudioOptions extends StudioLaunchOptions { + forceNew?: boolean; +} + +type StudioChildProcess = ChildProcessByStdio; +type ContextField = "server" | "selection" | "lint" | "capabilities"; +type CompactSelectionPayload = Pick< + StudioSelectionSnapshot, + | "schemaVersion" + | "projectId" + | "compositionPath" + | "sourceFile" + | "currentTime" + | "target" + | "label" + | "tagName" + | "boundingBox" + | "textContent" + | "thumbnailUrl" +>; + +const DEFAULT_CONTEXT_FIELDS: ContextField[] = ["server", "selection", "lint", "capabilities"]; + export default defineCommand({ meta: { name: "preview", description: "Start the studio for previewing compositions" }, args: { @@ -65,6 +103,32 @@ export default defineCommand({ default: true, description: "Open browser automatically", }, + selection: { + type: "boolean", + description: "Print the current element selected in a running Studio preview and exit", + default: false, + }, + json: { + type: "boolean", + description: "Output preview selection/context as JSON (only with --selection or --context)", + default: false, + }, + context: { + type: "boolean", + description: + "Print the current agent-readable context from a running Studio preview and exit", + default: false, + }, + "context-fields": { + type: "string", + description: + "Comma-separated context fields to include: server,selection,lint,capabilities (only with --context)", + }, + "context-detail": { + type: "string", + description: "Context payload detail: compact or full (only with --context)", + default: "compact", + }, "browser-path": { type: "string", description: "Path to the browser executable to open", @@ -80,6 +144,7 @@ export default defineCommand({ }, async run({ args }) { const startPort = parseInt(args.port ?? "3002", 10); + const preferredContextPort = hasExplicitPreviewPort(process.argv) ? startPort : undefined; // --list: scan and display active servers if (args.list) { @@ -111,6 +176,26 @@ export default defineCommand({ return; } + if (args.context) { + const project = resolveProject(args.dir); + return printCurrentContext(project.dir, startPort, { + json: Boolean(args.json), + fields: args["context-fields"] as string | undefined, + detail: args["context-detail"] as string | undefined, + ...(preferredContextPort === undefined ? {} : { preferredPort: preferredContextPort }), + }); + } + + if (args.selection) { + const project = resolveProject(args.dir); + return printCurrentSelection( + project.dir, + startPort, + Boolean(args.json), + preferredContextPort, + ); + } + // Kill orphaned chrome-headless-shell processes from previous crashed sessions. const orphansKilled = killOrphanedProcesses(); if (orphansKilled > 0) { @@ -198,28 +283,390 @@ export default defineCommand({ }, }); -/** - * Dev mode: spawn the studio dev server from the monorepo. - */ -async function runDevMode( - dir: string, - options?: { - projectName?: string; - noOpen?: boolean; - browserPath?: string; - userDataDir?: string; - remoteDebuggingPort?: number; - }, +function previewBaseUrl(port: number): string { + return `http://127.0.0.1:${port}`; +} + +function absolutePreviewUrl(port: number, path: string): string { + if (/^https?:\/\//.test(path)) return path; + return `${previewBaseUrl(port)}${path.startsWith("/") ? path : `/${path}`}`; +} + +function hasExplicitPreviewPort(argv: string[]): boolean { + return argv.some((arg) => arg === "--port" || arg.startsWith("--port=")); +} + +function printSelectionFailure(code: string, message: string, json: boolean): void { + if (json) { + console.log(JSON.stringify({ ok: false, error: { code, message } }, null, 2)); + } else { + clack.log.error(message); + } + process.exitCode = 1; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function previewServerPayload(server: { port: number; projectName: string; projectDir: string }): { + port: number; + projectName: string; + projectDir: string; + url: string; +} { + return { + port: server.port, + projectName: server.projectName, + projectDir: server.projectDir, + url: previewBaseUrl(server.port), + }; +} + +function parseContextFields(value: string | undefined): ContextField[] { + if (value === undefined) return DEFAULT_CONTEXT_FIELDS; + if (!value.trim()) throw new Error("--context-fields cannot be empty"); + const allowed = new Set(DEFAULT_CONTEXT_FIELDS); + const fields = value + .split(",") + .map((field) => field.trim()) + .filter(Boolean); + const invalid = fields.filter((field) => !allowed.has(field as ContextField)); + if (invalid.length > 0) { + throw new Error( + `Unknown context field${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`, + ); + } + return [...new Set(fields)] as ContextField[]; +} + +function contextIncludes(fields: ContextField[], field: ContextField): boolean { + return fields.includes(field); +} + +function addContextError( + payload: Record, + field: ContextField, + error: { code: string; message: string }, +): void { + payload.errors = { + ...((payload.errors as Record | undefined) ?? {}), + [field]: error, + }; +} + +async function printCurrentSelection( + projectDir: string, + startPort: number, + json: boolean, + preferredPort?: number, ): Promise { - // Find monorepo root by navigating from packages/cli/src/commands/ - const thisFile = fileURLToPath(import.meta.url); - const repoRoot = resolve(dirname(thisFile), "..", "..", "..", ".."); + const { + AmbiguousPreviewServerError, + PreviewServerPortMismatchError, + fetchStudioSelection, + findPreviewServerForProject, + } = await import("../utils/studioSelectionClient.js"); + let server: Awaited>; + try { + server = await findPreviewServerForProject( + projectDir, + startPort, + undefined, + undefined, + preferredPort === undefined ? undefined : { preferredPort }, + ); + } catch (err) { + if (err instanceof AmbiguousPreviewServerError) { + printSelectionFailure("ambiguous-preview-server", err.message, json); + return; + } + if (err instanceof PreviewServerPortMismatchError) { + printSelectionFailure("preview-port-mismatch", err.message, json); + return; + } + throw err; + } + if (!server) { + printSelectionFailure( + "preview-not-running", + "No running Studio preview found for this project. Start one with: npx hyperframes preview", + json, + ); + return; + } - // Symlink project into the studio's data directory - const projectsDir = join(repoRoot, "packages", "studio", "data", "projects"); - const pName = options?.projectName ?? basename(dir); - const symlinkPath = join(projectsDir, pName); + let response: Awaited>; + try { + response = await fetchStudioSelection(server); + } catch (err) { + printSelectionFailure("selection-unavailable", errorMessage(err), json); + return; + } + + if (!response.selection) { + printSelectionFailure( + "no-selection", + "Studio is running, but no element is selected. Select an element in Studio and rerun this command.", + json, + ); + return; + } + + const selection = { + ...response.selection, + thumbnailUrl: absolutePreviewUrl(server.port, response.selection.thumbnailUrl), + }; + + if (json) { + console.log( + JSON.stringify( + { + ok: true, + server: previewServerPayload(server), + selection, + updatedAt: response.updatedAt, + }, + null, + 2, + ), + ); + return; + } + + console.log(`${c.success("◇")} ${c.accent(selection.label)} selected in Studio`); + console.log(` ${c.dim("Source")} ${selection.sourceFile}`); + console.log( + ` ${c.dim("Target")} ${selection.target.hfId ?? selection.target.id ?? selection.target.selector ?? "(none)"}`, + ); + console.log(` ${c.dim("Time")} ${selection.currentTime.toFixed(3)}s`); + console.log(` ${c.dim("Thumbnail")} ${selection.thumbnailUrl}`); + console.log(); + console.log(c.dim("Use --json for the full agent-readable selection payload.")); +} + +function countLintFindings(findings: Array<{ severity: string }>): { + errors: number; + warnings: number; +} { + return { + errors: findings.filter((finding) => finding.severity === "error").length, + warnings: findings.filter((finding) => finding.severity === "warning").length, + }; +} + +async function printCurrentContext( + projectDir: string, + startPort: number, + options: { json: boolean; fields?: string; detail?: string; preferredPort?: number }, +): Promise { + let fields: ContextField[]; + try { + fields = parseContextFields(options.fields); + } catch (err) { + printSelectionFailure( + "invalid-context-fields", + err instanceof Error ? err.message : String(err), + options.json, + ); + return; + } + const fullDetail = options.detail === "full"; + if (options.detail !== undefined && !["compact", "full"].includes(options.detail)) { + printSelectionFailure( + "invalid-context-detail", + "--context-detail must be compact or full", + options.json, + ); + return; + } + + const { + AmbiguousPreviewServerError, + PreviewServerPortMismatchError, + fetchStudioLint, + fetchStudioSelection, + findPreviewServerForProject, + } = await import("../utils/studioSelectionClient.js"); + let server: Awaited>; + try { + server = await findPreviewServerForProject( + projectDir, + startPort, + undefined, + undefined, + options.preferredPort === undefined ? undefined : { preferredPort: options.preferredPort }, + ); + } catch (err) { + if (err instanceof AmbiguousPreviewServerError) { + printSelectionFailure("ambiguous-preview-server", err.message, options.json); + return; + } + if (err instanceof PreviewServerPortMismatchError) { + printSelectionFailure("preview-port-mismatch", err.message, options.json); + return; + } + throw err; + } + if (!server) { + printSelectionFailure( + "preview-not-running", + "No running Studio preview found for this project. Start one with: npx hyperframes preview", + options.json, + ); + return; + } + + const wantsSelection = contextIncludes(fields, "selection"); + const wantsLint = contextIncludes(fields, "lint"); + const [selectionResult, lintResult] = await Promise.allSettled([ + wantsSelection ? fetchStudioSelection(server) : Promise.resolve(null), + wantsLint ? fetchStudioLint(server) : Promise.resolve(null), + ]); + + const selection = + selectionResult.status === "fulfilled" && selectionResult.value?.selection + ? { + ok: true as const, + value: fullDetail + ? { + ...selectionResult.value.selection, + thumbnailUrl: absolutePreviewUrl( + server.port, + selectionResult.value.selection.thumbnailUrl, + ), + } + : compactSelectionPayload({ + ...selectionResult.value.selection, + thumbnailUrl: absolutePreviewUrl( + server.port, + selectionResult.value.selection.thumbnailUrl, + ), + }), + updatedAt: selectionResult.value.updatedAt, + } + : { + ok: false as const, + error: + selectionResult.status === "rejected" + ? { code: "selection-unavailable", message: errorMessage(selectionResult.reason) } + : { + code: "no-selection", + message: "Studio is running, but no element is selected.", + }, + }; + + const lint = + lintResult.status === "fulfilled" && lintResult.value + ? { + ok: true as const, + summary: countLintFindings(lintResult.value.findings), + findings: lintResult.value.findings, + } + : { + ok: false as const, + error: + lintResult.status === "rejected" + ? { code: "lint-unavailable", message: errorMessage(lintResult.reason) } + : { code: "lint-not-requested", message: "Lint was not requested." }, + }; + + const payload: Record = { ok: true }; + if (contextIncludes(fields, "server")) payload.server = previewServerPayload(server); + if (contextIncludes(fields, "selection")) { + payload.selection = selection.ok ? selection.value : null; + payload.selectionUpdatedAt = selection.ok ? selection.updatedAt : null; + if (!selection.ok) addContextError(payload, "selection", selection.error); + } + if (contextIncludes(fields, "lint")) payload.lint = lint; + if (contextIncludes(fields, "capabilities")) { + payload.capabilities = { + selection: true, + lint: true, + frame: false, + visibleElements: false, + lastAction: false, + }; + } + + if (options.json) { + console.log(JSON.stringify(payload, null, 2)); + return; + } + + console.log(`${c.success("◇")} Studio context`); + if (contextIncludes(fields, "server")) { + console.log(` ${c.dim("Project")} ${server.projectName}`); + console.log(` ${c.dim("Studio")} ${previewBaseUrl(server.port)}`); + } + if (contextIncludes(fields, "selection")) { + if (selection.ok) { + console.log(` ${c.dim("Selection")} ${selection.value.label}`); + } else { + console.log(` ${c.dim("Selection")} ${selection.error.message}`); + } + } + if (contextIncludes(fields, "lint")) { + if (lint.ok) { + console.log( + ` ${c.dim("Lint")} ${lint.summary.errors} error(s), ${lint.summary.warnings} warning(s)`, + ); + } else { + console.log(` ${c.dim("Lint")} ${lint.error.message}`); + } + } + console.log(); + console.log(c.dim("Use --json for the full agent-readable context payload.")); +} + +function compactSelectionPayload(selection: StudioSelectionSnapshot): CompactSelectionPayload { + return { + schemaVersion: selection.schemaVersion, + projectId: selection.projectId, + compositionPath: selection.compositionPath, + sourceFile: selection.sourceFile, + currentTime: selection.currentTime, + target: selection.target, + label: selection.label, + tagName: selection.tagName, + boundingBox: selection.boundingBox, + textContent: selection.textContent, + thumbnailUrl: selection.thumbnailUrl, + }; +} +function openStudioBrowser(url: string, projectName: string, options?: BrowserLaunchOptions): void { + if (options?.noOpen) return; + openBrowser(`${url}#project/${projectName}`, { + browserPath: options?.browserPath, + userDataDir: options?.userDataDir, + remoteDebuggingPort: options?.remoteDebuggingPort, + }); +} + +function printStudioSummary( + projectName: string, + url: string, + opts: { details?: string[]; footer?: string } = {}, +): void { + console.log(); + console.log(` ${c.dim("Project")} ${c.accent(projectName)}`); + console.log(` ${c.dim("Studio")} ${c.accent(url)}`); + console.log(); + for (const detail of opts.details ?? []) { + console.log(` ${c.dim(detail)}`); + } + if (opts.details?.length && opts.footer) console.log(); + if (opts.footer) console.log(` ${c.dim(opts.footer)}`); + console.log(); +} + +function linkProjectIntoStudioData( + dir: string, + projectsDir: string, + projectName: string, +): { symlinkPath: string; createdSymlink: boolean } { + const symlinkPath = join(projectsDir, projectName); mkdirSync(projectsDir, { recursive: true }); let createdSymlink = false; @@ -227,85 +674,102 @@ async function runDevMode( if (existsSync(symlinkPath)) { try { const stat = lstatSync(symlinkPath); - if (stat.isSymbolicLink()) { - const target = readlinkSync(symlinkPath); - if (resolve(target) !== resolve(dir)) { - unlinkSync(symlinkPath); - } + if (stat.isSymbolicLink() && resolve(readlinkSync(symlinkPath)) !== resolve(dir)) { + unlinkSync(symlinkPath); } - // If it's a real directory, leave it alone } catch { - // Not a symlink — don't touch it + // Real directories or unreadable paths are left untouched. } } - if (!existsSync(symlinkPath)) { symlinkSync(dir, symlinkPath, "dir"); createdSymlink = true; } } - clack.intro(c.bold("hyperframes preview")); + return { symlinkPath, createdSymlink }; +} - const s = clack.spinner(); - s.start("Starting studio..."); +function removeSymlinkOnExit(createdSymlink: boolean, symlinkPath: string): void { + if (!createdSymlink) return; + process.on("exit", () => { + try { + if (existsSync(symlinkPath)) unlinkSync(symlinkPath); + } catch { + /* ignore */ + } + }); +} - // Run the new consolidated studio (single Vite dev server with API plugin) - const studioPkgDir = join(repoRoot, "packages", "studio"); - const child = spawn("bun", ["run", "dev"], { - cwd: studioPkgDir, - stdio: ["ignore", "pipe", "pipe"], +function registerChildTreeShutdown(child: StudioChildProcess): void { + const shutdown = (): void => { + if (child.pid) killProcessTree(child.pid); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); +} + +function waitForChildClose(child: StudioChildProcess): Promise { + return new Promise((resolveClose) => { + child.on("close", () => resolveClose()); }); +} - let frontendUrl = ""; +function attachStudioReadyHandler( + child: StudioChildProcess, + spinner: ReturnType, + projectName: string, + options?: BrowserLaunchOptions, +): void { + let detected = false; function handleOutput(data: Buffer): void { - const text = data.toString(); + const url = data.toString().match(/Local:\s+(http:\/\/localhost:\d+)/)?.[1]; + if (!url || detected) return; + + detected = true; + spinner.stop(c.success("Studio running")); + printStudioSummary(projectName, url, { footer: "Press Ctrl+C to stop" }); + openStudioBrowser(url, projectName, options); + child.stdout.removeListener("data", handleOutput); + child.stderr.removeListener("data", handleOutput); + } - // Detect Vite URL - const localMatch = text.match(/Local:\s+(http:\/\/localhost:\d+)/); - if (localMatch && !frontendUrl) { - frontendUrl = localMatch[1] ?? ""; - s.stop(c.success("Studio running")); - console.log(); - console.log(` ${c.dim("Project")} ${c.accent(pName)}`); - console.log(` ${c.dim("Studio")} ${c.accent(frontendUrl)}`); - console.log(); - console.log(` ${c.dim("Press Ctrl+C to stop")}`); - console.log(); + child.stdout.on("data", handleOutput); + child.stderr.on("data", handleOutput); + child.on("error", (err) => { + spinner.stop(c.error("Failed to start studio")); + console.error(c.dim(err.message)); + }); +} - if (!options?.noOpen) { - const urlToOpen = `${frontendUrl}#project/${pName}`; - openBrowser(urlToOpen, { - browserPath: options?.browserPath, - userDataDir: options?.userDataDir, - remoteDebuggingPort: options?.remoteDebuggingPort, - }); - } +/** + * Dev mode: spawn the studio dev server from the monorepo. + */ +async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise { + // Find monorepo root by navigating from packages/cli/src/commands/ + const thisFile = fileURLToPath(import.meta.url); + const repoRoot = resolve(dirname(thisFile), "..", "..", "..", ".."); - child.stdout?.removeListener("data", handleOutput); - child.stderr?.removeListener("data", handleOutput); - } - } + // Symlink project into the studio's data directory + const projectsDir = join(repoRoot, "packages", "studio", "data", "projects"); + const pName = options?.projectName ?? basename(dir); + const { symlinkPath, createdSymlink } = linkProjectIntoStudioData(dir, projectsDir, pName); - child.stdout?.on("data", handleOutput); - child.stderr?.on("data", handleOutput); + clack.intro(c.bold("hyperframes preview")); - // If child exits before we detect readiness, show what we have - child.on("error", (err) => { - s.stop(c.error("Failed to start studio")); - console.error(c.dim(err.message)); + const s = clack.spinner(); + s.start("Starting studio..."); + + // Run the new consolidated studio (single Vite dev server with API plugin) + const studioPkgDir = join(repoRoot, "packages", "studio"); + const child = spawn("bun", ["run", "dev"], { + cwd: studioPkgDir, + stdio: ["ignore", "pipe", "pipe"], }); - if (createdSymlink) { - process.on("exit", () => { - try { - if (existsSync(symlinkPath)) unlinkSync(symlinkPath); - } catch { - /* ignore */ - } - }); - } + attachStudioReadyHandler(child, s, pName, options); + removeSymlinkOnExit(createdSymlink, symlinkPath); // Kill the child's entire process tree on SIGTERM/SIGINT. Ctrl+C sends // SIGINT to the foreground process group (covers the common case), but @@ -313,15 +777,8 @@ async function runDevMode( // would survive without explicit cleanup. // On Windows, killProcessTree is a no-op (pgrep/ps unavailable); Ctrl+C // propagates via the console process group instead. - const shutdown = () => { - if (child.pid) killProcessTree(child.pid); - }; - process.once("SIGINT", shutdown); - process.once("SIGTERM", shutdown); - - return new Promise((resolve) => { - child.on("close", () => resolve()); - }); + registerChildTreeShutdown(child); + return waitForChildClose(child); } /** @@ -341,37 +798,14 @@ function hasLocalStudio(dir: string): boolean { * Local studio mode: spawn Vite using a locally installed @hyperframes/studio. * Provides full Vite HMR and the complete studio experience. */ -async function runLocalStudioMode( - dir: string, - options?: { - projectName?: string; - noOpen?: boolean; - browserPath?: string; - userDataDir?: string; - remoteDebuggingPort?: number; - }, -): Promise { +async function runLocalStudioMode(dir: string, options?: StudioLaunchOptions): Promise { const req = createRequire(join(dir, "package.json")); const studioPkgPath = dirname(req.resolve("@hyperframes/studio/package.json")); const pName = options?.projectName ?? basename(dir); // Symlink project into studio's data directory const projectsDir = join(studioPkgPath, "data", "projects"); - const symlinkPath = join(projectsDir, pName); - mkdirSync(projectsDir, { recursive: true }); - - let createdSymlink = false; - if (dir !== symlinkPath) { - if (existsSync(symlinkPath) && lstatSync(symlinkPath).isSymbolicLink()) { - if (resolve(readlinkSync(symlinkPath)) !== resolve(dir)) { - unlinkSync(symlinkPath); - } - } - if (!existsSync(symlinkPath)) { - symlinkSync(dir, symlinkPath, "dir"); - createdSymlink = true; - } - } + const { symlinkPath, createdSymlink } = linkProjectIntoStudioData(dir, projectsDir, pName); clack.intro(c.bold("hyperframes preview") + c.dim(" (local studio)")); const s = clack.spinner(); @@ -383,58 +817,12 @@ async function runLocalStudioMode( stdio: ["ignore", "pipe", "pipe"], }); - let detected = false; - - function handleOutput(data: Buffer): void { - const text = data.toString(); - const localMatch = text.match(/Local:\s+(http:\/\/localhost:\d+)/); - if (localMatch && !detected) { - detected = true; - const url = localMatch[1] ?? ""; - s.stop(c.success("Studio running")); - console.log(); - console.log(` ${c.dim("Project")} ${c.accent(pName)}`); - console.log(` ${c.dim("Studio")} ${c.accent(url)}`); - console.log(); - console.log(` ${c.dim("Press Ctrl+C to stop")}`); - console.log(); - if (!options?.noOpen) { - openBrowser(`${url}#project/${pName}`, { - browserPath: options?.browserPath, - userDataDir: options?.userDataDir, - remoteDebuggingPort: options?.remoteDebuggingPort, - }); - } - } - } - - child.stdout?.on("data", handleOutput); - child.stderr?.on("data", handleOutput); - child.on("error", (err) => { - s.stop(c.error("Failed to start studio")); - console.error(c.dim(err.message)); - }); - - if (createdSymlink) { - process.on("exit", () => { - try { - if (existsSync(symlinkPath)) unlinkSync(symlinkPath); - } catch { - /* ignore */ - } - }); - } + attachStudioReadyHandler(child, s, pName, options); + removeSymlinkOnExit(createdSymlink, symlinkPath); // Same tree-kill handler as dev mode. No-op on Windows (see comment above). - const shutdown = () => { - if (child.pid) killProcessTree(child.pid); - }; - process.once("SIGINT", shutdown); - process.once("SIGTERM", shutdown); - - return new Promise((resolve) => { - child.on("close", () => resolve()); - }); + registerChildTreeShutdown(child); + return waitForChildClose(child); } /** @@ -447,14 +835,7 @@ async function runLocalStudioMode( async function runEmbeddedMode( dir: string, startPort: number, - options?: { - projectName?: string; - forceNew?: boolean; - noOpen?: boolean; - browserPath?: string; - userDataDir?: string; - remoteDebuggingPort?: number; - }, + options?: EmbeddedStudioOptions, ): Promise { const { createStudioServer, loadPreviewServerBuildSignature, resolveStudioBundle } = await import("../server/studioServer.js"); @@ -504,21 +885,10 @@ async function runEmbeddedMode( if (result.type === "already-running") { const url = `http://localhost:${result.port}`; s.stop(c.success("Already running")); - console.log(); - console.log(` ${c.dim("Project")} ${c.accent(pName)}`); - console.log(` ${c.dim("Studio")} ${c.accent(url)}`); - console.log(); - console.log( - ` ${c.dim("Reusing existing server. Use --force-new to start a fresh instance.")}`, - ); - console.log(); - if (!options?.noOpen) { - openBrowser(`${url}#project/${pName}`, { - browserPath: options?.browserPath, - userDataDir: options?.userDataDir, - remoteDebuggingPort: options?.remoteDebuggingPort, - }); - } + printStudioSummary(pName, url, { + details: ["Reusing existing server. Use --force-new to start a fresh instance."], + }); + openStudioBrowser(url, pName, options); return; } @@ -529,21 +899,14 @@ async function runEmbeddedMode( console.log(` ${c.warn(`Port ${startPort} is in use, using ${result.port} instead`)}`); console.log(); } - console.log(` ${c.dim("Project")} ${c.accent(pName)}`); - console.log(` ${c.dim("Studio")} ${c.accent(url)}`); - console.log(); - console.log(` ${c.dim("Edit with your AI agent — it has HyperFrames skills installed.")}`); - console.log(` ${c.dim("Changes reload automatically in the studio.")}`); - console.log(); - console.log(` ${c.dim("Press Ctrl+C to stop")}`); - console.log(); - if (!options?.noOpen) { - openBrowser(`${url}#project/${pName}`, { - browserPath: options?.browserPath, - userDataDir: options?.userDataDir, - remoteDebuggingPort: options?.remoteDebuggingPort, - }); - } + printStudioSummary(pName, url, { + details: [ + "Edit with your AI agent — it has HyperFrames skills installed.", + "Changes reload automatically in the studio.", + ], + footer: "Press Ctrl+C to stop", + }); + openStudioBrowser(url, pName, options); // Block until Ctrl+C. Node would normally exit on SIGINT, but the listening // HTTP server keeps handles open, so the event loop stays alive after the diff --git a/packages/cli/src/utils/studioSelectionClient.test.ts b/packages/cli/src/utils/studioSelectionClient.test.ts new file mode 100644 index 0000000000..b178dc2bb6 --- /dev/null +++ b/packages/cli/src/utils/studioSelectionClient.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolve } from "node:path"; +import { + AmbiguousPreviewServerError, + fetchStudioLint, + fetchStudioSelection, + studioApiUrl, + findPreviewServerForProject, + PreviewServerPortMismatchError, + studioSelectionUrl, +} from "./studioSelectionClient"; +import type { ActiveServer } from "../server/portUtils"; + +const servers: ActiveServer[] = [ + { + port: 3002, + projectName: "other", + projectDir: "/tmp/other", + version: "0.7.17", + pid: null, + }, + { + port: 3003, + projectName: "demo project", + projectDir: "/tmp/demo", + version: "0.7.17", + pid: "123", + }, +]; + +function mockProjectsFetch(port = 5190): typeof fetch { + return vi.fn(async (url: string | URL | Request) => { + expect(String(url)).toBe(`http://127.0.0.1:${port}/api/projects`); + return new Response( + JSON.stringify({ + projects: [{ id: "demo project", dir: "/tmp/demo", title: "Demo" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }) as unknown as typeof fetch; +} + +describe("studioSelectionClient", () => { + it("finds the active preview server for a project directory", async () => { + const scan = vi.fn(async () => servers); + + const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan); + + expect(server?.port).toBe(3003); + expect(scan).toHaveBeenCalledWith(3002); + }); + + it("matches by project directory when multiple projects are open", async () => { + const scan = vi.fn(async () => [ + ...servers, + { + port: 3004, + projectName: "third", + projectDir: "/tmp/third", + version: "0.7.17", + pid: null, + }, + ]); + + const server = await findPreviewServerForProject(resolve("/tmp/third"), 3002, scan); + + expect(server?.port).toBe(3004); + }); + + it("rejects ambiguous duplicate servers for the same project", async () => { + const scan = vi.fn(async () => [servers[1]!, { ...servers[1]!, port: 3004, pid: "456" }]); + + await expect( + findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan), + ).rejects.toMatchObject({ + name: "AmbiguousPreviewServerError", + ports: [3003, 3004], + } satisfies Partial); + }); + + it("uses an explicit preferred port to disambiguate duplicate project servers", async () => { + const scan = vi.fn(async () => [servers[1]!, { ...servers[1]!, port: 3004, pid: "456" }]); + + const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, undefined, { + preferredPort: 3004, + }); + + expect(server?.port).toBe(3004); + }); + + it("rejects an explicit preferred port that does not match the only project server", async () => { + const scan = vi.fn(async () => [servers[1]!]); + const fetchImpl = vi.fn(async () => new Response("missing", { status: 404 })); + + await expect( + findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, fetchImpl, { + preferredPort: 3999, + }), + ).rejects.toMatchObject({ + name: "PreviewServerPortMismatchError", + requestedPort: 3999, + ports: [3003], + } satisfies Partial); + expect(fetchImpl).toHaveBeenCalledWith("http://127.0.0.1:3999/api/projects"); + }); + + it("falls back to Vite Studio project discovery on port 5190", async () => { + const scan = vi.fn(async () => []); + const fetchImpl = mockProjectsFetch(); + + const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, fetchImpl); + + expect(server).toEqual({ + port: 5190, + projectName: "demo project", + projectDir: "/tmp/demo", + version: "studio-dev", + pid: null, + }); + }); + + it("checks an explicit preferred port for Vite Studio discovery", async () => { + const scan = vi.fn(async () => []); + const fetchImpl = mockProjectsFetch(5191); + + const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, fetchImpl, { + preferredPort: 5191, + }); + + expect(server?.port).toBe(5191); + expect(fetchImpl).not.toHaveBeenCalledWith("http://127.0.0.1:5190/api/projects"); + }); + + it("builds a URL to the existing preview server's selection endpoint", () => { + expect(studioSelectionUrl(servers[1]!)).toBe( + "http://127.0.0.1:3003/api/projects/demo%20project/selection", + ); + }); + + it("builds URLs to other preview server API routes", () => { + expect(studioApiUrl(servers[1]!, "lint")).toBe( + "http://127.0.0.1:3003/api/projects/demo%20project/lint", + ); + }); + + it("fetches the current selection snapshot from a preview server", async () => { + const fetchImpl = vi.fn(async () => { + return new Response( + JSON.stringify({ + selection: { + schemaVersion: 1, + projectId: "demo project", + compositionPath: "index.html", + sourceFile: "index.html", + currentTime: 2, + target: { hfId: "cta" }, + label: "CTA", + tagName: "button", + boundingBox: { x: 0, y: 0, width: 10, height: 10 }, + textContent: "Go", + dataAttributes: {}, + inlineStyles: {}, + computedStyles: {}, + textFields: [], + capabilities: { canSelect: true }, + thumbnailUrl: "/api/projects/demo%20project/thumbnail/index.html?t=2&format=png", + }, + updatedAt: "2026-06-28T16:00:00.000Z", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + + const result = await fetchStudioSelection(servers[1]!, fetchImpl); + + expect(result.selection?.target.hfId).toBe("cta"); + expect(result.updatedAt).toBe("2026-06-28T16:00:00.000Z"); + expect(fetchImpl).toHaveBeenCalledWith(studioSelectionUrl(servers[1]!)); + }); + + it("throws when the preview server returns a failed response", async () => { + await expect( + fetchStudioSelection( + servers[1]!, + vi.fn(async () => new Response("missing", { status: 404 })), + ), + ).rejects.toThrow("selection endpoint returned 404"); + }); + + it("fetches lint findings from a preview server", async () => { + const fetchImpl = vi.fn(async () => { + return new Response( + JSON.stringify({ + findings: [{ severity: "error", message: "Missing timeline", file: "index.html" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + + const result = await fetchStudioLint(servers[1]!, fetchImpl); + + expect(result.findings).toHaveLength(1); + expect(result.findings[0]?.message).toBe("Missing timeline"); + expect(fetchImpl).toHaveBeenCalledWith(studioApiUrl(servers[1]!, "lint")); + }); +}); diff --git a/packages/cli/src/utils/studioSelectionClient.ts b/packages/cli/src/utils/studioSelectionClient.ts new file mode 100644 index 0000000000..441e7f980b --- /dev/null +++ b/packages/cli/src/utils/studioSelectionClient.ts @@ -0,0 +1,149 @@ +import { existsSync, realpathSync } from "node:fs"; +import { resolve } from "node:path"; +import { scanActiveServers, type ActiveServer } from "../server/portUtils.js"; +import type { + LintResult, + ResolvedProject, + StudioSelectionResponse, +} from "@hyperframes/studio-server"; + +export type StudioLintResponse = LintResult; + +const VITE_STUDIO_DISCOVERY_PORTS = [5190] as const; + +interface StudioProjectsResponse { + projects?: ResolvedProject[]; +} + +interface FindPreviewServerOptions { + preferredPort?: number; +} + +export class AmbiguousPreviewServerError extends Error { + readonly ports: number[]; + + constructor(servers: ActiveServer[]) { + const ports = servers.map((server) => server.port).sort((a, b) => a - b); + super( + `Multiple Studio preview servers match this project (${ports.join(", ")}). Pass --port to choose one.`, + ); + this.name = "AmbiguousPreviewServerError"; + this.ports = ports; + } +} + +export class PreviewServerPortMismatchError extends Error { + readonly requestedPort: number; + readonly ports: number[]; + + constructor(requestedPort: number, servers: ActiveServer[]) { + const ports = servers.map((server) => server.port).sort((a, b) => a - b); + super( + `No Studio preview server for this project is running on port ${requestedPort}. Matching server port${ports.length === 1 ? "" : "s"}: ${ports.join(", ")}. Rerun with --port ${ports[0]}${ports.length > 1 ? " or omit --port to see all candidates" : ""}.`, + ); + this.name = "PreviewServerPortMismatchError"; + this.requestedPort = requestedPort; + this.ports = ports; + } +} + +function normalizePath(path: string): string { + const resolved = resolve(path); + try { + if (existsSync(resolved)) { + return realpathSync(resolved).replace(/\\/g, "/").toLowerCase(); + } + } catch { + // Fall through to resolved-path normalization. + } + return resolved.replace(/\\/g, "/").toLowerCase(); +} + +export async function findPreviewServerForProject( + projectDir: string, + startPort = 3002, + scan: (startPort?: number) => Promise = scanActiveServers, + fetchImpl: typeof fetch = fetch, + options: FindPreviewServerOptions = {}, +): Promise { + const normalizedProjectDir = normalizePath(projectDir); + const servers = await scan(startPort); + const embeddedServers = servers.filter( + (server) => normalizePath(server.projectDir) === normalizedProjectDir, + ); + if (options.preferredPort !== undefined) { + const preferred = embeddedServers.find((server) => server.port === options.preferredPort); + if (preferred) return preferred; + const viteServer = await findViteStudioServerForProject(normalizedProjectDir, fetchImpl, [ + options.preferredPort, + ]); + if (viteServer) return viteServer; + if (embeddedServers.length > 0) { + throw new PreviewServerPortMismatchError(options.preferredPort, embeddedServers); + } + return null; + } + if (embeddedServers.length === 1) return embeddedServers[0]!; + if (embeddedServers.length > 1) throw new AmbiguousPreviewServerError(embeddedServers); + return findViteStudioServerForProject(normalizedProjectDir, fetchImpl); +} + +export function studioSelectionUrl(server: ActiveServer): string { + return studioApiUrl(server, "selection"); +} + +export function studioApiUrl(server: ActiveServer, route: string): string { + return `http://127.0.0.1:${server.port}/api/projects/${encodeURIComponent(server.projectName)}/${route}`; +} + +async function findViteStudioServerForProject( + normalizedProjectDir: string, + fetchImpl: typeof fetch, + ports: readonly number[] = VITE_STUDIO_DISCOVERY_PORTS, +): Promise { + for (const port of ports) { + try { + const response = await fetchImpl(`http://127.0.0.1:${port}/api/projects`); + if (!response.ok) continue; + const payload = (await response.json()) as StudioProjectsResponse; + const project = payload.projects?.find( + (candidate) => normalizePath(candidate.dir) === normalizedProjectDir, + ); + if (!project) continue; + return { + port, + projectName: project.id, + projectDir: project.dir, + version: "studio-dev", + pid: null, + }; + } catch { + // Port is not a Vite-served Studio, or the dev server is not reachable. + } + } + return null; +} + +export async function fetchStudioSelection( + server: ActiveServer, + fetchImpl: typeof fetch = fetch, +): Promise { + const url = studioSelectionUrl(server); + const response = await fetchImpl(url); + if (!response.ok) { + throw new Error(`selection endpoint returned ${response.status}`); + } + return (await response.json()) as StudioSelectionResponse; +} + +export async function fetchStudioLint( + server: ActiveServer, + fetchImpl: typeof fetch = fetch, +): Promise { + const url = studioApiUrl(server, "lint"); + const response = await fetchImpl(url); + if (!response.ok) { + throw new Error(`lint endpoint returned ${response.status}`); + } + return (await response.json()) as StudioLintResponse; +} diff --git a/packages/studio-server/src/createStudioApi.ts b/packages/studio-server/src/createStudioApi.ts index e1a0ed56ae..99de7631be 100644 --- a/packages/studio-server/src/createStudioApi.ts +++ b/packages/studio-server/src/createStudioApi.ts @@ -10,6 +10,7 @@ import { registerThumbnailRoutes } from "./routes/thumbnail.js"; import { registerWaveformRoutes } from "./routes/waveform.js"; import { registerFontRoutes } from "./routes/fonts.js"; import { registerRegistryRoutes } from "./routes/registry.js"; +import { registerSelectionRoutes } from "./routes/selection.js"; /** * Create a Hono sub-app with all studio API routes. @@ -27,6 +28,7 @@ export function createStudioApi(adapter: StudioApiAdapter): Hono { registerLintRoutes(api, adapter); registerRenderRoutes(api, adapter); registerThumbnailRoutes(api, adapter); + registerSelectionRoutes(api, adapter); registerWaveformRoutes(api, adapter); registerFontRoutes(api); registerRegistryRoutes(api, adapter); diff --git a/packages/studio-server/src/index.ts b/packages/studio-server/src/index.ts index bbc77f69d6..87cd61103c 100644 --- a/packages/studio-server/src/index.ts +++ b/packages/studio-server/src/index.ts @@ -1,6 +1,14 @@ export { createStudioApi } from "./createStudioApi.js"; export { createProjectSignature } from "./helpers/projectSignature.js"; -export type { StudioApiAdapter, ResolvedProject, RenderJobState, LintResult } from "./types.js"; +export type { + StudioApiAdapter, + ResolvedProject, + RenderJobState, + LintResult, + StudioSelectionResponse, + StudioSelectionSnapshot, + StudioSelectionTextField, +} from "./types.js"; export { isSafePath, walkDir } from "./helpers/safePath.js"; export { getMimeType, MIME_TYPES } from "./helpers/mime.js"; export { buildSubCompositionHtml } from "./helpers/subComposition.js"; diff --git a/packages/studio-server/src/routes/selection.test.ts b/packages/studio-server/src/routes/selection.test.ts new file mode 100644 index 0000000000..41e164fcc1 --- /dev/null +++ b/packages/studio-server/src/routes/selection.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { Hono } from "hono"; +import { registerSelectionRoutes } from "./selection"; +import type { StudioApiAdapter, StudioSelectionSnapshot } from "../types"; + +function createAdapter(): StudioApiAdapter { + return { + listProjects: () => [], + resolveProject: async (id: string) => + id === "demo" ? { id, dir: "/tmp/demo", title: "Demo" } : null, + bundle: async () => null, + lint: async () => ({ findings: [] }), + runtimeUrl: "/api/runtime.js", + rendersDir: () => "/tmp/renders", + startRender: () => ({ + id: "job-1", + status: "rendering", + progress: 0, + outputPath: "/tmp/out.mp4", + }), + }; +} + +const selection = { + schemaVersion: 1, + projectId: "demo", + compositionPath: "index.html", + sourceFile: "index.html", + currentTime: 1.25, + target: { hfId: "hero-title", selector: ".title", selectorIndex: 0 }, + label: "Hero title", + tagName: "h1", + boundingBox: { x: 10, y: 20, width: 300, height: 64 }, + textContent: "Launch faster", + dataAttributes: { "data-hf-id": "hero-title" }, + inlineStyles: { color: "white" }, + computedStyles: { "font-size": "48px" }, + textFields: [ + { + key: "self", + label: "Text", + value: "Launch faster", + tagName: "h1", + source: "self", + }, + ], + capabilities: { canSelect: true, canEditStyles: true }, + thumbnailUrl: + "/api/projects/demo/thumbnail/index.html?t=1.25&format=png&selector=.title&selectorIndex=0", +} satisfies StudioSelectionSnapshot; + +describe("registerSelectionRoutes", () => { + it("stores and returns the latest Studio selection snapshot", async () => { + const app = new Hono(); + registerSelectionRoutes(app, createAdapter()); + + const put = await app.request("http://localhost/projects/demo/selection", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ selection }), + }); + expect(put.status).toBe(200); + + const response = await app.request("http://localhost/projects/demo/selection"); + const payload = (await response.json()) as { + selection?: StudioSelectionSnapshot | null; + updatedAt?: string | null; + }; + + expect(response.status).toBe(200); + expect(payload.selection).toMatchObject({ + projectId: "demo", + sourceFile: "index.html", + target: { hfId: "hero-title" }, + thumbnailUrl: expect.stringContaining("/thumbnail/index.html"), + }); + expect(payload.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it("clears a stored selection when Studio posts null", async () => { + const app = new Hono(); + registerSelectionRoutes(app, createAdapter()); + + await app.request("http://localhost/projects/demo/selection", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ selection }), + }); + + const clear = await app.request("http://localhost/projects/demo/selection", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ selection: null }), + }); + expect(clear.status).toBe(200); + + const response = await app.request("http://localhost/projects/demo/selection"); + const payload = (await response.json()) as { + selection?: StudioSelectionSnapshot | null; + updatedAt?: string | null; + }; + + expect(payload.selection).toBeNull(); + expect(payload.updatedAt).toBeNull(); + }); + + it("rejects malformed selection payloads", async () => { + const app = new Hono(); + registerSelectionRoutes(app, createAdapter()); + + const response = await app.request("http://localhost/projects/demo/selection", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ selection: { projectId: "demo" } }), + }); + + expect(response.status).toBe(400); + }); +}); diff --git a/packages/studio-server/src/routes/selection.ts b/packages/studio-server/src/routes/selection.ts new file mode 100644 index 0000000000..69aad1f4d5 --- /dev/null +++ b/packages/studio-server/src/routes/selection.ts @@ -0,0 +1,141 @@ +import type { Hono } from "hono"; +import type { + StudioApiAdapter, + StudioSelectionResponse, + StudioSelectionSnapshot, +} from "../types.js"; + +interface StoredSelection { + selection: StudioSelectionSnapshot; + updatedAt: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function isStringRecord(value: unknown): value is Record { + return isRecord(value) && Object.values(value).every((v) => typeof v === "string"); +} + +function hasString(value: Record, key: string): boolean { + return typeof value[key] === "string"; +} + +function hasRequiredStrings(value: Record, keys: string[]): boolean { + return keys.every((key) => hasString(value, key)); +} + +function hasOptionalString(value: Record, key: string): boolean { + return value[key] === undefined || typeof value[key] === "string"; +} + +function hasOptionalNullableString(value: Record, key: string): boolean { + return value[key] == null || typeof value[key] === "string"; +} + +function hasOptionalNumber(value: Record, key: string): boolean { + return value[key] === undefined || isFiniteNumber(value[key]); +} + +function isBoundingBox(value: unknown): value is StudioSelectionSnapshot["boundingBox"] { + return ( + isRecord(value) && ["x", "y", "width", "height"].every((key) => isFiniteNumber(value[key])) + ); +} + +function isTarget(value: unknown): value is StudioSelectionSnapshot["target"] { + if (!isRecord(value)) return false; + return ( + hasOptionalNullableString(value, "id") && + hasOptionalString(value, "hfId") && + hasOptionalString(value, "selector") && + hasOptionalNumber(value, "selectorIndex") + ); +} + +function isTextField(value: unknown): value is StudioSelectionSnapshot["textFields"][number] { + return ( + isRecord(value) && + hasRequiredStrings(value, ["key", "label", "value", "tagName"]) && + ["self", "child", "text-node"].includes(value.source as string) + ); +} + +function isTextFields(value: unknown): value is StudioSelectionSnapshot["textFields"] { + return Array.isArray(value) && value.every(isTextField); +} + +function isSelectionSnapshot(value: unknown): value is StudioSelectionSnapshot { + if (!isRecord(value)) return false; + + const checks = [ + value.schemaVersion === 1 && + hasRequiredStrings(value, [ + "projectId", + "compositionPath", + "sourceFile", + "label", + "tagName", + "thumbnailUrl", + ]), + isFiniteNumber(value.currentTime), + isTarget(value.target), + isBoundingBox(value.boundingBox), + value.textContent === null || typeof value.textContent === "string", + isStringRecord(value.dataAttributes), + isStringRecord(value.inlineStyles), + isStringRecord(value.computedStyles), + isTextFields(value.textFields), + isRecord(value.capabilities), + ]; + + return checks.every(Boolean); +} + +export function registerSelectionRoutes(api: Hono, adapter: StudioApiAdapter): void { + const selections = new Map(); + + api.get("/projects/:id/selection", async (c) => { + const project = await adapter.resolveProject(c.req.param("id")); + if (!project) return c.json({ error: "not found" }, 404); + const stored = selections.get(project.id); + return c.json({ + selection: stored?.selection ?? null, + updatedAt: stored?.updatedAt ?? null, + } satisfies StudioSelectionResponse); + }); + + api.put("/projects/:id/selection", async (c) => { + const project = await adapter.resolveProject(c.req.param("id")); + if (!project) return c.json({ error: "not found" }, 404); + + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "invalid json" }, 400); + } + if (!isRecord(body) || !("selection" in body)) { + return c.json({ error: "missing selection" }, 400); + } + + if (body.selection === null) { + selections.delete(project.id); + return c.json({ ok: true, selection: null, updatedAt: null }); + } + + if (!isSelectionSnapshot(body.selection)) { + return c.json({ error: "invalid selection" }, 400); + } + + const selection = { ...body.selection, projectId: project.id }; + const updatedAt = new Date().toISOString(); + selections.set(project.id, { selection, updatedAt }); + return c.json({ ok: true, selection, updatedAt }); + }); +} diff --git a/packages/studio-server/src/types.ts b/packages/studio-server/src/types.ts index 27a6bc5ddb..97e5aca9fa 100644 --- a/packages/studio-server/src/types.ts +++ b/packages/studio-server/src/types.ts @@ -29,6 +29,43 @@ export interface LintResult { }>; } +export interface StudioSelectionTextField { + key: string; + label: string; + value: string; + tagName: string; + source: "self" | "child" | "text-node"; +} + +export interface StudioSelectionSnapshot { + schemaVersion: 1; + projectId: string; + compositionPath: string; + sourceFile: string; + currentTime: number; + target: { + id?: string | null; + hfId?: string; + selector?: string; + selectorIndex?: number; + }; + label: string; + tagName: string; + boundingBox: { x: number; y: number; width: number; height: number }; + textContent: string | null; + dataAttributes: Record; + inlineStyles: Record; + computedStyles: Record; + textFields: StudioSelectionTextField[]; + capabilities: Record; + thumbnailUrl: string; +} + +export interface StudioSelectionResponse { + selection: StudioSelectionSnapshot | null; + updatedAt: string | null; +} + /** * Adapter interface — injected by each consumer to handle host-specific behavior. * The shared API module calls these methods; each host (vite dev, CLI embedded) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index fe98759701..897061e22e 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -83,7 +83,7 @@ export function StudioApp() { const [previewIframe, setPreviewIframe] = useState(null); const [compositionLoading, setCompositionLoading] = useState(true); const [refreshKey, setRefreshKey] = useState(0); - const [, setPreviewDocumentVersion] = useState(0); + const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0); const [blockPreview, setBlockPreview] = useState(null); const previewIframeRef = useRef(null); const activeCompPathRef = useRef(activeCompPath); @@ -296,6 +296,7 @@ export function StudioApp() { projectIdRef: fileManager.projectIdRef, previewIframe, refreshKey, + previewDocumentVersion, rightPanelTab: panelLayout.rightPanelTab, applyStudioManualEditsToPreviewRef: previewPersistence.applyStudioManualEditsToPreviewRef, syncPreviewHistoryHotkey: appHotkeys.syncPreviewHistoryHotkey, diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index fbdba55ce9..5741bbc3b0 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -18,6 +18,7 @@ import { useGsapScriptCommits } from "./useGsapScriptCommits"; import { useGsapCacheVersion } from "./useGsapTweenCache"; import { useDomEditWiring } from "./useDomEditWiring"; import { useGsapAwareEditing } from "./useGsapAwareEditing"; +import { useStudioSelectionPublisher } from "./useStudioSelectionPublisher"; // ── Types ── @@ -54,6 +55,7 @@ export interface UseDomEditSessionParams { projectIdRef: React.MutableRefObject; previewIframe: HTMLIFrameElement | null; refreshKey: number; + previewDocumentVersion: number; rightPanelTab: RightPanelTab; applyStudioManualEditsToPreviewRef: React.MutableRefObject< (iframe: HTMLIFrameElement) => Promise @@ -96,6 +98,7 @@ export function useDomEditSession({ projectIdRef, previewIframe, refreshKey, + previewDocumentVersion, rightPanelTab, applyStudioManualEditsToPreviewRef, syncPreviewHistoryHotkey, @@ -168,6 +171,15 @@ export function useDomEditSession({ domEditSelection, }); + useStudioSelectionPublisher({ + projectId, + domEditSelection, + domEditSelectionRef, + refreshKey, + previewDocumentVersion, + refreshDomEditSelectionFromPreview, + }); + // ── GSAP cache (hoisted so both useGsapScriptCommits and useDomEditWiring share the same instance) ── const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion(); diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index c3f50874a6..d7a7af35e4 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -381,7 +381,10 @@ export function useDomSelection({ if (!doc) return; const element = findElementForSelection(doc, selection, activeCompPath); - if (!element) return; + if (!element) { + applyDomSelection(null, { revealPanel: false }); + return; + } const nextSelection = await buildDomSelectionFromTarget(element); if (nextSelection) { diff --git a/packages/studio/src/hooks/useStudioSelectionPublisher.ts b/packages/studio/src/hooks/useStudioSelectionPublisher.ts new file mode 100644 index 0000000000..c4d2e09f2e --- /dev/null +++ b/packages/studio/src/hooks/useStudioSelectionPublisher.ts @@ -0,0 +1,102 @@ +import { useEffect, useRef, type MutableRefObject } from "react"; +import type { DomEditSelection } from "../components/editor/domEditing"; +import { usePlayerStore } from "../player"; +import { buildStudioSelectionSnapshot } from "../utils/studioSelectionSnapshot"; +import { trackStudioEvent } from "../utils/studioTelemetry"; + +interface UseStudioSelectionPublisherParams { + projectId: string | null; + domEditSelection: DomEditSelection | null; + domEditSelectionRef: MutableRefObject; + refreshKey: number; + previewDocumentVersion: number; + refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise; +} + +function reportSelectionPublishError(error: unknown): void { + if (error instanceof Error && error.name === "AbortError") return; + const errorName = error instanceof Error ? error.name : typeof error; + const errorMessage = error instanceof Error ? error.message : String(error); + trackStudioEvent("studio_selection_publish_failed", { + error_name: errorName, + error_message: errorMessage.slice(0, 500), + }); + // eslint-disable-next-line no-console + console.warn("[Studio] Failed to update agent selection context", error); +} + +function putSelection(projectId: string, selection: unknown, signal?: AbortSignal): Promise { + return fetch(`/api/projects/${encodeURIComponent(projectId)}/selection`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ selection }), + signal, + }).then(() => undefined); +} + +export function useStudioSelectionPublisher({ + projectId, + domEditSelection, + domEditSelectionRef, + refreshKey, + previewDocumentVersion, + refreshDomEditSelectionFromPreview, +}: UseStudioSelectionPublisherParams): void { + const lastSelectionRefreshKeyRef = useRef(refreshKey); + const pendingSelectionRefreshKeyRef = useRef(null); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (!projectId) return; + const selection = domEditSelection?.element.isConnected + ? buildStudioSelectionSnapshot({ + projectId, + selection: domEditSelection, + currentTime: usePlayerStore.getState().currentTime, + }) + : null; + const controller = new AbortController(); + void putSelection(projectId, selection, controller.signal).catch(reportSelectionPublishError); + return () => controller.abort(); + }, [domEditSelection, projectId]); + + // Clear server-side agent context when Studio leaves a project. Without this, + // a long-running multi-project preview server can keep serving the last + // selected element for a project after its tab/session unmounts. + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (!projectId) return; + return () => { + void putSelection(projectId, null).catch(reportSelectionPublishError); + }; + }, [projectId]); + + // On external file edits, the iframe reloads while React keeps the previous + // DOM selection object alive. Clear the agent-facing snapshot immediately so + // `preview --context` never serves a detached or stale target, then let the + // post-load preview document refresh below re-resolve the selection if it + // still exists in the new document. + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (lastSelectionRefreshKeyRef.current === refreshKey) return; + lastSelectionRefreshKeyRef.current = refreshKey; + pendingSelectionRefreshKeyRef.current = domEditSelectionRef.current ? refreshKey : null; + if (!projectId || !domEditSelectionRef.current) return; + const controller = new AbortController(); + void putSelection(projectId, null, controller.signal).catch(reportSelectionPublishError); + return () => controller.abort(); + }, [domEditSelectionRef, projectId, refreshKey]); + + // `refreshPreviewDocumentVersion` ticks after iframe load and shortly after. + // Consume one pending refresh per external reload: enough to re-resolve the + // selected element once the new document is queryable, without republishing + // the same snapshot on every follow-up 80/300ms tick. + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (pendingSelectionRefreshKeyRef.current === null) return; + pendingSelectionRefreshKeyRef.current = null; + const selection = domEditSelectionRef.current; + if (!selection) return; + void refreshDomEditSelectionFromPreview(selection); + }, [domEditSelectionRef, previewDocumentVersion, refreshDomEditSelectionFromPreview]); +} diff --git a/packages/studio/src/utils/studioSelectionSnapshot.test.ts b/packages/studio/src/utils/studioSelectionSnapshot.test.ts new file mode 100644 index 0000000000..0175d3096f --- /dev/null +++ b/packages/studio/src/utils/studioSelectionSnapshot.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { buildStudioSelectionSnapshot } from "./studioSelectionSnapshot"; +import type { DomEditSelection } from "../components/editor/domEditing"; + +describe("buildStudioSelectionSnapshot", () => { + it("serializes a DOM edit selection without the live HTMLElement", () => { + const selection = { + element: { tagName: "H1" } as HTMLElement, + id: null, + hfId: "hero-title", + selector: ".title", + selectorIndex: 0, + label: "Hero title", + tagName: "h1", + sourceFile: "index.html", + compositionPath: "index.html", + isCompositionHost: false, + isInsideLockedComposition: false, + boundingBox: { x: 10, y: 20, width: 300, height: 64 }, + textContent: "Launch faster", + dataAttributes: { "data-hf-id": "hero-title" }, + inlineStyles: { color: "white" }, + computedStyles: { "font-size": "48px" }, + textFields: [], + capabilities: { canSelect: true, canEditStyles: true }, + } as DomEditSelection; + + const snapshot = buildStudioSelectionSnapshot({ + projectId: "demo", + selection, + currentTime: 1.25, + }); + + expect(snapshot).toMatchObject({ + schemaVersion: 1, + projectId: "demo", + compositionPath: "index.html", + sourceFile: "index.html", + currentTime: 1.25, + target: { hfId: "hero-title", selector: ".title", selectorIndex: 0 }, + thumbnailUrl: + "/api/projects/demo/thumbnail/index.html?t=1.25&format=png&selector=.title&selectorIndex=0", + }); + expect(JSON.stringify(snapshot)).not.toContain("HTMLElement"); + expect(snapshot).not.toHaveProperty("element"); + }); +}); diff --git a/packages/studio/src/utils/studioSelectionSnapshot.ts b/packages/studio/src/utils/studioSelectionSnapshot.ts new file mode 100644 index 0000000000..594dd47e17 --- /dev/null +++ b/packages/studio/src/utils/studioSelectionSnapshot.ts @@ -0,0 +1,72 @@ +import type { StudioSelectionSnapshot } from "@hyperframes/studio-server"; +import type { DomEditSelection } from "../components/editor/domEditing"; + +function round3(value: number): number { + return Math.round(value * 1000) / 1000; +} + +function thumbnailUrl({ + projectId, + selection, + currentTime, +}: { + projectId: string; + selection: DomEditSelection; + currentTime: number; +}): string { + const compPath = encodeURIComponent( + selection.compositionPath || selection.sourceFile || "index.html", + ); + const params = new URLSearchParams({ + t: String(round3(currentTime)), + format: "png", + }); + if (selection.selector) params.set("selector", selection.selector); + if (selection.selectorIndex != null) params.set("selectorIndex", String(selection.selectorIndex)); + return `/api/projects/${encodeURIComponent(projectId)}/thumbnail/${compPath}?${params.toString()}`; +} + +export function buildStudioSelectionSnapshot({ + projectId, + selection, + currentTime, +}: { + projectId: string; + selection: DomEditSelection; + currentTime: number; +}): StudioSelectionSnapshot { + return { + schemaVersion: 1, + projectId, + compositionPath: selection.compositionPath, + sourceFile: selection.sourceFile, + currentTime: round3(currentTime), + target: { + id: selection.id, + hfId: selection.hfId, + selector: selection.selector, + selectorIndex: selection.selectorIndex, + }, + label: selection.label, + tagName: selection.tagName, + boundingBox: { + x: round3(selection.boundingBox.x), + y: round3(selection.boundingBox.y), + width: round3(selection.boundingBox.width), + height: round3(selection.boundingBox.height), + }, + textContent: selection.textContent, + dataAttributes: { ...selection.dataAttributes }, + inlineStyles: { ...selection.inlineStyles }, + computedStyles: { ...selection.computedStyles }, + textFields: selection.textFields.map((field) => ({ + key: field.key, + label: field.label, + value: field.value, + tagName: field.tagName, + source: field.source, + })), + capabilities: { ...selection.capabilities }, + thumbnailUrl: thumbnailUrl({ projectId, selection, currentTime }), + }; +} diff --git a/skills-manifest.json b/skills-manifest.json index 165fa14ff2..d8591b9eb6 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -22,7 +22,7 @@ "files": 115 }, "hyperframes-cli": { - "hash": "e493e8902805efef", + "hash": "be5af457d0b3d2ff", "files": 7 }, "hyperframes-core": { diff --git a/skills/hyperframes-cli/SKILL.md b/skills/hyperframes-cli/SKILL.md index 026d56c2cc..8ecfec8f79 100644 --- a/skills/hyperframes-cli/SKILL.md +++ b/skills/hyperframes-cli/SKILL.md @@ -14,7 +14,7 @@ Everything runs through `npx hyperframes` unless project instructions specify a 3. **Lint** — `npx hyperframes lint` 4. **Validate** — `npx hyperframes validate` (runtime errors + contrast) 5. **Visual inspect** — `npx hyperframes inspect` -6. **Preview** — `npx hyperframes preview` opens **Studio**, the timeline editor where the user can manually edit anything (not just watch). Review there, then ask before rendering. +6. **Preview / edit** — `npx hyperframes preview` opens **Studio**, the timeline editor where the user can manually edit anything (not just watch). Review there, then ask before rendering. 7. **Render** — pick the variant: - Iterate: `npx hyperframes render --quality draft` - Deliver: `npx hyperframes render --quality high --output out.mp4` @@ -29,12 +29,14 @@ For motion-heavy work, prefer snapshot-driven iteration and a `*.motion.json` si Cross-cutting rules that hold for every command: -- **`--json` is available on every command except `render`, `preview`, and `play`.** Use it for any agent / CI invocation of the supported commands; output includes a `_meta` envelope (cli version, latest available, update advice). `render` reports status via stdout + exit code only — verify success with the post-render check below; `preview` / `play` are servers, no JSON. +- **`--json` is available on every command except `render`, `preview`, and `play` server modes.** Use it for any agent / CI invocation of the supported commands; output includes a `_meta` envelope (cli version, latest available, update advice). `render` reports status via stdout + exit code only — verify success with the post-render check below. `preview --selection --json` and `preview --context --json` are the preview exceptions: they do not start a server, they query the user's running Studio session and exit. - **`doctor --json` always exits 0**, even when the environment is broken. Gate on the payload's `ok` field: `npx hyperframes doctor --json | jq -e '.ok' > /dev/null`. This insulates pipelines from CLI release churn. - **Non-TTY mode is auto-detected.** When `stdout` is not a TTY (CI, agents, piped output) the CLI auto-switches to non-interactive; `init` then **requires `--example`**. Pass `--non-interactive` to force this mode even on a TTY. - **CI gating on render**: `--strict` fails on lint errors, `--strict-all` fails on warnings too, `--strict-variables` fails on undeclared `--variables` keys. - **Paths in `--json` are redacted** — `$HOME` becomes the literal `$HOME` so output is safe to paste into bug reports and agent contexts. - **Render is user-gated.** Never auto-render once the checks pass. Pause at `preview`, tell the user the video is editable in Studio, and render only after they approve. +- **Use Studio context for user-directed edits.** When the user says "this selected element", "the thing I clicked", "current selection", or similar, ask them to select it in Studio, then run `npx hyperframes preview --context --json --context-fields selection`. Use the returned `selection.target.hfId` / `selector`, `selection.sourceFile`, `selection.currentTime`, and `selection.thumbnailUrl` to anchor the edit. If `selection` is `null` and `errors.selection.code` is `no-selection`, ask the user to click the element and rerun; do not guess from screenshots. +- **Keep Studio context compact.** `preview --context --json` returns compact selection by default. Add `--context-fields selection`, `--context-fields selection,lint`, or `--context-fields lint` to avoid bloating agent context. Use `--context-detail full` only when you need heavy fields like computed styles, inline styles, or text-field metadata. - **Post-render verification.** After `render` returns exit 0, confirm the output file exists and has plausible size before reporting success: `[ -s "$OUTPUT" ] || echo "render produced no output"`. The CLI prints `◇ ` on success; for long renders also sanity-check duration with `ffprobe -i "$OUTPUT" -show_format -v error`. ## Routing diff --git a/skills/hyperframes-cli/references/preview-render.md b/skills/hyperframes-cli/references/preview-render.md index 16447fce78..caa45cc95e 100644 --- a/skills/hyperframes-cli/references/preview-render.md +++ b/skills/hyperframes-cli/references/preview-render.md @@ -7,6 +7,8 @@ Serve, render, and share commands. ```bash npx hyperframes preview # serve current directory npx hyperframes preview --port 4567 # custom port (default 3002) +npx hyperframes preview --selection --json # print the current Studio selection and exit +npx hyperframes preview --context --json # print compact agent context from Studio ``` Hot-reloads on file changes. Opens Studio in the browser automatically — the full timeline editor, where the user can play the video and edit anything by hand before rendering. This is the review surface, not just a viewer. @@ -19,6 +21,46 @@ http://localhost:/#project/ Use the actual port and project directory name; treat `index.html` as source-code context, not the preview surface. For example, after `npx hyperframes preview --port 3017` in `codex-openai-video`, report `http://localhost:3017/#project/codex-openai-video`. +### Agent context from Studio selection + +`preview --context` and `preview --selection` are the agent bridge into a running Studio session. They do **not** start a new server; they find the active preview server for the current project, read agent-useful state from Studio, print it, and exit. + +Use it when the user gives deictic edit instructions like "change this", "move the selected element", "make the card I clicked bigger", or "fix the current selection": + +```bash +npx hyperframes preview --context --json --context-fields selection +``` + +The compact context payload includes the selected element's source file, composition path, current timeline time, `data-hf-id` / selector target, bounding box, text content, and a thumbnail URL for the selected element. Prefer `selection.target.hfId` when present; fall back to `selection.target.selector` only when no stable `data-hf-id` exists. If `selection` is `null`, inspect `errors.selection.code` (for example, `no-selection`). + +Keep agent context small by asking only for the slices you need: + +```bash +npx hyperframes preview --context --json --context-fields selection +npx hyperframes preview --context --json --context-fields lint +npx hyperframes preview --context --json --context-fields selection,lint +``` + +Use `--context-detail full` only when the edit genuinely needs heavy selection fields such as `computedStyles`, `inlineStyles`, `dataAttributes`, or editable text-field metadata: + +```bash +npx hyperframes preview --context --json --context-fields selection --context-detail full +``` + +`preview --selection --json` remains available when you explicitly want the full selected-element payload and do not need lint/server context. + +Failure modes: + +| Code | Meaning | +| -------------------------- | -------------------------------------------------------------------------- | +| `preview-not-running` | Start Studio first with `npx hyperframes preview`. | +| `ambiguous-preview-server` | Multiple matching Studio servers are open; rerun with one listed `--port`. | +| `preview-port-mismatch` | The requested `--port` is not one of the matching Studio servers. | +| `no-selection` | Studio is open, but the user has not selected an element yet. | +| `selection-unavailable` | The running preview server does not expose selection context cleanly. | + +If there is no selection, ask the user to click the target element in Studio and rerun the command. If the server error lists candidate ports, rerun the same command with `--port `. Do not infer the target from a screenshot when the CLI can give a stable element target. + ## play (lightweight player) ```bash