mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
feat(cli): expose Studio selection through preview (#1777)
Add a small Studio selection channel so agents can ask a running preview server for the element the user selected in Studio. This keeps the UX on the existing npx hyperframes preview surface while giving agents a stable source file, target selector, timeline time, and thumbnail URL for follow-up edits.
This commit is contained in:
@@ -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 <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<ActiveServer[]> = scanActiveServers,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
options: FindPreviewServerOptions = {},
|
||||
): Promise<ActiveServer | null> {
|
||||
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<ActiveServer | null> {
|
||||
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<StudioSelectionResponse> {
|
||||
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<StudioLintResponse> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user