diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index ffb25e040b..9ef5192800 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -438,6 +438,8 @@ Start a live preview server with hot reload. npx hyperframes preview [dir] npx hyperframes preview --port 4567 npx hyperframes preview --background # keep running after the command exits +npx hyperframes preview --foreground # stay attached in a non-interactive shell +npx hyperframes preview --status --json # inspect a managed preview from an agent npx hyperframes preview --list # every running preview ``` @@ -446,6 +448,8 @@ npx hyperframes preview --list # every running preview | `--port` | Server port (default 3002) | | `--open` / `--no-open` | Open a browser, or leave it closed | | `--background` | Keep an embedded preview running after the command exits | +| `--foreground` | Keep the preview attached even when the shell is non-interactive | +| `--json` | Emit one versioned JSON result for managed start, status, stop, list, and kill-all operations | | `--browser-gpu` / `--no-browser-gpu` | Hardware GPU for Studio thumbnails and frame capture, or deterministic SwiftShader (default: auto-detect) | | `--proxy` / `--no-proxy` | Auto-transcode browser-hostile codecs (HEVC, ProRes, AV1) to a cached authoring proxy (default: on) | | `--browser-path` | Open a specific browser. `--user-data-dir`, `--remote-debugging-port`, and `--browser-no-gpu` require it. | @@ -455,6 +459,13 @@ background preview, `--list` and `--kill-all` act on all of them, and `--force-new` starts a second server for a project that already has one. Each exits straight after. +Bare `preview` chooses the safest lifecycle for its caller: it stays in the +foreground in a human interactive terminal, while a non-interactive or agent +shell starts a managed background preview. Re-running the command for the same +project reuses the healthy preview. Every start or status result includes the +exact Studio project URL as well as the underlying server URL, so agents can +hand off the intended project without guessing from the port. + To read a running Studio from a script: `--selection` prints the selected element and `--context` prints the agent-readable context, both with `--json`. Narrow the context with `--context-fields` (`server`, `selection`, `lint`, diff --git a/packages/cli/README.md b/packages/cli/README.md index fffe6cc26e..a1dc03e5c4 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -33,11 +33,20 @@ Start the live preview studio in your browser: ```bash npx hyperframes preview -# Studio running at http://localhost:3002 +# Studio: http://localhost:3002/#project/my-video +# Server: http://localhost:3002 npx hyperframes preview --port 4567 ``` +In an interactive terminal, the preview stays attached until you press +Ctrl+C. In a non-interactive shell such as a coding-agent session, the same +command starts a managed preview that survives after the command returns. Use +`--background` or `--foreground` to choose explicitly, and manage persistent +previews with `--status`, `--stop`, `--list`, and `--kill-all`. Add `--json` to +managed lifecycle commands for machine-readable output. `--foreground --json` +prints the ready-session envelope once, then remains attached until stopped. + ### `render` Render a composition to MP4. Run from the project directory; the positional diff --git a/packages/cli/src/commands/coreSkillContent.test.ts b/packages/cli/src/commands/coreSkillContent.test.ts index fe25f5764d..635579de30 100644 --- a/packages/cli/src/commands/coreSkillContent.test.ts +++ b/packages/cli/src/commands/coreSkillContent.test.ts @@ -125,4 +125,15 @@ describe("media treatment routing documentation", () => { expect(template).toContain("do not improvise equivalent CSS/SVG filters or overlays"); } }); + + it("gives agents a process-owned preview lifecycle in new project instructions", () => { + for (const file of ["AGENTS.md", "CLAUDE.md"]) { + const template = read("packages", "cli", "src", "templates", "_shared", file); + expect(template).toContain("npx hyperframes preview --background"); + expect(template).toContain("npx hyperframes preview --status"); + expect(template).toContain("npx hyperframes preview --stop"); + expect(template).toContain("leaving refreshes at `ERR_CONNECTION_TIMED_OUT`"); + expect(template).not.toContain("run_in_background: true"); + } + }); }); diff --git a/packages/cli/src/commands/preview.test.ts b/packages/cli/src/commands/preview.test.ts index 38b02f58e6..a9586179de 100644 --- a/packages/cli/src/commands/preview.test.ts +++ b/packages/cli/src/commands/preview.test.ts @@ -1,13 +1,32 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { studioLandingSearch } from "./preview.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { runCommand } from "citty"; +import { + default as previewCommand, + foregroundPreviewReadyPayload, + handlePreviewKillAll, + handlePreviewList, + previewLaunchMode, + previewLaunchModeError, + previewPortError, + publicPreviewPid, + previewViteArgs, + reportPreviewShutdown, + studioReadyUrl, + studioDeepLink, + studioLandingSearch, + studioSummaryUrls, + waitForStudioChildClose, +} from "./preview.js"; const tempDirs: string[] = []; afterEach(() => { for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); + process.exitCode = undefined; }); function projectWith(storyboard: string | null, frameFiles: string[] = []): string { @@ -51,3 +70,363 @@ describe("studioLandingSearch", () => { expect(studioLandingSearch(dir)).toBe(""); }); }); + +describe("Studio handoff URLs", () => { + it("hands off the exact timeline project route", () => { + const dir = projectWith(null); + expect(studioDeepLink("http://127.0.0.1:3002", "demo", dir)).toBe( + "http://127.0.0.1:3002/#project/demo", + ); + expect(studioSummaryUrls("demo", "http://127.0.0.1:3002", dir)).toEqual({ + serverUrl: "http://127.0.0.1:3002", + studioUrl: "http://127.0.0.1:3002/#project/demo", + }); + }); + + it("hands off the exact storyboard route while a project is still planning", () => { + const dir = projectWith(FRAME(1, "outline")); + expect(studioDeepLink("http://127.0.0.1:3002", "demo", dir)).toBe( + "http://127.0.0.1:3002/?view=storyboard#project/demo", + ); + }); + + it("URL-encodes project names that have hash-route metacharacters", () => { + const dir = projectWith(null); + expect(studioDeepLink("http://127.0.0.1:3002", "Launch #1? 50%", dir)).toBe( + "http://127.0.0.1:3002/#project/Launch%20%231%3F%2050%25", + ); + }); +}); + +describe("previewLaunchMode", () => { + it.each([ + [ + { + background: false, + foreground: false, + interactive: false, + devMode: false, + localStudio: false, + }, + "background", + ], + [ + { + background: false, + foreground: false, + interactive: true, + devMode: false, + localStudio: false, + }, + "embedded", + ], + [ + { + background: false, + foreground: true, + interactive: false, + devMode: true, + localStudio: false, + }, + "dev", + ], + [ + { + background: false, + foreground: true, + interactive: false, + devMode: false, + localStudio: true, + }, + "local", + ], + [ + { + background: true, + foreground: false, + interactive: true, + devMode: true, + localStudio: true, + }, + "background", + ], + ] as const)("resolves %o to %s", (options, expected) => { + expect(previewLaunchMode(options)).toBe(expected); + }); + + it("rejects conflicting lifecycle overrides and actions", () => { + expect( + previewLaunchModeError({ + background: true, + foreground: true, + status: false, + stop: false, + list: false, + killAll: false, + }), + ).toBe("--background and --foreground cannot be used together"); + expect( + previewLaunchModeError({ + background: false, + foreground: false, + status: true, + stop: true, + list: false, + killAll: false, + }), + ).toBe("Only one of --status, --stop, --list, or --kill-all can be used at a time"); + expect( + previewLaunchModeError({ + background: true, + foreground: false, + status: false, + stop: false, + list: false, + killAll: false, + }), + ).toBeNull(); + expect( + previewLaunchModeError({ + background: true, + foreground: false, + status: true, + stop: false, + list: false, + killAll: false, + }), + ).toBe("Preview launch overrides cannot be combined with lifecycle actions"); + expect( + previewLaunchModeError({ + background: false, + foreground: true, + status: false, + stop: false, + list: false, + killAll: true, + }), + ).toBe("Preview launch overrides cannot be combined with lifecycle actions"); + expect( + previewLaunchModeError({ + background: false, + foreground: false, + forceNew: true, + status: true, + stop: false, + list: false, + killAll: false, + }), + ).toBe("Preview launch overrides cannot be combined with lifecycle actions"); + }); + + it.each([ + [undefined, null], + ["3002", null], + ["1", null], + ["65535", null], + ["banana", "--port must be an integer between 1 and 65535"], + ["3002oops", "--port must be an integer between 1 and 65535"], + ["0", "--port must be an integer between 1 and 65535"], + ["65536", "--port must be an integer between 1 and 65535"], + ])("validates preview port %j", (value, expected) => { + expect(previewPortError(value)).toBe(expected); + }); + + it("prefers the live server PID over its launcher PID", () => { + expect(publicPreviewPid("9876", 4321)).toBe(9876); + expect(publicPreviewPid(null, 4321)).toBe(4321); + }); + + it("pins detached Vite to the port the lifecycle scanner waits on", () => { + expect(previewViteArgs(3032)).toEqual(["--host", "127.0.0.1", "--port", "3032"]); + }); + + it.each([ + [" Local: http://localhost:43127/", "http://localhost:43127"], + [" Local: http://127.0.0.1:43127/", "http://127.0.0.1:43127"], + [ + "\u001b[32m Local:\u001b[0m \u001b[36mhttp://127.0.0.1:43127/\u001b[0m", + "http://127.0.0.1:43127", + ], + ])("extracts the ready URL from Vite output %j", (output, expected) => { + expect(studioReadyUrl(output)).toBe(expected); + }); +}); + +describe("preview lifecycle JSON failures", () => { + it.each([ + [ + "list", + () => + handlePreviewList(3002, true, { + scan: async () => { + throw new Error("list probe failed"); + }, + listManaged: async () => [], + }), + "preview-list-failed", + ], + [ + "kill-all", + () => + handlePreviewKillAll(3002, true, { + listManaged: async () => [ + { + pid: 4321, + port: 41402, + projectDir: "/tmp/managed-preview", + logPath: "/tmp/managed-preview.log", + }, + ], + stopManaged: async () => { + throw new Error("ownership failed"); + }, + killScanned: async () => 0, + }), + "preview-kill-all-failed", + ], + ] as const)("wraps %s failures in one JSON document", async (operation, run, code) => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + await run(); + + expect(log).toHaveBeenCalledOnce(); + const [line] = log.mock.calls[0] as [string]; + expect(JSON.parse(line)).toMatchObject({ + schemaVersion: 1, + operation, + ok: false, + error: { code }, + }); + expect(error).not.toHaveBeenCalled(); + }); + + it("wraps managed-start validation failures in one JSON document", async () => { + const dir = projectWith(null); + writeFileSync(join(dir, "index.html"), ""); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runCommand(previewCommand, { + rawArgs: [dir, "--background", "--json", "--user-data-dir", join(dir, "profile")], + }); + + expect(log).toHaveBeenCalledOnce(); + const [line] = log.mock.calls[0] as [string]; + expect(JSON.parse(line)).toMatchObject({ + schemaVersion: 1, + operation: "start", + ok: false, + error: { code: "preview-validation-failed" }, + }); + }); + + it("wraps stop failures in one JSON document", async () => { + const missing = join(tmpdir(), `hf-preview-missing-${process.pid}-${Date.now()}`); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + await runCommand(previewCommand, { + rawArgs: [missing, "--stop", "--json"], + }); + + expect(log).toHaveBeenCalledOnce(); + const [line] = log.mock.calls[0] as [string]; + expect(JSON.parse(line)).toMatchObject({ + schemaVersion: 1, + operation: "stop", + ok: false, + error: { code: "preview-stop-failed" }, + }); + expect(error).not.toHaveBeenCalled(); + }); + + it("wraps missing-project start failures without human stderr", async () => { + const missing = join(tmpdir(), `hf-preview-missing-start-${process.pid}-${Date.now()}`); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + await runCommand(previewCommand, { rawArgs: [missing, "--background", "--json"] }); + + expect(log).toHaveBeenCalledOnce(); + const [line] = log.mock.calls[0] as [string]; + expect(JSON.parse(line)).toMatchObject({ + operation: "start", + ok: false, + error: { code: "preview-start-failed" }, + }); + expect(error).not.toHaveBeenCalled(); + }); +}); + +describe("foreground preview JSON", () => { + it("emits the same ready session contract before remaining attached", () => { + const dir = projectWith(null); + expect(foregroundPreviewReadyPayload("Launch #1", "http://localhost:4567", dir, 4321)).toEqual({ + schemaVersion: 1, + operation: "start", + ok: true, + result: { + state: "started", + mode: "foreground", + projectName: "Launch #1", + projectDir: dir, + host: "127.0.0.1", + port: 4567, + pid: 4321, + serverUrl: "http://127.0.0.1:4567", + studioUrl: "http://127.0.0.1:4567/#project/Launch%20%231", + ready: true, + }, + }); + }); + + it("keeps embedded shutdown silent after the readiness envelope", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + reportPreviewShutdown(true); + + expect(log).not.toHaveBeenCalled(); + }); +}); + +describe("waitForStudioChildClose", () => { + it("resolves when the child closed before the listener was attached", async () => { + const signalTarget = { once: vi.fn(), off: vi.fn() }; + const child = { + exitCode: 1, + signalCode: null, + once: vi.fn(), + } as unknown as Parameters[0]; + + await expect(waitForStudioChildClose(child, signalTarget)).resolves.toBeUndefined(); + expect(child.once).not.toHaveBeenCalled(); + expect(signalTarget.once).toHaveBeenCalledTimes(2); + expect(signalTarget.off).toHaveBeenCalledTimes(2); + }); + + it("reaps on process exit even when stdio never emits close", async () => { + let exit: (() => void) | undefined; + const signalTarget = { once: vi.fn(), off: vi.fn() }; + const child = { + exitCode: null, + signalCode: null, + once: vi.fn((event: string, listener: () => void) => { + if (event === "exit") exit = listener; + }), + } as unknown as Parameters[0]; + + let resolved = false; + const waiting = waitForStudioChildClose(child, signalTarget).then(() => { + resolved = true; + }); + + await Promise.resolve(); + expect(resolved).toBe(false); + expect(child.once).toHaveBeenCalledWith("exit", expect.any(Function)); + + exit?.(); + await waiting; + expect(resolved).toBe(true); + expect(signalTarget.off).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 1c297427f4..822eb99a65 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -13,6 +13,7 @@ export const examples: Example[] = [ ["Use a custom port", "hyperframes preview --port 8080"], ["Force a new server even if one is already running", "hyperframes preview --force-new"], ["Keep preview running after this command exits", "hyperframes preview --background"], + ["Force an attached preview in a non-interactive shell", "hyperframes preview --foreground"], ["Show the background preview for this project", "hyperframes preview --status"], ["Stop the background preview for this project", "hyperframes preview --stop"], ["Start without opening the browser", "hyperframes preview --no-open"], @@ -55,20 +56,30 @@ import { import { lintProject } from "../utils/lintProject.js"; import { formatLintFindings } from "../utils/lintFormat.js"; import { + activeServerOnPort, findPortAndServe, scanActiveServers, killActiveServers, type FindPortResult, } from "../server/portUtils.js"; import { killOrphanedProcesses, killProcessTree } from "../utils/orphanCleanup.js"; -import { resolveProject } from "../utils/project.js"; +import { resolveProject, resolveProjectOrThrow } from "../utils/project.js"; import { resolveAutoProxy } from "../utils/projectConfig.js"; import { studioProxyEnv } from "../utils/studioProxyEnv.js"; import { + listBackgroundPreviewStatuses, readBackgroundPreviewStatus, startBackgroundPreview, stopBackgroundPreview, } from "./previewLifecycle.js"; +import { + lifecycleFailurePayload, + lifecyclePayload, + writeLifecycleJson, + type PreviewLifecycleOperation, + type PreviewLifecyclePayload, + type PreviewLifecycleSession, +} from "./previewLifecycleOutput.js"; import { resolveLocalBrowserGpuMode, type BrowserGpuMode } from "../browser/gpuPolicy.js"; interface BrowserLaunchOptions { @@ -83,6 +94,8 @@ interface StudioLaunchOptions extends BrowserLaunchOptions { projectName?: string; autoProxy?: boolean; browserGpuMode?: BrowserGpuMode; + port?: number; + json?: boolean; } interface EmbeddedStudioOptions extends StudioLaunchOptions { @@ -91,6 +104,10 @@ interface EmbeddedStudioOptions extends StudioLaunchOptions { } type StudioChildProcess = ChildProcessByStdio; +interface StudioSignalTarget { + once(event: "SIGINT" | "SIGTERM", listener: () => void): unknown; + off(event: "SIGINT" | "SIGTERM", listener: () => void): unknown; +} type ContextField = "server" | "selection" | "lint" | "capabilities"; type CompactSelectionPayload = Pick< StudioSelectionSnapshot, @@ -110,10 +127,21 @@ type CompactSelectionPayload = Pick< const DEFAULT_CONTEXT_FIELDS: ContextField[] = ["server", "selection", "lint", "capabilities"]; export default defineCommand({ - meta: { name: "preview", description: "Start the studio for previewing compositions" }, + meta: { + name: "preview", + description: "Start the studio for previewing compositions", + }, args: { - dir: { type: "positional", description: "Project directory", required: false }, - port: { type: "string", description: "Port to run the preview server on", default: "3002" }, + dir: { + type: "positional", + description: "Project directory", + required: false, + }, + port: { + type: "string", + description: "Port to run the preview server on", + default: "3002", + }, "force-new": { type: "boolean", description: "Start a new server even if one is already running for this project", @@ -121,7 +149,12 @@ export default defineCommand({ }, background: { type: "boolean", - description: "Start an embedded preview that remains running after the command exits", + description: "Start a preview that remains running after the command exits", + default: false, + }, + foreground: { + type: "boolean", + description: "Keep preview attached even when the shell is non-interactive", default: false, }, status: { @@ -156,7 +189,7 @@ export default defineCommand({ }, json: { type: "boolean", - description: "Output preview selection/context as JSON (only with --selection or --context)", + description: "Output selection, context, or managed lifecycle state as JSON", default: false, }, context: { @@ -207,6 +240,58 @@ export default defineCommand({ }, }, async run({ args }) { + const launchModeError = previewLaunchModeError({ + background: Boolean(args.background), + foreground: Boolean(args.foreground), + forceNew: Boolean(args["force-new"]), + status: Boolean(args.status), + stop: Boolean(args.stop), + list: Boolean(args.list), + killAll: Boolean(args["kill-all"]), + }); + if (launchModeError) { + if (args.json) { + writeLifecycleJson( + lifecycleFailurePayload( + args.status + ? "status" + : args.stop + ? "stop" + : args.list + ? "list" + : args["kill-all"] + ? "kill-all" + : "start", + "conflicting-lifecycle-flags", + launchModeError, + ), + ); + } else { + clack.log.error(launchModeError); + } + setCommandExitCode(1); + return; + } + + const portError = previewPortError(args.port); + if (portError) { + reportPreviewFailure( + Boolean(args.json), + args.status + ? "status" + : args.stop + ? "stop" + : args.list + ? "list" + : args["kill-all"] + ? "kill-all" + : "start", + "preview-validation-failed", + portError, + ); + return; + } + const browserGpuMode = resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined); if (args["browser-gpu"] === true) process.env.PRODUCER_BROWSER_GPU_MODE = "hardware"; if (args["browser-gpu"] === false) process.env.PRODUCER_BROWSER_GPU_MODE = "software"; @@ -214,56 +299,83 @@ export default defineCommand({ const preferredContextPort = hasExplicitPreviewPort(process.argv) ? startPort : undefined; if (args.status || args.stop) { - const project = resolveProject(args.dir); - if (args.stop) { - const stopped = await stopBackgroundPreview(project.dir, startPort); - console.log( - stopped - ? `\n ${c.success("Stopped background preview")} ${c.dim(project.dir)}\n` - : `\n ${c.dim("No background preview is running for")} ${project.dir}\n`, - ); + try { + const project = args.json ? resolveProjectOrThrow(args.dir) : resolveProject(args.dir); + if (args.stop) { + const stopped = await stopBackgroundPreview(project.dir, startPort); + if (args.json) { + writeLifecycleJson( + lifecyclePayload( + "stop", + stopped + ? { state: "stopped", projectDir: project.dir } + : { state: "not-running", projectDir: project.dir }, + ), + ); + } else { + console.log( + stopped + ? `\n ${c.success("Stopped background preview")} ${c.dim(project.dir)}\n` + : `\n ${c.dim("No background preview is running for")} ${project.dir}\n`, + ); + } + return; + } + const status = await readBackgroundPreviewStatus(project.dir, startPort); + if (!status) { + if (args.json) { + writeLifecycleJson( + lifecyclePayload("status", { + state: "not-running", + projectDir: project.dir, + }), + ); + } else { + console.log(`\n ${c.dim("No background preview is running for")} ${project.dir}\n`); + } + return; + } + if (args.json) { + writeLifecycleJson( + lifecyclePayload( + "status", + previewLifecycleSession({ + state: "running", + mode: "background", + projectName: project.name, + projectDir: project.dir, + port: status.port, + pid: status.pid, + logPath: status.logPath, + }), + ), + ); + return; + } + printStudioSummary(project.name, previewBaseUrl(status.port), project.dir, { + details: [`Background preview running (PID ${status.pid}).`, `Log: ${status.logPath}`], + }); return; - } - const status = await readBackgroundPreviewStatus(project.dir, startPort); - if (!status) { - console.log(`\n ${c.dim("No background preview is running for")} ${project.dir}\n`); + } catch (error) { + reportPreviewFailure( + Boolean(args.json), + args.stop ? "stop" : "status", + args.stop ? "preview-stop-failed" : "preview-status-failed", + errorMessage(error), + ); return; } - console.log(`\n ${c.success("Background preview running")}`); - console.log( - ` ${c.accent(`http://localhost:${status.port}`)} ${c.dim(`(PID ${status.pid})`)}`, - ); - console.log(` ${c.dim(status.logPath)}\n`); - return; } // --list: scan and display active servers if (args.list) { - const servers = await scanActiveServers(startPort); - if (servers.length === 0) { - console.log("\n No active preview servers found.\n"); - return; - } - console.log(`\n ${c.bold("Active preview servers:")}\n`); - for (const s of servers) { - const pidStr = s.pid ? c.dim(` (PID ${s.pid})`) : ""; - console.log( - ` ${c.accent(`Port ${s.port}`)} ${s.projectName} ${c.dim(s.projectDir)}${pidStr}`, - ); - } - console.log(`\n ${servers.length} server${servers.length === 1 ? "" : "s"} running.\n`); + await handlePreviewList(startPort, Boolean(args.json)); return; } // --kill-all: kill all active servers if (args["kill-all"]) { - const servers = await scanActiveServers(startPort); - if (servers.length === 0) { - console.log("\n No active preview servers to kill.\n"); - return; - } - const killed = await killActiveServers(startPort); - console.log(`\n Killed ${killed} preview server${killed === 1 ? "" : "s"}.\n`); + await handlePreviewKillAll(startPort, Boolean(args.json)); return; } @@ -289,7 +401,7 @@ export default defineCommand({ // Kill orphaned chrome-headless-shell processes from previous crashed sessions. const orphansKilled = killOrphanedProcesses(); - if (orphansKilled > 0) { + if (orphansKilled > 0 && !args.json) { console.log( ` ${c.dim(`Cleaned up ${orphansKilled} orphaned process${orphansKilled === 1 ? "" : "es"} from a previous session.`)}`, ); @@ -297,13 +409,24 @@ export default defineCommand({ const rawArg = args.dir; const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./"; - const project = resolveProject(rawArg); + let project; + try { + project = args.json ? resolveProjectOrThrow(rawArg) : resolveProject(rawArg); + } catch (error) { + reportPreviewFailure( + Boolean(args.json), + "start", + "preview-start-failed", + errorMessage(error), + ); + return; + } const dir = project.dir; const projectName = isImplicitCwd ? basename(process.env.PWD ?? dir) : project.name; // Lint before starting — surface issues for the agent to fix. const lintResult = await lintProject(dir); - if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) { + if (!args.json && (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0)) { console.log(); for (const line of formatLintFindings(lintResult)) console.log(line); console.log(); @@ -311,8 +434,12 @@ export default defineCommand({ // Validation: --user-data-dir requires --browser-path if (args["user-data-dir"] && !args["browser-path"]) { - clack.log.error("--user-data-dir requires --browser-path"); - setCommandExitCode(1); + reportPreviewFailure( + Boolean(args.json), + "start", + "preview-validation-failed", + "--user-data-dir requires --browser-path", + ); return; } // Validation: --remote-debugging-port deps @@ -322,8 +449,7 @@ export default defineCommand({ remoteDebuggingPort: args["remote-debugging-port"] as string | undefined, }); if (depsError) { - clack.log.error(depsError); - setCommandExitCode(1); + reportPreviewFailure(Boolean(args.json), "start", "preview-validation-failed", depsError); return; } @@ -331,10 +457,12 @@ export default defineCommand({ const browserPath = args["browser-path"] as string | undefined; const browserNoGpu = !!args["browser-no-gpu"]; if (browserNoGpu && !browserPath) { - clack.log.error( + reportPreviewFailure( + Boolean(args.json), + "start", + "preview-validation-failed", "--browser-no-gpu requires --browser-path (the system default browser cannot receive Chromium flags — use --no-open on GPU-unstable hosts)", ); - setCommandExitCode(1); return; } const userDataDir = args["user-data-dir"] as string | undefined; @@ -344,39 +472,86 @@ export default defineCommand({ args["remote-debugging-port"] as string | undefined, ); } catch (err) { - clack.log.error((err as Error).message); - setCommandExitCode(1); + reportPreviewFailure( + Boolean(args.json), + "start", + "preview-validation-failed", + (err as Error).message, + ); return; } // Resolve once so embedded, monorepo-dev, and locally installed Studio // modes all receive identical --proxy/--no-proxy + config semantics. const autoProxy = resolveAutoProxy(dir, args.proxy as boolean | undefined); - if (isDevMode()) { - if (args.background) { - clack.log.error("--background currently supports the embedded preview server only"); + const launchMode = previewLaunchMode({ + background: Boolean(args.background), + foreground: Boolean(args.foreground), + interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY), + devMode: isDevMode(), + localStudio: hasLocalStudio(dir), + }); + + if (launchMode === "background") { + let background; + try { + background = await startBackgroundPreview(dir, startPort, { + forceNew: Boolean(args["force-new"]), + // A bare launch promises same-project reuse, regardless of the mode + // the existing managed server resolved earlier. Only an explicit + // --browser-gpu/--no-browser-gpu request authorizes replacement. + browserGpuMode: args["browser-gpu"] === undefined ? undefined : browserGpuMode, + }); + } catch (error) { + const message = errorMessage(error); + if (args.json) { + writeLifecycleJson(lifecycleFailurePayload("start", "preview-start-failed", message)); + } else { + clack.log.error(message); + } setCommandExitCode(1); return; } - return runDevMode(dir, { - projectName, + const url = `http://localhost:${background.port}`; + if (args.json) { + writeLifecycleJson( + lifecyclePayload( + "start", + previewLifecycleSession({ + state: background.type, + mode: "background", + projectName, + projectDir: dir, + port: background.port, + pid: background.pid, + ...(background.logPath ? { logPath: background.logPath } : {}), + }), + ), + ); + } else { + clack.intro(c.bold("hyperframes preview")); + printStudioSummary(projectName, url, dir, { + details: [ + background.type === "reused" + ? "Reusing the background server already running for this project." + : `Running in the background. Log: ${background.logPath}`, + "Changes reload automatically in the studio.", + ], + footer: `Stop with: hyperframes preview ${JSON.stringify(dir)} --stop`, + }); + } + openStudioBrowser(url, projectName, dir, { noOpen, browserPath, userDataDir, remoteDebuggingPort, browserNoGpu, - autoProxy, }); + return; } - // If @hyperframes/studio is installed locally, use Vite for full HMR - if (hasLocalStudio(dir)) { - if (args.background) { - clack.log.error("--background currently supports the embedded preview server only"); - setCommandExitCode(1); - return; - } - return runLocalStudioMode(dir, { + if (launchMode === "dev") { + return runDevMode(dir, { projectName, noOpen, browserPath, @@ -384,40 +559,26 @@ export default defineCommand({ remoteDebuggingPort, browserNoGpu, autoProxy, + browserGpuMode, + port: startPort, + json: Boolean(args.json), }); } - if (args.background) { - let background; - try { - background = await startBackgroundPreview(dir, startPort, { - forceNew: Boolean(args["force-new"]), - browserGpuMode, - }); - } catch (error) { - clack.log.error(errorMessage(error)); - setCommandExitCode(1); - return; - } - const url = `http://localhost:${background.port}`; - clack.intro(c.bold("hyperframes preview")); - printStudioSummary(projectName, url, { - details: [ - background.type === "reused" - ? "Reusing the background server already running for this project." - : `Running in the background. Log: ${background.logPath}`, - "Changes reload automatically in the studio.", - ], - footer: `Stop with: hyperframes preview ${JSON.stringify(dir)} --stop`, - }); - openStudioBrowser(url, projectName, dir, { + // If @hyperframes/studio is installed locally, use Vite for full HMR + if (launchMode === "local") { + return runLocalStudioMode(dir, { + projectName, noOpen, browserPath, userDataDir, remoteDebuggingPort, browserNoGpu, + autoProxy, + browserGpuMode, + port: startPort, + json: Boolean(args.json), }); - return; } const forceNew = !!args["force-new"]; @@ -431,12 +592,179 @@ export default defineCommand({ remoteDebuggingPort, browserNoGpu, browserGpuMode, + json: Boolean(args.json), }); }, }); -// `host` is the loopback the server actually bound (Vite binds `[::1]`, embedded -// binds `127.0.0.1`); default to IPv4 for the embedded/legacy callers. +export type PreviewLaunchMode = "background" | "dev" | "local" | "embedded"; + +export function previewLaunchMode(options: { + background: boolean; + foreground: boolean; + interactive: boolean; + devMode: boolean; + localStudio: boolean; +}): PreviewLaunchMode { + if (options.background) return "background"; + if (!options.foreground && !options.interactive) return "background"; + if (options.devMode) return "dev"; + return options.localStudio ? "local" : "embedded"; +} + +export function previewLaunchModeError(options: { + background: boolean; + foreground: boolean; + forceNew?: boolean; + status: boolean; + stop: boolean; + list: boolean; + killAll: boolean; +}): string | null { + if (options.background && options.foreground) { + return "--background and --foreground cannot be used together"; + } + const actionCount = [options.status, options.stop, options.list, options.killAll].filter( + Boolean, + ).length; + if (actionCount > 1) { + return "Only one of --status, --stop, --list, or --kill-all can be used at a time"; + } + if (actionCount > 0 && (options.background || options.foreground || options.forceNew)) { + return "Preview launch overrides cannot be combined with lifecycle actions"; + } + return null; +} + +export function previewPortError(port: string | undefined): string | null { + const value = port ?? "3002"; + if (!/^\d+$/.test(value)) return "--port must be an integer between 1 and 65535"; + const parsed = Number(value); + return parsed >= 1 && parsed <= 65535 ? null : "--port must be an integer between 1 and 65535"; +} + +export function publicPreviewPid( + serverPid: string | null | undefined, + fallbackPid: number | null, +): number | null { + const parsed = Number(serverPid); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallbackPid; +} + +function reportPreviewFailure( + json: boolean, + operation: PreviewLifecycleOperation, + code: string, + message: string, +): void { + if (json) writeLifecycleJson(lifecycleFailurePayload(operation, code, message)); + else clack.log.error(message); + setCommandExitCode(1); +} + +interface PreviewActionDependencies { + scan?: typeof scanActiveServers; + listManaged?: typeof listBackgroundPreviewStatuses; + stopManaged?: typeof stopBackgroundPreview; + killScanned?: typeof killActiveServers; +} + +export async function handlePreviewList( + startPort: number, + json: boolean, + dependencies: PreviewActionDependencies = {}, +): Promise { + try { + const [scannedServers, managedSessions] = await Promise.all([ + (dependencies.scan ?? scanActiveServers)(startPort), + (dependencies.listManaged ?? listBackgroundPreviewStatuses)(), + ]); + const managedKeys = new Set( + managedSessions.map((session) => `${resolve(session.projectDir)}\0${session.port}`), + ); + const servers = [ + ...managedSessions.map((session) => ({ + port: session.port, + host: "127.0.0.1", + projectName: basename(session.projectDir), + projectDir: session.projectDir, + version: "managed", + pid: String(session.pid), + })), + ...scannedServers.filter( + (server) => !managedKeys.has(`${resolve(server.projectDir)}\0${server.port}`), + ), + ]; + if (json) { + writeLifecycleJson( + lifecyclePayload("list", { + state: "listed", + sessions: servers.map((server) => + previewLifecycleSession({ + state: "running", + mode: server.version === "managed" ? "background" : "unknown", + projectName: server.projectName, + projectDir: server.projectDir, + port: server.port, + pid: server.pid ? Number(server.pid) : null, + host: server.host, + }), + ), + }), + ); + return; + } + if (servers.length === 0) { + console.log("\n No active preview servers found.\n"); + return; + } + console.log(`\n ${c.bold("Active preview servers:")}\n`); + for (const server of servers) { + const pid = server.pid ? c.dim(` (PID ${server.pid})`) : ""; + console.log( + ` ${c.accent(`Port ${server.port}`)} ${server.projectName} ${c.dim(server.projectDir)}${pid}`, + ); + } + console.log(`\n ${servers.length} server${servers.length === 1 ? "" : "s"} running.\n`); + } catch (error) { + reportPreviewFailure(json, "list", "preview-list-failed", errorMessage(error)); + } +} + +export async function handlePreviewKillAll( + startPort: number, + json: boolean, + dependencies: PreviewActionDependencies = {}, +): Promise { + try { + const managedSessions = await (dependencies.listManaged ?? listBackgroundPreviewStatuses)(); + let killed = 0; + for (const session of managedSessions) { + if ( + await (dependencies.stopManaged ?? stopBackgroundPreview)(session.projectDir, session.port) + ) { + killed++; + } + } + killed += await (dependencies.killScanned ?? killActiveServers)(startPort); + if (json) { + writeLifecycleJson(lifecyclePayload("kill-all", { state: "killed-all", stopped: killed })); + } else if (killed === 0) { + console.log("\n No active preview servers to kill.\n"); + } else { + console.log(`\n Killed ${killed} preview server${killed === 1 ? "" : "s"}.\n`); + } + } catch (error) { + reportPreviewFailure(json, "kill-all", "preview-kill-all-failed", errorMessage(error)); + } +} + +export function previewViteArgs(port: number | undefined): string[] { + return ["--host", "127.0.0.1", ...(port === undefined ? [] : ["--port", String(port)])]; +} + +// All preview modes bind the same IPv4 loopback so lifecycle probes and handed +// URLs agree on the reachable server. function previewBaseUrl(port: number, host = "127.0.0.1"): string { return `http://${host}:${port}`; } @@ -613,7 +941,12 @@ function countLintFindings(findings: Array<{ severity: string }>): { async function printCurrentContext( projectDir: string, startPort: number, - options: { json: boolean; fields?: string; detail?: string; preferredPort?: number }, + options: { + json: boolean; + fields?: string; + detail?: string; + preferredPort?: number; + }, ): Promise { let fields: ContextField[]; try { @@ -700,7 +1033,10 @@ async function printCurrentContext( ok: false as const, error: selectionResult.status === "rejected" - ? { code: "selection-unavailable", message: errorMessage(selectionResult.reason) } + ? { + code: "selection-unavailable", + message: errorMessage(selectionResult.reason), + } : { code: "no-selection", message: "Studio is running, but no element is selected.", @@ -718,8 +1054,14 @@ async function printCurrentContext( ok: false as const, error: lintResult.status === "rejected" - ? { code: "lint-unavailable", message: errorMessage(lintResult.reason) } - : { code: "lint-not-requested", message: "Lint was not requested." }, + ? { + code: "lint-unavailable", + message: errorMessage(lintResult.reason), + } + : { + code: "lint-not-requested", + message: "Lint was not requested.", + }, }; const payload: Record = { ok: true }; @@ -815,8 +1157,66 @@ export function studioLandingSearch(projectDir: string): string { // The full Studio URL to open or hand to the user: status-aware landing view // plus the project hash route. `url` never carries a trailing slash (both the // embedded server and the Vite `Local:` match strip it). -function studioDeepLink(url: string, projectName: string, projectDir: string): string { - return `${url}/${studioLandingSearch(projectDir)}#project/${projectName}`; +export function studioDeepLink(url: string, projectName: string, projectDir: string): string { + return `${url}/${studioLandingSearch(projectDir)}#project/${encodeURIComponent(projectName)}`; +} + +export function studioSummaryUrls( + projectName: string, + serverUrl: string, + projectDir: string, +): { serverUrl: string; studioUrl: string } { + return { + serverUrl, + studioUrl: studioDeepLink(serverUrl, projectName, projectDir), + }; +} + +export function foregroundPreviewReadyPayload( + projectName: string, + serverUrl: string, + projectDir: string, + pid: number | null, +): PreviewLifecyclePayload { + const port = Number(new URL(serverUrl).port); + return lifecyclePayload( + "start", + previewLifecycleSession({ + state: "started", + mode: "foreground", + projectName, + projectDir, + port, + pid, + }), + ); +} + +function previewLifecycleSession(options: { + state: PreviewLifecycleSession["state"]; + mode: PreviewLifecycleSession["mode"]; + projectName: string; + projectDir: string; + port: number; + pid: number | null; + host?: string; + logPath?: string; +}): PreviewLifecycleSession { + const host = options.host ?? "127.0.0.1"; + const serverUrl = previewBaseUrl(options.port, host); + return { + state: options.state, + mode: options.mode, + projectName: options.projectName, + projectDir: options.projectDir, + host, + port: options.port, + pid: options.pid, + serverUrl, + studioUrl: studioDeepLink(serverUrl, options.projectName, options.projectDir), + ready: true, + ...(options.logPath ? { logPath: options.logPath } : {}), + }; } function openStudioBrowser( @@ -836,12 +1236,15 @@ function openStudioBrowser( function printStudioSummary( projectName: string, - url: string, + serverUrl: string, + projectDir: string, opts: { details?: string[]; footer?: string } = {}, ): void { + const urls = studioSummaryUrls(projectName, serverUrl, projectDir); console.log(); console.log(` ${c.dim("Project")} ${c.accent(projectName)}`); - console.log(` ${c.dim("Studio")} ${c.accent(url)}`); + console.log(` ${c.dim("Studio")} ${c.accent(urls.studioUrl)}`); + console.log(` ${c.dim("Server")} ${c.accent(urls.serverUrl)}`); console.log(); for (const detail of opts.details ?? []) { console.log(` ${c.dim(detail)}`); @@ -893,17 +1296,33 @@ function removeSymlinkOnExit(createdSymlink: boolean, symlinkPath: string): void }); } -function registerChildTreeShutdown(child: StudioChildProcess): void { +export function waitForStudioChildClose( + child: StudioChildProcess, + signalTarget: StudioSignalTarget = process, +): Promise { const shutdown = (): void => { if (child.pid) killProcessTree(child.pid); }; - process.once("SIGINT", shutdown); - process.once("SIGTERM", shutdown); -} + signalTarget.once("SIGINT", shutdown); + signalTarget.once("SIGTERM", shutdown); + + // A short-lived Vite child can exit before launch setup reaches this point. + // ChildProcess does not replay lifecycle events to listeners attached later, + // so waiting unconditionally would strand the preview wrapper forever. + const closed = + child.exitCode !== null || child.signalCode !== null + ? Promise.resolve() + : new Promise((resolveClose) => { + // `close` waits for stdio to close too. A Vite descendant can inherit + // those pipes, so the wrapper must key its lifetime to process exit. + child.once("exit", () => resolveClose()); + }); -function waitForChildClose(child: StudioChildProcess): Promise { - return new Promise((resolveClose) => { - child.on("close", () => resolveClose()); + return closed.finally(() => { + // Signal listeners keep Bun's event loop alive even after Vite exits. Leaving + // them registered makes `preview --stop` close the port but leak the wrapper. + signalTarget.off("SIGINT", shutdown); + signalTarget.off("SIGTERM", shutdown); }); } @@ -912,30 +1331,60 @@ function attachStudioReadyHandler( spinner: ReturnType, projectName: string, projectDir: string, - options?: BrowserLaunchOptions, + options?: StudioLaunchOptions, ): void { let detected = false; - function handleOutput(data: Buffer): void { - const url = data.toString().match(/Local:\s+(http:\/\/localhost:\d+)/)?.[1]; + async function handleOutput(data: Buffer): Promise { + const url = studioReadyUrl(data.toString()); if (!url || detected) return; detected = true; - spinner.stop(c.success("Studio running")); - printStudioSummary(projectName, url, { footer: "Press Ctrl+C to stop" }); + if (options?.json) { + const port = Number(new URL(url).port); + const server = await activeServerOnPort(port); + writeLifecycleJson( + foregroundPreviewReadyPayload( + projectName, + url, + projectDir, + publicPreviewPid(server?.pid, child.pid ?? null), + ), + ); + } else { + spinner.stop(c.success("Studio running")); + printStudioSummary(projectName, url, projectDir, { + footer: "Press Ctrl+C to stop", + }); + } openStudioBrowser(url, projectName, projectDir, options); child.stdout.removeListener("data", handleOutput); child.stderr.removeListener("data", handleOutput); } - child.stdout.on("data", handleOutput); - child.stderr.on("data", handleOutput); + child.stdout.on("data", (data) => void handleOutput(data)); + child.stderr.on("data", (data) => void handleOutput(data)); child.on("error", (err) => { - spinner.stop(c.error("Failed to start studio")); - console.error(c.dim(err.message)); + if (options?.json) { + reportPreviewFailure(true, "start", "preview-start-failed", err.message); + } else { + spinner.stop(c.error("Failed to start studio")); + console.error(c.dim(err.message)); + } }); } +export function studioReadyUrl(output: string): string | null { + const localLine = output.split(/\r?\n/).find((line) => line.includes("Local:")); + return localLine?.match(/https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]):\d+/)?.[0] ?? null; +} + +export function reportPreviewShutdown(json: boolean): void { + if (json) return; + console.log(); + console.log(` ${c.dim("Shutting down studio...")}`); +} + /** * Dev mode: spawn the studio dev server from the monorepo. */ @@ -949,17 +1398,21 @@ async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise` only targets this process — the child tree (Vite + Chrome) // would survive without explicit cleanup. - // On Windows, killProcessTree is a no-op (pgrep/ps unavailable); Ctrl+C - // propagates via the console process group instead. - registerChildTreeShutdown(child); - return waitForChildClose(child); + // On Windows, killProcessTree delegates to taskkill /T so descendants are + // reaped even when the console signal reaches only this wrapper. + return waitForStudioChildClose(child); } /** @@ -1001,23 +1453,26 @@ async function runLocalStudioMode(dir: string, options?: StudioLaunchOptions): P const projectsDir = join(studioPkgPath, "data", "projects"); const { symlinkPath, createdSymlink } = linkProjectIntoStudioData(dir, projectsDir, pName); - clack.intro(c.bold("hyperframes preview") + c.dim(" (local studio)")); + if (!options?.json) clack.intro(c.bold("hyperframes preview") + c.dim(" (local studio)")); const s = clack.spinner(); - s.start("Starting studio..."); + if (!options?.json) s.start("Starting studio..."); - const viteCommand = buildNpxCommand(["vite"]); + const viteCommand = buildNpxCommand(["vite", ...previewViteArgs(options?.port)]); const child = spawn(viteCommand.command, viteCommand.args, { cwd: studioPkgPath, stdio: ["ignore", "pipe", "pipe"], - env: studioProxyEnv(options?.autoProxy ?? true), + env: studioProxyEnv(options?.autoProxy ?? true, process.env, { + projectDir: dir, + projectName: pName, + browserGpuMode: options?.browserGpuMode, + }), }); attachStudioReadyHandler(child, s, pName, dir, options); removeSymlinkOnExit(createdSymlink, symlinkPath); - // Same tree-kill handler as dev mode. No-op on Windows (see comment above). - registerChildTreeShutdown(child); - return waitForChildClose(child); + // Same cross-platform tree-kill handler as dev mode. + return waitForStudioChildClose(child); } /** @@ -1038,32 +1493,37 @@ async function runEmbeddedMode( const pName = options?.projectName ?? basename(dir); const studioBundle = resolveStudioBundle(); - clack.intro(c.bold("hyperframes preview")); + if (!options?.json) clack.intro(c.bold("hyperframes preview")); const s = clack.spinner(); - s.start("Starting studio..."); + if (!options?.json) s.start("Starting studio..."); if (!studioBundle.available) { - s.stop(c.error("Studio build missing")); - console.error(); - console.error(` ${c.dim("Could not find")} ${c.accent("index.html")} ${c.dim("in:")}`); - for (const checkedPath of studioBundle.checkedPaths) { - console.error(` ${c.dim("-")} ${checkedPath}`); + if (options?.json) { + reportPreviewFailure(true, "start", "preview-start-failed", "Studio build missing"); + } else { + s.stop(c.error("Studio build missing")); + console.error(); + console.error(` ${c.dim("Could not find")} ${c.accent("index.html")} ${c.dim("in:")}`); + for (const checkedPath of studioBundle.checkedPaths) { + console.error(` ${c.dim("-")} ${checkedPath}`); + } + console.error(); + console.error(` ${c.dim("Rebuild the CLI package with")} ${c.accent("bun run build")}`); + console.error(); } - console.error(); - console.error(` ${c.dim("Rebuild the CLI package with")} ${c.accent("bun run build")}`); - console.error(); setCommandExitCode(1); return; } - const { app } = createStudioServer({ + // Compute everything that may throw before acquiring the fs.watch handle. + // Once createStudioServer returns, every subsequent exit path must close it. + const serverBuildSignature = await loadPreviewServerBuildSignature(); + const { app, watcher } = createStudioServer({ projectDir: dir, projectName: pName, autoProxy: options?.autoProxy, browserGpuMode: options?.browserGpuMode, }); - const serverBuildSignature = await loadPreviewServerBuildSignature(); - let result: FindPortResult; try { result = await findPortAndServe( @@ -1076,38 +1536,65 @@ async function runEmbeddedMode( options?.browserGpuMode, ); } catch (err: unknown) { - s.stop(c.error("Failed to start studio")); - console.error(); - console.error(` ${(err as Error).message}`); - console.error(); - setCommandExitCode(1); + watcher.close(); + reportPreviewFailure( + Boolean(options?.json), + "start", + "preview-start-failed", + (err as Error).message, + ); return; } if (result.type === "already-running") { + // createStudioServer acquires an fs.watch handle before port discovery. + // Reuse owns no local server, so release that handle before returning or + // the otherwise-finished CLI process remains alive indefinitely. + watcher.close(); const url = `http://localhost:${result.port}`; - s.stop(c.success("Already running")); - printStudioSummary(pName, url, { - details: ["Reusing existing server. Use --force-new to start a fresh instance."], - }); + if (options?.json) { + const server = await activeServerOnPort(result.port); + writeLifecycleJson( + lifecyclePayload( + "start", + previewLifecycleSession({ + state: "reused", + mode: "foreground", + projectName: pName, + projectDir: dir, + port: result.port, + pid: publicPreviewPid(server?.pid, null), + }), + ), + ); + } else { + s.stop(c.success("Already running")); + printStudioSummary(pName, url, dir, { + details: ["Reusing existing server. Use --force-new to start a fresh instance."], + }); + } openStudioBrowser(url, pName, dir, options); return; } const url = `http://localhost:${result.port}`; - s.stop(c.success("Studio running")); - console.log(); - if (result.port !== startPort) { - console.log(` ${c.warn(`Port ${startPort} is in use, using ${result.port} instead`)}`); + if (options?.json) { + writeLifecycleJson(foregroundPreviewReadyPayload(pName, url, dir, process.pid)); + } else { + s.stop(c.success("Studio running")); console.log(); + if (result.port !== startPort) { + console.log(` ${c.warn(`Port ${startPort} is in use, using ${result.port} instead`)}`); + console.log(); + } + printStudioSummary(pName, url, dir, { + details: [ + "Edit with your AI agent — it has HyperFrames skills installed.", + "Changes reload automatically in the studio.", + ], + footer: "Press Ctrl+C to stop", + }); } - 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, dir, options); // Block until Ctrl+C. Node would normally exit on SIGINT, but the listening @@ -1123,7 +1610,10 @@ async function runEmbeddedMode( let rl: import("node:readline").Interface | undefined; if (process.platform === "win32") { const readline = await import("node:readline"); - rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); rl.on("SIGINT", () => { process.emit("SIGINT", "SIGINT"); }); @@ -1137,8 +1627,7 @@ async function runEmbeddedMode( process.off("SIGINT", shutdown); process.off("SIGTERM", shutdown); rl?.close(); - console.log(); - console.log(` ${c.dim("Shutting down studio...")}`); + reportPreviewShutdown(Boolean(options?.json)); // Hard deadline: if cleanup hangs (e.g. dead Chrome never responds to // browser.close()), force exit. Armed before awaiting cleanup so it @@ -1157,6 +1646,7 @@ async function runEmbeddedMode( cleanup() .catch(() => {}) .finally(() => { + watcher.close(); result.server.close(() => resolveRun()); }); }; diff --git a/packages/cli/src/commands/previewLifecycle.test.ts b/packages/cli/src/commands/previewLifecycle.test.ts index 1f0fbdb0aa..a3a8f4b3a2 100644 --- a/packages/cli/src/commands/previewLifecycle.test.ts +++ b/packages/cli/src/commands/previewLifecycle.test.ts @@ -1,10 +1,11 @@ -import { existsSync, mkdtempSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; import type { ActiveServer } from "../server/portUtils.js"; import { buildBackgroundPreviewArgs, + listBackgroundPreviewStatuses, previewSessionPath, readBackgroundPreviewStatus, startBackgroundPreview, @@ -48,7 +49,7 @@ describe("background preview lifecycle", () => { ); }); - it("builds a detached child invocation without recursively preserving --background", () => { + it("forces the detached child foreground without inheriting launcher-only flags", () => { expect( buildBackgroundPreviewArgs([ "/opt/hyperframes/cli.js", @@ -56,8 +57,9 @@ describe("background preview lifecycle", () => { projectDir, "--background", "--open", + "--json", ]), - ).toEqual(["/opt/hyperframes/cli.js", "preview", projectDir, "--no-open"]); + ).toEqual(["/opt/hyperframes/cli.js", "preview", projectDir, "--foreground", "--no-open"]); }); it("reuses an already-running server for the same project", async () => { @@ -76,6 +78,77 @@ describe("background preview lifecycle", () => { expect(spawn).not.toHaveBeenCalled(); }); + it("reuses a saved managed preview on a custom port without repeating --port", async () => { + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + writePreviewSession( + { pid: 4321, port: 41402, projectDir, logPath: "/tmp/custom.log" }, + stateHome, + ); + const customServer = { ...server, port: 41402, browserGpuMode: "software" as const }; + const scan = vi.fn(async (startPort?: number) => (startPort === 41402 ? [customServer] : [])); + const spawn = vi.fn(); + + const result = await startBackgroundPreview(projectDir, 3002, { + scan, + spawn, + stateHome, + }); + + expect(result).toMatchObject({ type: "reused", port: 41402, pid: 4321 }); + expect(scan).toHaveBeenCalledWith(41402); + expect(spawn).not.toHaveBeenCalled(); + }); + + it("discovers managed previews outside the default port scan and removes stale records", async () => { + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + const otherProjectDir = resolve("/tmp/hyperframes-preview-managed-custom-port"); + const staleProjectDir = resolve("/tmp/hyperframes-preview-managed-stale"); + writePreviewSession( + { + pid: 8765, + port: 41402, + projectDir: otherProjectDir, + logPath: "/tmp/custom.log", + }, + stateHome, + ); + writePreviewSession( + { + pid: 9999, + port: 45000, + projectDir: staleProjectDir, + logPath: "/tmp/stale.log", + }, + stateHome, + ); + + const statuses = await listBackgroundPreviewStatuses({ + stateHome, + scan: async (startPort) => + startPort === 41402 + ? [ + { + port: 41402, + projectName: "managed-custom-port", + projectDir: otherProjectDir, + version: "test", + pid: "8765", + }, + ] + : [], + }); + + expect(statuses).toEqual([ + { + pid: 8765, + port: 41402, + projectDir: otherProjectDir, + logPath: "/tmp/custom.log", + }, + ]); + expect(existsSync(previewSessionPath(staleProjectDir, stateHome))).toBe(false); + }); + it("force-new waits for a different server instead of reusing the existing one", async () => { const replacement = { ...server, port: 3211, pid: "5432" }; let scans = 0; @@ -94,6 +167,104 @@ describe("background preview lifecycle", () => { expect(spawn).toHaveBeenCalledOnce(); }); + it.each([ + ["force-new", true], + ["a GPU-policy change", false], + ])( + "%s replaces a previously managed server instead of orphaning it", + async (_label, forceNew) => { + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + const oldServer = { ...server, port: 41490, browserGpuMode: "hardware" as const }; + writePreviewSession( + { pid: 4321, port: 41490, projectDir, logPath: "/tmp/preview.log" }, + stateHome, + ); + const replacement = { + ...server, + port: 41491, + pid: "5432", + browserGpuMode: "software" as const, + }; + let oldRunning = true; + let replacementRunning = false; + const scan = vi.fn(async () => + oldRunning ? [oldServer] : replacementRunning ? [replacement] : [], + ); + const kill = vi.fn((pid: number) => { + if (pid === 4321) oldRunning = false; + }); + const spawn = vi.fn(() => { + replacementRunning = true; + return { pid: 5432, unref: vi.fn() }; + }); + + const result = await startBackgroundPreview(projectDir, 41491, { + browserGpuMode: "software", + forceNew, + kill, + scan, + sleep: async () => {}, + spawn, + stateHome, + }); + + expect(scan).toHaveBeenNthCalledWith(1, 41490); + expect(kill).toHaveBeenCalledWith(4321); + expect(result).toMatchObject({ type: "started", port: 41491, pid: 5432 }); + expect(readFileSync(previewSessionPath(projectDir, stateHome), "utf8")).toContain( + '"port": 41491', + ); + }, + ); + + it("replaces the owned preview instead of reusing an unmanaged policy-matching sibling", async () => { + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + const owned = { ...server, port: 41490, browserGpuMode: "hardware" as const }; + const sibling = { + ...server, + port: 41491, + pid: "8765", + browserGpuMode: "software" as const, + }; + const replacement = { + ...server, + port: 41492, + pid: "5432", + browserGpuMode: "software" as const, + }; + writePreviewSession( + { pid: 4321, port: owned.port, projectDir, logPath: "/tmp/preview.log" }, + stateHome, + ); + let ownedRunning = true; + let replacementRunning = false; + const scan = vi.fn(async () => [ + ...(ownedRunning ? [owned] : []), + sibling, + ...(replacementRunning ? [replacement] : []), + ]); + const kill = vi.fn((pid: number) => { + if (pid === 4321) ownedRunning = false; + }); + const spawn = vi.fn(() => { + replacementRunning = true; + return { pid: 5432, unref: vi.fn() }; + }); + + const result = await startBackgroundPreview(projectDir, replacement.port, { + browserGpuMode: "software", + kill, + scan, + sleep: async () => {}, + spawn, + stateHome, + }); + + expect(kill).toHaveBeenCalledWith(4321); + expect(spawn).toHaveBeenCalledOnce(); + expect(result).toMatchObject({ type: "started", port: replacement.port, pid: 5432 }); + }); + it("starts a replacement when the existing server uses a different GPU policy", async () => { const hardwareServer = { ...server, browserGpuMode: "hardware" as const }; const softwareServer = { @@ -102,11 +273,15 @@ describe("background preview lifecycle", () => { pid: "5432", browserGpuMode: "software" as const, }; - let scans = 0; - const scan = vi.fn(async () => - ++scans < 2 ? [hardwareServer] : [hardwareServer, softwareServer], - ); - const spawn = vi.fn(() => ({ pid: 5432, unref: vi.fn() })); + let replacementRunning = false; + const scan = vi.fn(async () => [ + hardwareServer, + ...(replacementRunning ? [softwareServer] : []), + ]); + const spawn = vi.fn(() => { + replacementRunning = true; + return { pid: 5432, unref: vi.fn() }; + }); const result = await startBackgroundPreview(projectDir, 3002, { browserGpuMode: "software", @@ -121,10 +296,13 @@ describe("background preview lifecycle", () => { }); it("returns after a detached child becomes reachable and records its session", async () => { - let scans = 0; - const scan = vi.fn(async () => (++scans < 2 ? [] : [server])); + let spawned = false; + const scan = vi.fn(async () => (spawned ? [server] : [])); const unref = vi.fn(); - const spawn = vi.fn(() => ({ pid: 4321, unref })); + const spawn = vi.fn(() => { + spawned = true; + return { pid: 4321, unref }; + }); const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); const result = await startBackgroundPreview(projectDir, 3002, { @@ -141,6 +319,46 @@ describe("background preview lifecycle", () => { expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(true); }); + it("reports the live server PID while retaining the wrapper PID for cleanup", async () => { + const liveServer = { ...server, pid: "9876" }; + let spawned = false; + const scan = vi.fn(async () => (spawned ? [liveServer] : [])); + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + + const result = await startBackgroundPreview(projectDir, 3002, { + scan, + spawn: () => { + spawned = true; + return { pid: 4321, unref: vi.fn() }; + }, + stateHome, + }); + + expect(result).toMatchObject({ type: "started", pid: 9876 }); + expect( + JSON.parse(readFileSync(previewSessionPath(projectDir, stateHome), "utf8")), + ).toMatchObject({ pid: 4321 }); + }); + + it("reaps a detached child that never becomes reachable without recording ownership", async () => { + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + const kill = vi.fn(); + + await expect( + startBackgroundPreview(projectDir, 3002, { + scan: async () => [], + spawn: () => ({ pid: 4321, unref: vi.fn() }), + sleep: async () => {}, + kill, + stateHome, + }), + ).rejects.toThrow(/did not become ready/i); + + expect(kill).toHaveBeenCalledOnce(); + expect(kill).toHaveBeenCalledWith(4321); + expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(false); + }); + it("removes a stale session when no matching server or process survives", async () => { const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); writePreviewSession( @@ -163,7 +381,10 @@ describe("background preview lifecycle", () => { savePreviewSession(stateHome); const scan = vi.fn(async () => [server]); - const status = await readBackgroundPreviewStatus(projectDir, 3002, { scan, stateHome }); + const status = await readBackgroundPreviewStatus(projectDir, 3002, { + scan, + stateHome, + }); expect(status?.port).toBe(3210); expect(scan).toHaveBeenCalledWith(3210); @@ -204,27 +425,87 @@ describe("background preview lifecycle", () => { expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(false); }); - it("uses the saved child PID when a matching live server cannot report one", async () => { + it("refuses to stop when the live server cannot prove its own PID", async () => { const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); writePreviewSession( { pid: 4321, port: 3210, projectDir, logPath: "/tmp/preview.log" }, stateHome, ); + const scan = vi.fn(async () => [{ ...server, pid: null }]); + const kill = vi.fn(); + + await expect( + stopBackgroundPreview(projectDir, 3002, { + scan, + kill, + sleep: async () => {}, + stateHome, + }), + ).rejects.toThrow(/ownership/i); + + expect(kill).not.toHaveBeenCalled(); + }); + + it("reaps the saved wrapper when the live server is proven to be its descendant", async () => { + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + writePreviewSession( + { + pid: 4321, + wrapperIdentity: "wrapper-birth", + port: 3210, + projectDir, + logPath: "/tmp/preview.log", + }, + stateHome, + ); let running = true; - const scan = vi.fn(async () => (running ? [{ ...server, pid: null }] : [])); - const kill = vi.fn(() => { - running = false; + const scan = vi.fn(async () => (running ? [{ ...server, pid: "9876" }] : [])); + const kill = vi.fn((pid: number) => { + if (pid === 4321) running = false; }); const result = await stopBackgroundPreview(projectDir, 3002, { scan, kill, + isDescendant: (childPid, ancestorPid) => childPid === 9876 && ancestorPid === 4321, + identity: (pid) => (pid === 4321 ? "wrapper-birth" : null), sleep: async () => {}, stateHome, }); expect(result).toBe(true); - expect(kill).toHaveBeenCalledWith(4321); + expect(kill.mock.calls).toEqual([[4321]]); + }); + + it("kills only the live server when the saved wrapper birth identity has changed", async () => { + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + writePreviewSession( + { + pid: 4321, + wrapperIdentity: "original-wrapper-birth", + port: 3210, + projectDir, + logPath: "/tmp/preview.log", + }, + stateHome, + ); + let running = true; + const scan = vi.fn(async () => (running ? [{ ...server, pid: "9876" }] : [])); + const kill = vi.fn((pid: number) => { + if (pid === 9876) running = false; + }); + + const result = await stopBackgroundPreview(projectDir, 3002, { + scan, + kill, + isDescendant: () => true, + identity: () => "reused-pid-birth", + sleep: async () => {}, + stateHome, + }); + + expect(result).toBe(true); + expect(kill.mock.calls).toEqual([[9876]]); }); it("fails loudly when the server remains reachable after stop", async () => { @@ -244,4 +525,30 @@ describe("background preview lifecycle", () => { ).rejects.toThrow(/did not stop/i); expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(true); }); + + it("verifies the owned port stopped even when another server serves the same project", async () => { + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + const owned = { ...server, port: 41490 }; + const sibling = { ...server, port: 41491, pid: "8765" }; + writePreviewSession( + { pid: 4321, port: owned.port, projectDir, logPath: "/tmp/preview.log" }, + stateHome, + ); + let ownedRunning = true; + const scan = vi.fn(async () => [...(ownedRunning ? [owned] : []), sibling]); + const kill = vi.fn((pid: number) => { + if (pid === 4321) ownedRunning = false; + }); + + const stopped = await stopBackgroundPreview(projectDir, owned.port, { + kill, + scan, + sleep: async () => {}, + stateHome, + }); + + expect(stopped).toBe(true); + expect(kill).toHaveBeenCalledWith(4321); + expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(false); + }); }); diff --git a/packages/cli/src/commands/previewLifecycle.ts b/packages/cli/src/commands/previewLifecycle.ts index eaec7f91ab..f1af298f91 100644 --- a/packages/cli/src/commands/previewLifecycle.ts +++ b/packages/cli/src/commands/previewLifecycle.ts @@ -6,6 +6,7 @@ import { mkdirSync, openSync, readFileSync, + readdirSync, rmSync, writeFileSync, } from "node:fs"; @@ -13,10 +14,11 @@ import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { scanActiveServers, type ActiveServer } from "../server/portUtils.js"; import type { BrowserGpuMode } from "../browser/gpuPolicy.js"; -import { killProcessTree } from "../utils/orphanCleanup.js"; +import { isProcessDescendant, killProcessTree, processIdentity } from "../utils/orphanCleanup.js"; export interface PreviewSession { pid: number; + wrapperIdentity?: string; port: number; projectDir: string; logPath: string; @@ -40,6 +42,8 @@ interface LifecycleDependencies { spawn?: SpawnPreview; sleep?: (ms: number) => Promise; kill?: (pid: number) => void; + isDescendant?: (childPid: number, ancestorPid: number) => boolean; + identity?: (pid: number) => string | null; stateHome?: string; forceNew?: boolean; browserGpuMode?: BrowserGpuMode; @@ -95,6 +99,43 @@ function readPreviewSession( } } +function hasValidPreviewProcess(session: PreviewSession): boolean { + return Number.isInteger(session.pid) && session.pid > 0; +} + +function hasValidPreviewEndpoint(session: PreviewSession): boolean { + return Number.isInteger(session.port) && session.port > 0 && session.port <= 65535; +} + +function matchesPreviewSessionFile( + session: PreviewSession, + path: string, + stateHome: string, +): boolean { + return ( + typeof session.projectDir === "string" && + typeof session.logPath === "string" && + previewSessionPath(session.projectDir, stateHome) === path + ); +} + +function readPreviewSessionFile(path: string, stateHome: string): PreviewSession | null { + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as PreviewSession; + if ( + !hasValidPreviewProcess(parsed) || + !hasValidPreviewEndpoint(parsed) || + !matchesPreviewSessionFile(parsed, path, stateHome) + ) { + throw new Error("invalid preview session"); + } + return parsed; + } catch { + rmSync(path, { force: true }); + return null; + } +} + function removePreviewSession(projectDir: string, stateHome = defaultStateHome()): void { rmSync(previewSessionPath(projectDir, stateHome), { force: true }); } @@ -113,6 +154,26 @@ function matchingServer( ); } +function matchingServerAtPort( + servers: ActiveServer[], + projectDir: string, + port: number, +): ActiveServer | null { + return matchingServer( + servers.filter((server) => server.port === port), + projectDir, + ); +} + +function sameProjectPorts(servers: ActiveServer[], projectDir: string): Set { + const project = normalized(projectDir); + return new Set( + servers + .filter((server) => normalized(server.projectDir) === project) + .map((server) => server.port), + ); +} + function stopProcess(pid: number): void { killProcessTree(pid); if (process.platform === "win32") { @@ -130,7 +191,7 @@ function spawnDetachedPreview( projectDir: string, stateHome: string, dependencies: LifecycleDependencies, -): { pid: number; logPath: string } { +): { pid: number; wrapperIdentity: string | undefined; logPath: string } { const logPath = previewLogPath(projectDir, stateHome); mkdirSync(dirname(logPath), { recursive: true }); const logFd = openSync(logPath, "a", 0o600); @@ -151,18 +212,20 @@ function spawnDetachedPreview( } if (!child.pid) throw new Error("background preview child did not report a PID"); child.unref(); - return { pid: child.pid, logPath }; + return { + pid: child.pid, + wrapperIdentity: (dependencies.identity ?? processIdentity)(child.pid) ?? undefined, + logPath, + }; } function startedServer( servers: ActiveServer[], projectDir: string, - existing: ActiveServer | null, - forceNew: boolean, + preLaunchPorts: Set, browserGpuMode?: BrowserGpuMode, ): ActiveServer | null { - const candidates = - forceNew && existing ? servers.filter((server) => server.port !== existing.port) : servers; + const candidates = servers.filter((server) => !preLaunchPorts.has(server.port)); return matchingServer(candidates, projectDir, browserGpuMode); } @@ -171,10 +234,13 @@ export function buildBackgroundPreviewArgs(argv: string[]): string[] { (arg) => arg !== "--background" && !arg.startsWith("--background=") && + arg !== "--foreground" && + !arg.startsWith("--foreground=") && arg !== "--open" && - arg !== "--no-open", + arg !== "--no-open" && + arg !== "--json", ); - return [...filtered, "--no-open"]; + return [...filtered, "--foreground", "--no-open"]; } export async function readBackgroundPreviewStatus( @@ -202,6 +268,92 @@ export async function readBackgroundPreviewStatus( return null; } +export async function listBackgroundPreviewStatuses( + dependencies: LifecycleDependencies = {}, +): Promise { + const stateHome = dependencies.stateHome ?? defaultStateHome(); + const directory = sessionDirectory(stateHome); + let files: string[]; + try { + files = readdirSync(directory) + .filter((name) => name.endsWith(".json")) + .map((name) => join(directory, name)); + } catch { + return []; + } + + const saved = files + .map((path) => readPreviewSessionFile(path, stateHome)) + .filter((session): session is PreviewSession => session !== null); + const statuses = await Promise.all( + saved.map((session) => + readBackgroundPreviewStatus(session.projectDir, session.port, { + ...dependencies, + stateHome, + }), + ), + ); + return statuses.filter((status): status is PreviewSession => status !== null); +} + +function readyPreviewSession( + server: ActiveServer, + pid: number, + wrapperIdentity: string | undefined, + projectDir: string, + logPath: string, + dependencies: LifecycleDependencies, +): { session: PreviewSession; publicPid: number } { + const identity = wrapperIdentity ?? (dependencies.identity ?? processIdentity)(pid) ?? undefined; + const liveServerPid = Number(server.pid); + return { + session: { + pid, + wrapperIdentity: identity, + port: server.port, + projectDir: resolve(projectDir), + logPath, + }, + publicPid: Number.isInteger(liveServerPid) && liveServerPid > 0 ? liveServerPid : pid, + }; +} + +function ownedStopTargetPid( + saved: PreviewSession | null, + liveServerPid: number, + dependencies: LifecycleDependencies, +): number { + if (!saved?.wrapperIdentity) return liveServerPid; + const identity = dependencies.identity ?? processIdentity; + if (identity(saved.pid) !== saved.wrapperIdentity) return liveServerPid; + if (saved.pid === liveServerPid) return saved.pid; + const isDescendant = dependencies.isDescendant ?? isProcessDescendant; + return isDescendant(liveServerPid, saved.pid) ? saved.pid : liveServerPid; +} + +async function stopOwnedPreviewBeforeReplacement( + owned: ActiveServer | null, + projectDir: string, + dependencies: LifecycleDependencies, +): Promise { + if (!owned) return; + const stopped = await stopBackgroundPreview(projectDir, owned.port, dependencies); + if (!stopped) throw new Error(`managed preview could not be replaced for ${resolve(projectDir)}`); +} + +function savedOwnedPreview( + servers: ActiveServer[], + saved: PreviewSession | null, + projectDir: string, +): ActiveServer | null { + if (!saved) return null; + // Ownership comes from the saved project+port, not the replacement's GPU + // policy. Filtering here would miss an owned hardware→software replacement + // and overwrite the only session record while leaving the old listener live. + const savedPortServers = servers.filter((server) => server.port === saved.port); + return matchingServer(savedPortServers, projectDir); +} + export async function startBackgroundPreview( projectDir: string, startPort: number, @@ -211,37 +363,65 @@ export async function startBackgroundPreview( | { type: "started"; port: number; pid: number; logPath: string } > { const scan = dependencies.scan ?? scanActiveServers; - const existing = matchingServer(await scan(startPort), projectDir, dependencies.browserGpuMode); - if (existing && !dependencies.forceNew) { + const stateHome = dependencies.stateHome ?? defaultStateHome(); + const saved = readPreviewSession(projectDir, stateHome); + // Always inspect a saved custom port first. `--force-new --port ` must + // replace that owned server before recording the replacement, otherwise the + // single per-project ownership record would orphan the old listener. + const scanStart = saved?.port ?? startPort; + const scanned = await scan(scanStart); + const requestedExisting = matchingServer(scanned, projectDir, dependencies.browserGpuMode); + const ownedExisting = savedOwnedPreview(scanned, saved, projectDir); + // A saved managed preview is the authoritative same-project instance. An + // explicit GPU-policy change replaces it; it must not silently adopt an + // unmanaged sibling that happens to match the new policy. + const reusableOwned = ownedExisting + ? matchingServer([ownedExisting], projectDir, dependencies.browserGpuMode) + : null; + const reusableExisting = reusableOwned ?? (ownedExisting ? null : requestedExisting); + if (reusableExisting && !dependencies.forceNew) { return { type: "reused", - port: existing.port, - pid: existing.pid ? Number(existing.pid) : null, + port: reusableExisting.port, + pid: reusableExisting.pid ? Number(reusableExisting.pid) : null, logPath: null, }; } + await stopOwnedPreviewBeforeReplacement(ownedExisting, projectDir, dependencies); + // Snapshot every same-project listener in the prospective launch range only + // after the owned listener is gone. Readiness must identify a newly appeared + // server, never a pre-existing unmanaged sibling. + const preLaunchPorts = sameProjectPorts(await scan(startPort), projectDir); - const stateHome = dependencies.stateHome ?? defaultStateHome(); - const { pid, logPath } = spawnDetachedPreview(projectDir, stateHome, dependencies); + const { pid, wrapperIdentity, logPath } = spawnDetachedPreview( + projectDir, + stateHome, + dependencies, + ); const sleep = dependencies.sleep ?? delay; for (let attempt = 0; attempt < 50; attempt++) { const server = startedServer( await scan(startPort), projectDir, - existing, - dependencies.forceNew === true, + preLaunchPorts, dependencies.browserGpuMode, ); if (server) { - const session = { + const ready = readyPreviewSession( + server, pid, - port: server.port, - projectDir: resolve(projectDir), + wrapperIdentity, + projectDir, logPath, + dependencies, + ); + writePreviewSession(ready.session, stateHome); + return { + type: "started", + ...ready.session, + pid: ready.publicPid, }; - writePreviewSession(session, stateHome); - return { type: "started", ...session }; } await sleep(200); } @@ -259,19 +439,28 @@ export async function stopBackgroundPreview( const stateHome = dependencies.stateHome ?? defaultStateHome(); const saved = readPreviewSession(projectDir, stateHome); const scanStart = saved?.port ?? startPort; - const server = matchingServer(await scan(scanStart), projectDir); - // A saved PID can be reused after a crashed preview, so only trust it while - // a currently reachable server proves this exact project is still running. - const pid = Number(server ? (server.pid ?? saved?.pid) : undefined); - if (!Number.isInteger(pid) || pid <= 0) { + const scanned = await scan(scanStart); + const server = saved + ? matchingServerAtPort(scanned, projectDir, saved.port) + : matchingServer(scanned, projectDir); + if (!server) { removePreviewSession(projectDir, stateHome); return false; } + // A saved PID can be reused after a crashed preview. The HTTP probe proves + // the project, but only the live server's own metadata proves which process + // owns it; never substitute the saved wrapper PID here. + const pid = Number(server.pid); + if (!Number.isInteger(pid) || pid <= 0) { + throw new Error(`preview ownership could not be proven for ${resolve(projectDir)}`); + } + + const kill = dependencies.kill ?? stopProcess; + kill(ownedStopTargetPid(saved, pid, dependencies)); - (dependencies.kill ?? stopProcess)(pid); const sleep = dependencies.sleep ?? delay; for (let attempt = 0; attempt < 25; attempt++) { - if (!matchingServer(await scan(scanStart), projectDir)) { + if (!matchingServerAtPort(await scan(scanStart), projectDir, server.port)) { removePreviewSession(projectDir, stateHome); return true; } diff --git a/packages/cli/src/commands/previewLifecycleOutput.test.ts b/packages/cli/src/commands/previewLifecycleOutput.test.ts new file mode 100644 index 0000000000..780b4e876c --- /dev/null +++ b/packages/cli/src/commands/previewLifecycleOutput.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + lifecycleFailurePayload, + lifecyclePayload, + writeLifecycleJson, + type PreviewLifecycleSession, +} from "./previewLifecycleOutput.js"; + +const session: PreviewLifecycleSession = { + state: "started", + mode: "background", + projectName: "demo", + projectDir: "/tmp/demo", + host: "127.0.0.1", + port: 3002, + pid: 42, + serverUrl: "http://127.0.0.1:3002", + studioUrl: "http://127.0.0.1:3002/#project/demo", + ready: true, + logPath: "/tmp/demo.log", +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("preview lifecycle JSON", () => { + it("versions a managed start result", () => { + expect(lifecyclePayload("start", session)).toEqual({ + schemaVersion: 1, + operation: "start", + ok: true, + result: session, + }); + }); + + it.each([ + ["status", { state: "not-running", projectDir: "/tmp/demo" }], + ["stop", { state: "stopped", projectDir: "/tmp/demo" }], + ["list", { state: "listed", sessions: [session] }], + ["kill-all", { state: "killed-all", stopped: 1 }], + ] as const)("versions the %s result", (operation, result) => { + expect(lifecyclePayload(operation, result)).toMatchObject({ + schemaVersion: 1, + operation, + ok: true, + result, + }); + }); + + it("writes exactly one parseable JSON document", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + writeLifecycleJson(lifecyclePayload("start", { ...session, pid: null, state: "reused" })); + + expect(log).toHaveBeenCalledOnce(); + const [line] = log.mock.calls[0] as [string]; + expect(JSON.parse(line)).toEqual({ + schemaVersion: 1, + operation: "start", + ok: true, + result: { ...session, pid: null, state: "reused" }, + }); + }); + + it("versions lifecycle failures with a stable code", () => { + expect( + lifecycleFailurePayload( + "start", + "conflicting-lifecycle-flags", + "--background and --foreground cannot be used together", + ), + ).toEqual({ + schemaVersion: 1, + operation: "start", + ok: false, + error: { + code: "conflicting-lifecycle-flags", + message: "--background and --foreground cannot be used together", + }, + }); + }); +}); diff --git a/packages/cli/src/commands/previewLifecycleOutput.ts b/packages/cli/src/commands/previewLifecycleOutput.ts new file mode 100644 index 0000000000..7f538f55e7 --- /dev/null +++ b/packages/cli/src/commands/previewLifecycleOutput.ts @@ -0,0 +1,59 @@ +export type PreviewLifecycleOperation = "start" | "status" | "stop" | "list" | "kill-all"; + +export type PreviewLifecycleState = "started" | "reused" | "running"; + +export interface PreviewLifecycleSession { + state: PreviewLifecycleState; + mode: "background" | "foreground" | "unknown"; + projectName: string; + projectDir: string; + host: string; + port: number; + pid: number | null; + serverUrl: string; + studioUrl: string; + ready: boolean; + logPath?: string; +} + +export type PreviewLifecycleResult = + | PreviewLifecycleSession + | { state: "not-running"; projectDir?: string } + | { state: "stopped"; projectDir: string } + | { state: "listed"; sessions: readonly PreviewLifecycleSession[] } + | { state: "killed-all"; stopped: number }; + +export interface PreviewLifecyclePayload { + schemaVersion: 1; + operation: PreviewLifecycleOperation; + ok: true; + result: PreviewLifecycleResult; +} + +export interface PreviewLifecycleFailurePayload { + schemaVersion: 1; + operation: PreviewLifecycleOperation; + ok: false; + error: { code: string; message: string }; +} + +export function lifecyclePayload( + operation: PreviewLifecycleOperation, + result: PreviewLifecycleResult, +): PreviewLifecyclePayload { + return { schemaVersion: 1, operation, ok: true, result }; +} + +export function lifecycleFailurePayload( + operation: PreviewLifecycleOperation, + code: string, + message: string, +): PreviewLifecycleFailurePayload { + return { schemaVersion: 1, operation, ok: false, error: { code, message } }; +} + +export function writeLifecycleJson( + payload: PreviewLifecyclePayload | PreviewLifecycleFailurePayload, +): void { + console.log(JSON.stringify(payload)); +} diff --git a/packages/cli/src/server/portUtils.ts b/packages/cli/src/server/portUtils.ts index ee9cb6f4a9..f62b6bb0eb 100644 --- a/packages/cli/src/server/portUtils.ts +++ b/packages/cli/src/server/portUtils.ts @@ -15,7 +15,6 @@ import http from "node:http"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { resolve } from "node:path"; -import { c } from "../ui/colors.js"; import type { BrowserGpuMode } from "../browser/gpuPolicy.js"; const execFileAsync = promisify(execFile); @@ -285,24 +284,7 @@ export async function scanActiveServers(startPort = 3002): Promise batchStart + i); - const results = await Promise.all( - ports.map(async (port) => { - const config = await probePort(port); - if (!config) return null; - const pid = - Number.isInteger(config.pid) && Number(config.pid) > 0 - ? String(config.pid) - : await getProcessOnPort(port); - return { - port, - projectName: config.projectName, - projectDir: config.projectDir, - version: config.version, - pid, - browserGpuMode: config.browserGpuMode, - }; - }), - ); + const results = await Promise.all(ports.map((port) => activeServerOnPort(port))); for (const r of results) { if (r) servers.push(r); @@ -312,6 +294,24 @@ export async function scanActiveServers(startPort = 3002): Promise { + const config = await probePort(port); + if (!config) return null; + const pid = + Number.isInteger(config.pid) && Number(config.pid) > 0 + ? String(config.pid) + : await getProcessOnPort(port); + return { + port, + projectName: config.projectName, + projectDir: config.projectDir, + version: config.version, + pid, + browserGpuMode: config.browserGpuMode, + }; +} + /** * Kill all active HyperFrames preview servers by sending SIGTERM to their PIDs. * Returns the number of servers killed. @@ -416,17 +416,9 @@ export async function findPortAndServe( return { type: "already-running", port }; } if (detection.type === "mismatch") { - console.log( - ` ${c.dim(`Port ${port} in use by HyperFrames project "${detection.projectName}" — skipping`)}`, - ); continue; } } - - const pid = await getProcessOnPort(port); - if (pid) { - console.log(` ${c.dim(`Port ${port} in use by PID ${pid} — skipping`)}`); - } } throw new Error( diff --git a/packages/cli/src/templates/_shared/AGENTS.md b/packages/cli/src/templates/_shared/AGENTS.md index 1762ea7a66..31ae625c33 100644 --- a/packages/cli/src/templates/_shared/AGENTS.md +++ b/packages/cli/src/templates/_shared/AGENTS.md @@ -33,7 +33,10 @@ The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes- ## Commands ```bash -npm run dev # start the preview server (long-running — keep it alive in background) +npm run dev # human-operated foreground preview (blocks until stopped) +npx hyperframes preview --background # agent-safe persistent Studio preview +npx hyperframes preview --status # verify the persistent preview is listening +npx hyperframes preview --stop # stop it when review is finished npm run check # lint + runtime + layout + motion + contrast (one command) npm run render # render to MP4 npm run publish # publish and get a shareable link @@ -42,9 +45,11 @@ npx hyperframes lint --json # machine-readable output for CI npx hyperframes docs # reference docs in terminal ``` -> **`npm run dev` is a long-running server, not a one-shot command.** It blocks until stopped. -> In Claude Code, always run it with `run_in_background: true`. Never run it as a foreground -> command — it will time out and the server will die, breaking the browser preview. +> **Agents must use `npx hyperframes preview --background` for Studio handoff.** Do not rely +> on a shell/tool `run_in_background` wrapper around `npm run dev`: that foreground process +> remains owned by the invoking session and can disappear while the browser stays open, +> leaving refreshes at `ERR_CONNECTION_TIMED_OUT`. Verify with `preview --status`, keep it +> alive through review, and stop it explicitly with `preview --stop` afterward. > **Pinned CLI version.** These scripts pin an exact `hyperframes@X.Y.Z` so this project re-renders identically over time. Weeks later that pin lags fixes shipped since. To move up: `npx hyperframes@latest upgrade --project . --check` (shows the delta), then `npx hyperframes@latest upgrade --project .` to rewrite the pins. Always unpinned — the pinned script re-runs the old version against itself. diff --git a/packages/cli/src/templates/_shared/CLAUDE.md b/packages/cli/src/templates/_shared/CLAUDE.md index 1762ea7a66..31ae625c33 100644 --- a/packages/cli/src/templates/_shared/CLAUDE.md +++ b/packages/cli/src/templates/_shared/CLAUDE.md @@ -33,7 +33,10 @@ The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes- ## Commands ```bash -npm run dev # start the preview server (long-running — keep it alive in background) +npm run dev # human-operated foreground preview (blocks until stopped) +npx hyperframes preview --background # agent-safe persistent Studio preview +npx hyperframes preview --status # verify the persistent preview is listening +npx hyperframes preview --stop # stop it when review is finished npm run check # lint + runtime + layout + motion + contrast (one command) npm run render # render to MP4 npm run publish # publish and get a shareable link @@ -42,9 +45,11 @@ npx hyperframes lint --json # machine-readable output for CI npx hyperframes docs # reference docs in terminal ``` -> **`npm run dev` is a long-running server, not a one-shot command.** It blocks until stopped. -> In Claude Code, always run it with `run_in_background: true`. Never run it as a foreground -> command — it will time out and the server will die, breaking the browser preview. +> **Agents must use `npx hyperframes preview --background` for Studio handoff.** Do not rely +> on a shell/tool `run_in_background` wrapper around `npm run dev`: that foreground process +> remains owned by the invoking session and can disappear while the browser stays open, +> leaving refreshes at `ERR_CONNECTION_TIMED_OUT`. Verify with `preview --status`, keep it +> alive through review, and stop it explicitly with `preview --stop` afterward. > **Pinned CLI version.** These scripts pin an exact `hyperframes@X.Y.Z` so this project re-renders identically over time. Weeks later that pin lags fixes shipped since. To move up: `npx hyperframes@latest upgrade --project . --check` (shows the delta), then `npx hyperframes@latest upgrade --project .` to rewrite the pins. Always unpinned — the pinned script re-runs the old version against itself. diff --git a/packages/cli/src/utils/orphanCleanup.test.ts b/packages/cli/src/utils/orphanCleanup.test.ts index b3dc2afdf2..aebe4d0b6c 100644 --- a/packages/cli/src/utils/orphanCleanup.test.ts +++ b/packages/cli/src/utils/orphanCleanup.test.ts @@ -1,13 +1,52 @@ import { describe, it, expect } from "vitest"; import { spawn } from "node:child_process"; -import { killProcessTree, killOrphanedProcesses } from "./orphanCleanup.js"; +import { + isProcessDescendant, + killProcessTree, + killOrphanedProcesses, + processIdentity, + windowsProcessTreeKillArgs, +} from "./orphanCleanup.js"; const IS_UNIX = process.platform !== "win32"; +describe("Windows process-tree cleanup", () => { + it("uses taskkill recursively and forcefully for the owned PID", () => { + expect(windowsProcessTreeKillArgs(4321)).toEqual(["/PID", "4321", "/T", "/F"]); + }); +}); + +describe("process-tree ownership", () => { + it("captures a stable birth token for the current process", () => { + const first = processIdentity(process.pid); + expect(first).toMatch(/^(?:linux|posix|windows):/); + expect(processIdentity(process.pid)).toBe(first); + expect(processIdentity(-1)).toBeNull(); + }); + + it("proves ancestry through every intermediate wrapper", () => { + const parents = new Map([ + [400, 300], + [300, 200], + [200, 1], + ]); + + expect(isProcessDescendant(400, 200, (pid) => parents.get(pid) ?? null)).toBe(true); + expect(isProcessDescendant(400, 999, (pid) => parents.get(pid) ?? null)).toBe(false); + }); + + it("fails closed on missing or cyclic process metadata", () => { + expect(isProcessDescendant(400, 200, () => null)).toBe(false); + expect(isProcessDescendant(400, 200, (pid) => (pid === 400 ? 300 : 400))).toBe(false); + }); +}); + describe.skipIf(!IS_UNIX)("killProcessTree", () => { it("kills a process and all its children", async () => { // Spawn a parent that spawns two sleeping children - const parent = spawn("bash", ["-c", "sleep 60 & sleep 60 & wait"], { stdio: "ignore" }); + const parent = spawn("bash", ["-c", "sleep 60 & sleep 60 & wait"], { + stdio: "ignore", + }); // Let children spawn await new Promise((r) => setTimeout(r, 200)); @@ -27,7 +66,9 @@ describe.skipIf(!IS_UNIX)("killProcessTree", () => { it("escalates to SIGKILL after grace period", async () => { // Spawn a process that traps SIGTERM - const proc = spawn("bash", ["-c", "trap '' TERM; sleep 60"], { stdio: "ignore" }); + const proc = spawn("bash", ["-c", "trap '' TERM; sleep 60"], { + stdio: "ignore", + }); await new Promise((r) => setTimeout(r, 100)); const exitPromise = new Promise((resolve) => proc.on("close", resolve)); diff --git a/packages/cli/src/utils/orphanCleanup.ts b/packages/cli/src/utils/orphanCleanup.ts index a34f01775c..ae630c0151 100644 --- a/packages/cli/src/utils/orphanCleanup.ts +++ b/packages/cli/src/utils/orphanCleanup.ts @@ -1,4 +1,5 @@ -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; +import { readFileSync } from "node:fs"; /** * Find and kill orphaned Chrome processes from previous crashed sessions. @@ -34,11 +35,20 @@ export function killOrphanedProcesses(): number { * depth-first so children are killed before parents, preventing * re-adoption races. * - * No-op on Windows — process groups are managed differently and - * the pgrep/ps utilities are not available. + * Windows uses taskkill's tree mode because pgrep/ps are unavailable there. */ export function killProcessTree(pid: number, signal: NodeJS.Signals = "SIGTERM"): void { - if (process.platform === "win32") return; + if (process.platform === "win32") { + try { + execFileSync("taskkill", windowsProcessTreeKillArgs(pid), { + stdio: "ignore", + timeout: 5000, + }); + } catch { + // Process already exited or taskkill could not inspect it. + } + return; + } const descendants = getDescendants(pid); const allPids = [...descendants.reverse(), pid]; @@ -65,10 +75,111 @@ export function killProcessTree(pid: number, signal: NodeJS.Signals = "SIGTERM") } } +export function windowsProcessTreeKillArgs(pid: number): string[] { + return ["/PID", String(pid), "/T", "/F"]; +} + +/** + * Return a process birth token suitable for detecting PID reuse. The token is + * diagnostic state only: callers must still prove the live server is a + * descendant before treating a saved wrapper as the owned process-tree root. + */ +export function processIdentity(pid: number): string | null { + if (!Number.isInteger(pid) || pid <= 0) return null; + try { + if (process.platform === "win32") { + const created = execFileSync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CreationDate.ToFileTimeUtc()`, + ], + { encoding: "utf8", timeout: 2000 }, + ).trim(); + return created ? `windows:${created}` : null; + } + + if (process.platform === "linux") { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + const fields = stat + .slice(stat.lastIndexOf(") ") + 2) + .trim() + .split(/\s+/); + const startTicks = fields[19]; // field 22 overall; fields starts at process state (3) + return startTicks ? `linux:${startTicks}` : null; + } + + const started = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { + encoding: "utf8", + timeout: 2000, + }).trim(); + return started ? `posix:${started}` : null; + } catch { + return null; + } +} + +type ParentPidLookup = (pid: number) => number | null; + +function processParentPid(pid: number): number | null { + try { + const output = + process.platform === "win32" + ? execFileSync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').ParentProcessId`, + ], + { encoding: "utf8", timeout: 2000 }, + ) + : execFileSync("ps", ["-o", "ppid=", "-p", String(pid)], { + encoding: "utf8", + timeout: 2000, + }); + const parentPid = Number(output.trim()); + return Number.isInteger(parentPid) && parentPid > 0 ? parentPid : null; + } catch { + return null; + } +} + +/** + * Prove that `childPid` currently belongs to the process tree rooted at + * `ancestorPid`. The walk fails closed on missing, invalid, or cyclic process + * metadata so a stale saved PID can never authorize terminating a new process. + */ +export function isProcessDescendant( + childPid: number, + ancestorPid: number, + parentPid: ParentPidLookup = processParentPid, +): boolean { + if (childPid <= 0 || ancestorPid <= 0 || childPid === ancestorPid) return false; + + const visited = new Set(); + let current = childPid; + for (let depth = 0; depth < 64; depth++) { + if (visited.has(current)) return false; + visited.add(current); + const parent = parentPid(current); + if (parent === ancestorPid) return true; + if (parent === null || parent <= 1) return false; + current = parent; + } + return false; +} + function getDescendants(pid: number): number[] { let children: number[]; try { - const raw = execSync(`pgrep -P ${pid}`, { encoding: "utf-8", timeout: 2000 }).trim(); + const raw = execSync(`pgrep -P ${pid}`, { + encoding: "utf-8", + timeout: 2000, + }).trim(); if (!raw) return []; children = raw .split("\n") diff --git a/packages/cli/src/utils/studioProxyEnv.test.ts b/packages/cli/src/utils/studioProxyEnv.test.ts index 9b95a2d436..10b1340ecb 100644 --- a/packages/cli/src/utils/studioProxyEnv.test.ts +++ b/packages/cli/src/utils/studioProxyEnv.test.ts @@ -12,4 +12,22 @@ describe("studioProxyEnv", () => { HYPERFRAMES_AUTO_PROXY: "false", }); }); + + it("identifies a detached Vite preview to the lifecycle scanner", () => { + expect( + studioProxyEnv( + true, + { KEEP: "yes" }, + { + projectDir: "/tmp/video", + projectName: "video", + browserGpuMode: "software", + }, + ), + ).toMatchObject({ + HYPERFRAMES_PREVIEW_PROJECT_DIR: "/tmp/video", + HYPERFRAMES_PREVIEW_PROJECT_NAME: "video", + HYPERFRAMES_PREVIEW_BROWSER_GPU_MODE: "software", + }); + }); }); diff --git a/packages/cli/src/utils/studioProxyEnv.ts b/packages/cli/src/utils/studioProxyEnv.ts index cd19e4060b..2974f814bc 100644 --- a/packages/cli/src/utils/studioProxyEnv.ts +++ b/packages/cli/src/utils/studioProxyEnv.ts @@ -1,9 +1,23 @@ export function studioProxyEnv( autoProxy: boolean, baseEnv: NodeJS.ProcessEnv = process.env, + preview?: { + projectDir: string; + projectName: string; + browserGpuMode?: "auto" | "hardware" | "software"; + }, ): NodeJS.ProcessEnv { return { ...baseEnv, HYPERFRAMES_AUTO_PROXY: autoProxy ? "true" : "false", + ...(preview + ? { + HYPERFRAMES_PREVIEW_PROJECT_DIR: preview.projectDir, + HYPERFRAMES_PREVIEW_PROJECT_NAME: preview.projectName, + ...(preview.browserGpuMode + ? { HYPERFRAMES_PREVIEW_BROWSER_GPU_MODE: preview.browserGpuMode } + : {}), + } + : {}), }; } diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index 1485e4f022..ef217b46f8 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -158,6 +158,12 @@ "types": "./dist/audioAutomation.d.ts", "environments": ["browser", "bun", "node"] }, + "./audio-gain": { + "source": "./src/audioGain.ts", + "runtime": "./dist/audioGain.js", + "types": "./dist/audioGain.d.ts", + "environments": ["browser", "bun", "node"] + }, "./color-grading": { "source": "./src/colorGrading.ts", "runtime": "./dist/colorGrading.js", diff --git a/packages/core/package.json b/packages/core/package.json index 41077e31d1..c830528b9c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -172,6 +172,12 @@ "import": "./src/audioAutomation.ts", "types": "./src/audioAutomation.ts" }, + "./audio-gain": { + "bun": "./src/audioGain.ts", + "node": "./dist/audioGain.js", + "import": "./src/audioGain.ts", + "types": "./src/audioGain.ts" + }, "./color-grading": { "bun": "./src/colorGrading.ts", "node": "./dist/colorGrading.js", @@ -478,6 +484,10 @@ "import": "./dist/audioAutomation.js", "types": "./dist/audioAutomation.d.ts" }, + "./audio-gain": { + "import": "./dist/audioGain.js", + "types": "./dist/audioGain.d.ts" + }, "./color-grading": { "import": "./dist/colorGrading.js", "types": "./dist/colorGrading.d.ts" diff --git a/packages/core/src/audioGain.test.ts b/packages/core/src/audioGain.test.ts new file mode 100644 index 0000000000..394c046f1b --- /dev/null +++ b/packages/core/src/audioGain.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + AUDIO_GAIN_FADER_MAX, + AUDIO_GAIN_FADER_MIN, + MAX_AUDIO_GAIN, + audioGainToFaderPosition, + audioGainToText, + audioFaderPositionToGain, +} from "./audioGain"; + +describe("audio gain fader", () => { + it("puts unity gain at the physical midpoint", () => { + expect(audioGainToFaderPosition(1)).toBe(0); + expect(audioFaderPositionToGain(0)).toBe(1); + }); + + it("provides +12 dB of boost above unity", () => { + expect(audioFaderPositionToGain(AUDIO_GAIN_FADER_MAX)).toBeCloseTo(MAX_AUDIO_GAIN, 6); + expect(audioGainToText(MAX_AUDIO_GAIN)).toBe("+12.0 dB"); + }); + + it("preserves a true silence endpoint below unity", () => { + expect(audioFaderPositionToGain(AUDIO_GAIN_FADER_MIN)).toBe(0); + expect(audioGainToText(0)).toBe("-∞ dB"); + }); + + it("pins sub-floor gain to the fader's silence endpoint", () => { + expect(audioGainToFaderPosition(0.00001)).toBe(AUDIO_GAIN_FADER_MIN); + }); + + it("round-trips representative attenuation and boost values", () => { + for (const gain of [0.1, 0.5, 1, 2, MAX_AUDIO_GAIN]) { + expect(audioFaderPositionToGain(audioGainToFaderPosition(gain))).toBeCloseTo(gain, 6); + } + }); +}); diff --git a/packages/core/src/audioGain.ts b/packages/core/src/audioGain.ts new file mode 100644 index 0000000000..41a98cf185 --- /dev/null +++ b/packages/core/src/audioGain.ts @@ -0,0 +1,54 @@ +/** + * Authoring gain for a media clip. + * + * HTMLMediaElement.volume is limited to 0..1, but HyperFrames' Web Audio + * preview and FFmpeg render paths both support gain above unity. Keep the + * shared ceiling here so Studio, preview, and render cannot drift. + */ +export const MAX_AUDIO_GAIN_DB = 12; +export const MAX_AUDIO_GAIN = 10 ** (MAX_AUDIO_GAIN_DB / 20); + +/** Studio fader coordinates. Unity is deliberately the physical midpoint. */ +export const AUDIO_GAIN_FADER_MIN = -100; +export const AUDIO_GAIN_FADER_MAX = 100; + +const MIN_AUDIO_GAIN_DB = -60; + +export function clampAudioGain(value: number): number { + if (!Number.isFinite(value)) return 1; + return Math.max(0, Math.min(MAX_AUDIO_GAIN, value)); +} + +export function clampNativeMediaVolume(value: number): number { + if (!Number.isFinite(value)) return 1; + return Math.max(0, Math.min(1, value)); +} + +export function audioFaderPositionToGain(position: number): number { + const safe = Math.max(AUDIO_GAIN_FADER_MIN, Math.min(AUDIO_GAIN_FADER_MAX, position)); + if (safe === AUDIO_GAIN_FADER_MIN) return 0; + const db = + safe < 0 + ? (safe / Math.abs(AUDIO_GAIN_FADER_MIN)) * Math.abs(MIN_AUDIO_GAIN_DB) + : (safe / AUDIO_GAIN_FADER_MAX) * MAX_AUDIO_GAIN_DB; + return 10 ** (db / 20); +} + +export function audioGainToFaderPosition(gain: number): number { + const safe = clampAudioGain(gain); + if (safe === 0) return AUDIO_GAIN_FADER_MIN; + const db = 20 * Math.log10(safe); + const position = + db < 0 + ? (db / Math.abs(MIN_AUDIO_GAIN_DB)) * Math.abs(AUDIO_GAIN_FADER_MIN) + : (db / MAX_AUDIO_GAIN_DB) * AUDIO_GAIN_FADER_MAX; + return Math.max(AUDIO_GAIN_FADER_MIN, Math.min(AUDIO_GAIN_FADER_MAX, position)); +} + +export function audioGainToText(gain: number): string { + const safe = clampAudioGain(gain); + if (safe === 0) return "-∞ dB"; + const db = 20 * Math.log10(safe); + const rounded = Math.abs(db) < 0.05 ? 0 : db; + return (rounded > 0 ? "+" : "") + rounded.toFixed(1) + " dB"; +} diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index cd8bd8b2ea..39f3e3c718 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -3146,7 +3146,7 @@ export function initSandboxRuntimeModular(): void { if (!(el instanceof HTMLMediaElement)) continue; const parsed = parseFloat(el.dataset.volume ?? ""); const clipVolume = Number.isFinite(parsed) ? parsed : 1; - el.volume = clipVolume * volume; + el.volume = Math.max(0, Math.min(1, clipVolume * volume)); } }, onSetMediaOutputMuted: (muted) => { diff --git a/packages/core/src/runtime/media.test.ts b/packages/core/src/runtime/media.test.ts index bf697161b8..fc182c32da 100644 --- a/packages/core/src/runtime/media.test.ts +++ b/packages/core/src/runtime/media.test.ts @@ -365,6 +365,25 @@ describe("syncRuntimeMedia", () => { expect(only).toBeCloseTo(0.55, 5); }); + it("sends boosted author gain to Web Audio while keeping the native element legal", () => { + const clip = createMockClip({ start: 0, end: 10, volume: 3.98 }); + Object.defineProperty(clip.el, "readyState", { value: 4, writable: true }); + let transportGain = -1; + + syncRuntimeMedia({ + clips: [clip], + timeSeconds: 1, + playing: true, + playbackRate: 1, + onElementVolume: (_el, volume) => { + transportGain = volume; + }, + }); + + expect(transportGain).toBeCloseTo(3.98, 5); + expect(clip.el.volume).toBe(1); + }); + /** * The render bakes the lane at CLIP-LOCAL time: prepareAudioTrack already * cut the wav with `-ss mediaStart`, so its t=0 is the clip's start, and diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index 40ae5ec176..08d41ebf29 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -2,6 +2,7 @@ import { swallow } from "./diagnostics"; import { interpolateVolumeGain, type VolumeKeyframe } from "./mediaVolumeEnvelope.js"; import { elementVolumeLaneGain } from "./audioAutomationVolume.js"; import { normalizePlaybackRate } from "./playbackRate.js"; +import { clampAudioGain, clampNativeMediaVolume } from "../audioGain.js"; export function readElementPlaybackRate(el: Element): number { const authored = Number.parseFloat(el.getAttribute("data-playback-rate") ?? ""); @@ -162,11 +163,6 @@ function isUnplayable(el: HTMLMediaElement): boolean { const lastRuntimeAppliedVolume = new WeakMap(); -function clampVolume(volume: number): number { - if (!Number.isFinite(volume)) return 1; - return Math.max(0, Math.min(1, volume)); -} - /** * Drop every per-source sync baseline tracked for `el` — offset drift * samples, the seek-past-buffered-range retry latch, and the last @@ -265,10 +261,10 @@ export function syncRuntimeMedia(params: { relTime = clip.mediaStart + ((relTime - clip.mediaStart) % loopLength); } } - const userVol = clampVolume(params.userVolume ?? 1); - const fallbackAuthorVolume = clampVolume(clip.volume ?? 1); + const userVol = clampNativeMediaVolume(params.userVolume ?? 1); + const fallbackAuthorVolume = clampAudioGain(clip.volume ?? 1); const previousRuntimeVolume = lastRuntimeAppliedVolume.get(el); - const currentElementVolume = clampVolume(el.volume); + const currentElementVolume = clampNativeMediaVolume(el.volume); let authorVolume: number; // An explicit volume lane owns the fader. It is checked before the probed @@ -284,7 +280,7 @@ export function syncRuntimeMedia(params: { // there is one time base, and this is it. const laneGain = elementVolumeLaneGain(el, params.timeSeconds - clip.start); if (laneGain !== null) { - authorVolume = clampVolume(laneGain); + authorVolume = clampAudioGain(laneGain); } else if (clip.volumeKeyframes && clip.volumeKeyframes.length > 0) { // Keyframes probed from the GSAP timeline — same source as the renderer. // Use the interpolated envelope value directly; no need to track GSAP changes. @@ -294,13 +290,13 @@ export function syncRuntimeMedia(params: { // and the playback rate — so it only coincides with the envelope's time base // for an untrimmed clip playing at 1x from t=0. const elapsedInClip = params.timeSeconds - clip.start; - authorVolume = clampVolume(interpolateVolumeGain(clip.volumeKeyframes, elapsedInClip)); + authorVolume = clampAudioGain(interpolateVolumeGain(clip.volumeKeyframes, elapsedInClip)); } else if (previousRuntimeVolume === undefined) { // First tick this clip is active. The transport has already seeked GSAP // to the current time (seekTimelineAndAdapters runs before syncRuntimeMedia), // so el.volume reflects the animated value — trust it rather than falling // back to data-volume, which would clobber the GSAP-seeked position. - authorVolume = currentElementVolume; + authorVolume = fallbackAuthorVolume > 1 ? fallbackAuthorVolume : currentElementVolume; } else if (Math.abs(currentElementVolume - previousRuntimeVolume) > 0.0001) { // GSAP (or user code) changed el.volume between ticks — track it. authorVolume = currentElementVolume; @@ -309,10 +305,11 @@ export function syncRuntimeMedia(params: { authorVolume = fallbackAuthorVolume; } - const effectiveVolume = clampVolume(authorVolume * userVol); - el.volume = effectiveVolume; - lastRuntimeAppliedVolume.set(el, effectiveVolume); - params.onElementVolume?.(el, effectiveVolume); + const effectiveGain = clampAudioGain(authorVolume * userVol); + const nativeVolume = clampNativeMediaVolume(effectiveGain); + el.volume = nativeVolume; + lastRuntimeAppliedVolume.set(el, nativeVolume); + params.onElementVolume?.(el, effectiveGain); // Mute only when force-muted or the transport owns this element; an unclaimed // track stays audible via the HTMLMedia fallback. if (forceMuteAll || params.isWebAudioOwned?.(el)) el.muted = true; diff --git a/packages/core/src/runtime/webAudioTransport.test.ts b/packages/core/src/runtime/webAudioTransport.test.ts index 35f935bc5a..725393ff98 100644 --- a/packages/core/src/runtime/webAudioTransport.test.ts +++ b/packages/core/src/runtime/webAudioTransport.test.ts @@ -166,6 +166,15 @@ describe("WebAudioTransport", () => { }); describe("schedulePlayback timing", () => { + it("keeps author boost above unity on the per-element gain node", async () => { + const { transport, mock, gen } = setupTransport(100); + + await transport.schedulePlayback(mockEl, mockBuffer, 0, 0, 0, 1, gen); + transport.setElementVolume(mockEl, 3.98); + + expect(mock.gainNode.gain.value).toBeCloseTo(3.98, 5); + }); + it("starts in-progress clips immediately with correct buffer offset", async () => { const { transport, mock, gen } = setupTransport(100); diff --git a/packages/core/src/runtime/webAudioTransport.ts b/packages/core/src/runtime/webAudioTransport.ts index e2e2dc66ed..92b812d0cf 100644 --- a/packages/core/src/runtime/webAudioTransport.ts +++ b/packages/core/src/runtime/webAudioTransport.ts @@ -7,6 +7,7 @@ import { import { VOLUME_RANGE } from "../audioAutomation.js"; import { swallow } from "./diagnostics"; import { getDebugSurface } from "./globals.js"; +import { clampAudioGain } from "../audioGain.js"; function normalizeRate(rate: number): number { if (!Number.isFinite(rate) || rate <= 0) return 1; @@ -206,7 +207,7 @@ export class WebAudioTransport { sourceNode.playbackRate.value = safeRate; const gainNode = this._ctx.createGain(); - gainNode.gain.value = volume; + gainNode.gain.value = clampAudioGain(volume); const elapsed = compositionTime - compositionStart; const scheduledAt = this._ctx.currentTime; @@ -353,7 +354,7 @@ export class WebAudioTransport { } setElementVolume(el: HTMLMediaElement, volume: number): void { - const safeVolume = Math.max(0, Math.min(1, volume)); + const safeVolume = clampAudioGain(volume); for (const source of this._activeSources) { if (source.el !== el) continue; try { diff --git a/packages/engine/src/services/audioMixer.test.ts b/packages/engine/src/services/audioMixer.test.ts index cac9b10ae8..891658b723 100644 --- a/packages/engine/src/services/audioMixer.test.ts +++ b/packages/engine/src/services/audioMixer.test.ts @@ -257,6 +257,35 @@ describe("processCompositionAudio", () => { expect(filter).not.toContain("weights="); }); + it("preserves authored clip gain above unity for quiet-source boosting", async () => { + const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); + const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); + tempDirs.push(baseDir, workDir); + writeFileSync(join(baseDir, "quiet.wav"), "stub"); + + const result = await processCompositionAudio( + [ + { + id: "quiet", + src: "quiet.wav", + start: 0, + end: 2, + mediaStart: 0, + layer: 0, + volume: 3.98, + type: "audio", + }, + ], + baseDir, + workDir, + join(baseDir, "out.m4a"), + 2, + ); + + expect(result.success).toBe(true); + expect(capturedFilterScripts[1]).toContain("volume=3.98"); + }); + it("lets an FX tail run past the clip, still bounded by the composition", async () => { // A reverb is still decaying when the clip's own audio stops. Trimming at // the clip boundary is what cut every tail short in the render. diff --git a/packages/engine/src/services/audioMixer.ts b/packages/engine/src/services/audioMixer.ts index e71d26587b..d3f146c560 100644 --- a/packages/engine/src/services/audioMixer.ts +++ b/packages/engine/src/services/audioMixer.ts @@ -41,6 +41,7 @@ import { import { chainTailSeconds } from "@hyperframes/core/audio-fx-tail"; import { applyAudioFxChain, AudioFxRenderError } from "./audioFxRender.js"; import type { AudioVolumeKeyframe } from "./audioMixer.types.js"; +import { clampAudioGain } from "@hyperframes/core/audio-gain"; export type { AudioElement, MixResult } from "./audioMixer.types.js"; @@ -60,8 +61,7 @@ export type { AudioElement, MixResult } from "./audioMixer.types.js"; export const MIXED_AUDIO_FILENAME = "audio.m4a"; function clampVolume(volume: number): number { - if (!Number.isFinite(volume)) return 1; - return Math.max(0, Math.min(1, volume)); + return clampAudioGain(volume); } function formatFilterNumber(value: number): string { diff --git a/packages/parsers/src/types.ts b/packages/parsers/src/types.ts index 4820ba34fd..f402726c2b 100644 --- a/packages/parsers/src/types.ts +++ b/packages/parsers/src/types.ts @@ -160,7 +160,7 @@ export interface TimelineMediaElement extends TimelineElementBase { isAroll?: boolean; sourceWidth?: number; sourceHeight?: number; - volume?: number; // 0-1 (0% to 100%), default 1.0 + volume?: number; // linear gain; 0 is silent, 1 is 0 dB, values above 1 boost hasAudio?: boolean; // For videos - indicates if video has audio track } diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx index 125f5a1830..e7ef34099f 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx @@ -131,9 +131,9 @@ describe("FlatMediaSection — cutout", () => { }); describe("FlatMediaSection — volume/rate/media-start", () => { - it("renders volume at its stored percentage and commits a new value on drag", () => { + it("renders unity volume as neutral 0 dB at the slider midpoint", () => { const onSetAttribute = vi.fn(); - const element = makeVideoElement({ dataAttributes: { volume: "0.5" } }); + const element = makeVideoElement({ dataAttributes: { volume: "1" } }); const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); @@ -149,13 +149,16 @@ describe("FlatMediaSection — volume/rate/media-start", () => { />, ); }); - expect(host.textContent).toContain("50%"); + expect(host.textContent).toContain("0.0 dB"); + expect( + host.querySelector('[data-flat-slider-track="true"]')?.getAttribute("aria-valuenow"), + ).toBe("0"); act(() => root.unmount()); }); - it("commits a new volume value on slider track pointerdown", () => { + it("commits +12 dB of boost from the upper half of the volume fader", () => { const onSetAttribute = vi.fn(); - const element = makeVideoElement({ dataAttributes: { volume: "0.2" } }); + const element = makeVideoElement({ dataAttributes: { volume: "1" } }); const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); @@ -176,11 +179,10 @@ describe("FlatMediaSection — volume/rate/media-start", () => { value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }), }); act(() => { - volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 })); - volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 })); + volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 })); + volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 })); }); - // starting volume 0.2 (draft=20); min=0, max=100, ratio=0.5 -> raw=50 -> commit(50) -> 50/100=0.5 -> "0.5" - expect(onSetAttribute).toHaveBeenCalledWith("volume", "0.5"); + expect(onSetAttribute).toHaveBeenCalledWith("volume", "3.98"); act(() => root.unmount()); }); diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx index 997483e136..e2fbf547d7 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx @@ -13,6 +13,13 @@ import { import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives"; import { FlatToggle } from "./propertyPanelFlatToggle"; import { AutomationToggle } from "./propertyPanelFxControls"; +import { + AUDIO_GAIN_FADER_MAX, + AUDIO_GAIN_FADER_MIN, + audioFaderPositionToGain, + audioGainToFaderPosition, + audioGainToText, +} from "@hyperframes/core/audio-gain"; // fallow-ignore-next-line complexity export function FlatMediaSection({ @@ -54,7 +61,7 @@ export function FlatMediaSection({ const el = element.element; const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1; - const volumePercent = Math.round(volume * 100); + const volumeFaderPosition = audioGainToFaderPosition(volume); const mediaStart = Number.parseFloat( element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0", @@ -215,13 +222,16 @@ export function FlatMediaSection({
void onSetAttribute("volume", formatNumericValue(next / 100))} + centerTick + onCommit={(next) => + void onSetAttribute("volume", formatNumericValue(audioFaderPositionToGain(next))) + } />
Volume `${Math.round(next)}%`} + displayValue={audioGainToText(volume)} + formatDisplayValue={(next) => audioGainToText(audioFaderPositionToGain(next))} onCommit={(next) => { - void onSetAttribute("volume", formatNumericValue(next / 100)); + void onSetAttribute("volume", formatNumericValue(audioFaderPositionToGain(next))); }} /> diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts index 97830c5d65..da670ed85f 100644 --- a/packages/studio/vite.config.ts +++ b/packages/studio/vite.config.ts @@ -5,6 +5,7 @@ import { join, resolve } from "node:path"; import { readNodeRequestBody } from "./vite.request-body.js"; import { watch } from "chokidar"; import { createViteAdapter } from "./vite.adapter"; +import { previewConfigPayload } from "./vite.preview-config"; async function loadRuntimeSourceForDev( server: import("vite").ViteDevServer, @@ -84,6 +85,14 @@ function devProjectApi(): Plugin { return _api; }; + server.middlewares.use((req, res, next) => { + if (req.url !== "/__hyperframes_config") return next(); + const payload = previewConfigPayload(process.env, process.pid, studioPkg.version); + if (!payload) return next(); + res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" }); + res.end(JSON.stringify(payload)); + }); + // Runtime endpoint — prefer source build over dist artifact server.middlewares.use((req, res, next) => { if (req.url !== "/api/runtime.js") return next(); diff --git a/packages/studio/vite.preview-config.test.ts b/packages/studio/vite.preview-config.test.ts new file mode 100644 index 0000000000..865de6cbf1 --- /dev/null +++ b/packages/studio/vite.preview-config.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { previewConfigPayload } from "./vite.preview-config"; + +describe("previewConfigPayload", () => { + it("identifies a detached Vite preview to the CLI lifecycle scanner", () => { + expect( + previewConfigPayload( + { + HYPERFRAMES_PREVIEW_PROJECT_DIR: "/tmp/video", + HYPERFRAMES_PREVIEW_PROJECT_NAME: "video", + HYPERFRAMES_PREVIEW_BROWSER_GPU_MODE: "software", + }, + 4321, + "0.7.109", + ), + ).toEqual({ + isHyperframes: true, + pid: 4321, + projectName: "video", + projectDir: "/tmp/video", + serverBuildSignature: null, + browserGpuMode: "software", + version: "0.7.109", + }); + }); + + it("does not claim unrelated direct Vite sessions", () => { + expect(previewConfigPayload({})).toBeNull(); + }); +}); diff --git a/packages/studio/vite.preview-config.ts b/packages/studio/vite.preview-config.ts new file mode 100644 index 0000000000..3fe0c38d1d --- /dev/null +++ b/packages/studio/vite.preview-config.ts @@ -0,0 +1,22 @@ +export type PreviewConfigEnv = Record; + +export function previewConfigPayload( + env: PreviewConfigEnv, + pid = process.pid, + version = "dev", +): Record | null { + const projectDir = env.HYPERFRAMES_PREVIEW_PROJECT_DIR; + const projectName = env.HYPERFRAMES_PREVIEW_PROJECT_NAME; + if (!projectDir || !projectName) return null; + + const browserGpuMode = env.HYPERFRAMES_PREVIEW_BROWSER_GPU_MODE; + return { + isHyperframes: true, + pid, + projectName, + projectDir, + serverBuildSignature: null, + ...(browserGpuMode ? { browserGpuMode } : {}), + version, + }; +} diff --git a/skills-manifest.json b/skills-manifest.json index f12b4ae853..2fc061beac 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -6,7 +6,7 @@ "files": 138 }, "faceless-explainer": { - "hash": "c70b904aa68cf7e5", + "hash": "e5142b87a79e62dd", "files": 24 }, "figma": { @@ -26,15 +26,15 @@ "files": 121 }, "hyperframes-audio": { - "hash": "534cea75fe0f2bc6", + "hash": "bcd72fa055559886", "files": 6 }, "hyperframes-cli": { - "hash": "e042fcaaa3f9767f", + "hash": "48a43f848bad1886", "files": 11 }, "hyperframes-core": { - "hash": "e9daaccaed05b8b4", + "hash": "62968dbb5b6930cb", "files": 19 }, "hyperframes-creative": { @@ -54,7 +54,7 @@ "files": 152 }, "motion-graphics": { - "hash": "1434e22bb0259bbb", + "hash": "69dc088b8e0d22fe", "files": 23 }, "music-to-video": { @@ -66,7 +66,7 @@ "files": 30 }, "product-launch-video": { - "hash": "81953f054fcb9d91", + "hash": "11368bf71a32ba49", "files": 28 }, "remotion-to-hyperframes": { @@ -74,11 +74,11 @@ "files": 70 }, "slideshow": { - "hash": "6a24a84b0c1a75f9", + "hash": "2029471821f6f371", "files": 2 }, "talking-head-recut": { - "hash": "2f5d99f823c48e75", + "hash": "214eda4c0f2bedb1", "files": 28 } } diff --git a/skills/faceless-explainer/SKILL.md b/skills/faceless-explainer/SKILL.md index 3a519b3f48..3ba1891a9c 100644 --- a/skills/faceless-explainer/SKILL.md +++ b/skills/faceless-explainer/SKILL.md @@ -191,7 +191,7 @@ If a command fails, surface stderr and stop — don't pile on recovery commands. After checks pass, pause for user review — the review loop's final look (`../hyperframes-core/references/review-loop.md` § 4): one question, on the Studio that has been open since Step 3 — render now, or what changes? (Autonomous: the one kept question, preview first or render.) Then deliver the MP4 with the contact sheet and the frame ids so revisions can target a single frame. -Preview: `npx hyperframes preview` +Preview: `npx hyperframes preview --background` Render only after user approval (autonomous mode: after the preview-or-render question): diff --git a/skills/hyperframes-audio/references/diagnosis.md b/skills/hyperframes-audio/references/diagnosis.md index e92e0166fd..47fb3786df 100644 --- a/skills/hyperframes-audio/references/diagnosis.md +++ b/skills/hyperframes-audio/references/diagnosis.md @@ -135,6 +135,27 @@ the ambiguity instead. ## Recipes +### Compare loudness from the bytes the listener actually hears + +Do not call two clips equally loud because their Studio faders, waveform peaks, +or cached asset metadata match. Those are controls and proxies, not a loudness +measurement. Resolve the exact URLs used by preview/render, download or inspect +those exact served bytes, and measure each decoded stream with FFmpeg's +`ebur128` filter. Compare the integrated LUFS values. + +For a target loudness, the required move is: + +```text +gain_db = target_lufs - measured_lufs +linear_gain = 10 ** (gain_db / 20) +``` + +Studio's clip-gain fader uses `0 dB` / linear gain `1` at its physical midpoint +and provides up to `+12 dB` on the upper half. After changing gain, measure the +served preview/render bytes again. If a listener still hears a mismatch, trust +the report and first verify the asset URL and bytes are current; do not explain +it away with matching peaks or a stale proxy measurement. + All verified with ffmpeg 8.1.1. `-hide_banner` keeps the output readable; `volumedetect` prints to stderr, so do not silence it with `-v error`. diff --git a/skills/hyperframes-cli/SKILL.md b/skills/hyperframes-cli/SKILL.md index b01ee482c1..1ead76589f 100644 --- a/skills/hyperframes-cli/SKILL.md +++ b/skills/hyperframes-cli/SKILL.md @@ -21,7 +21,7 @@ Run commands as `npx hyperframes ...` unless project instructions provide a wrap 4. **Get fast feedback while editing:** run `npx hyperframes lint` after the first HTML pass and after structural changes. 5. **Run the final gate:** run `npx hyperframes check`; it reruns lint before opening the browser. Do not prepend a redundant standalone lint invocation. Add `--snapshots` for annotated overview frames and finding crops. 6. **Inspect sub-compositions:** when `index.html` mounts `data-composition-src`, capture midpoint snapshots and inspect each mounted scene. -7. **Open the final Studio preview:** run `npx hyperframes preview`, hand the timeline project URL to the user, and ask whether to revise or render. +7. **Open the final Studio preview:** run `npx hyperframes preview --background`, verify the URL returns HTTP 200, hand the timeline project URL to the user, and ask whether to revise or render. Keep it alive until review ends. 8. **Render only after approval:** use draft quality for iteration and high quality for delivery. 9. **Verify the output:** confirm the file exists, is non-empty, and has a plausible duration. @@ -31,7 +31,7 @@ npx hyperframes lint # Required final gate; includes lint. npx hyperframes check -npx hyperframes preview +npx hyperframes preview --background npx hyperframes render --quality high --output out.mp4 test -s out.mp4 ffprobe -v error -show_format out.mp4 diff --git a/skills/hyperframes-cli/references/preview-render.md b/skills/hyperframes-cli/references/preview-render.md index f5ed1f4e8e..db0e351ee2 100644 --- a/skills/hyperframes-cli/references/preview-render.md +++ b/skills/hyperframes-cli/references/preview-render.md @@ -5,8 +5,10 @@ Serve, render, and share commands. ## preview ```bash -npx hyperframes preview # serve current directory -npx hyperframes preview --port 4567 # custom port (default 3002) +npx hyperframes preview # foreground on a TTY; persistent in agent shells +npx hyperframes preview --background # explicit persistent session +npx hyperframes preview --foreground --json # ready JSON, then remain attached +npx hyperframes preview --background --port 4567 # agent-safe 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 ``` @@ -19,11 +21,11 @@ When handing a project back to the user, use the Studio project URL, not the sou 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`. +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 --background --port 3017` in `codex-openai-video`, report `http://localhost:3017/#project/codex-openai-video`. To land the user on the **Storyboard view** instead of the timeline, put `?view=storyboard` ahead of the hash: `http://localhost:/?view=storyboard#project/`. Hand this URL whenever the storyboard is the thing to review and nothing is assembled yet — before `index.html` exists, the timeline stage has nothing to show, so the bare project URL opens on an empty player. -Two ways a handed URL turns out dead — check both before handing it back: the URL is missing its `#project/` hash (Studio loads but has no project to open), or the server is not actually running. `preview` is a long-running process — start it from the project directory as a background task, and if that task reports it exited ("completed"), the server is down: restart it, don't hand out the link. +Two ways a handed URL turns out dead — check both before handing it back: the URL is missing its `#project/` hash (Studio loads but has no project to open), or the server is not actually running. Bare `preview` automatically creates a managed persistent session in a non-TTY agent shell; `--background` remains the clearest explicit form. Verify the printed URL returns HTTP 200, keep it alive for the whole review, and stop it explicitly with `npx hyperframes preview --stop` afterward. Use the printed URL as-is: HyperFrames URL-encodes project names that contain route metacharacters. ### Agent context from Studio selection @@ -57,7 +59,7 @@ Failure modes: | Code | Meaning | | -------------------------- | -------------------------------------------------------------------------- | -| `preview-not-running` | Start Studio first with `npx hyperframes preview`. | +| `preview-not-running` | Start Studio first with `npx hyperframes preview --background`. | | `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. | @@ -89,7 +91,7 @@ Both `preview` and `play` can open inside an explicit Chromium-compatible browse ```bash # Open preview in an isolated Chromium profile -npx hyperframes preview --browser-path /usr/bin/chromium --user-data-dir /tmp/hf-profile +npx hyperframes preview --background --browser-path /usr/bin/chromium --user-data-dir /tmp/hf-profile # Same plus a CDP endpoint on :9222 (attach DevTools / Playwright / etc.) npx hyperframes play --browser-path /usr/bin/chromium --user-data-dir /tmp/hf-profile --remote-debugging-port 9222 diff --git a/skills/hyperframes-core/SKILL.md b/skills/hyperframes-core/SKILL.md index 3e44a4e54a..1ee9f83549 100644 --- a/skills/hyperframes-core/SKILL.md +++ b/skills/hyperframes-core/SKILL.md @@ -85,5 +85,5 @@ Use `hyperframes-cli` for command details - [ ] `npx hyperframes check` passes (0 findings across lint, runtime, layout, motion, and contrast) - [ ] Projects with sub-compositions: `npx hyperframes snapshot --at ` and eyeball each frame -- [ ] `npx hyperframes preview` for review (the user can edit anything in Studio's timeline) +- [ ] `npx hyperframes preview --background` for review (the user can edit anything in Studio's timeline, and the server survives the invoking command) - [ ] `npx hyperframes render` only after the user approves diff --git a/skills/hyperframes-core/references/review-loop.md b/skills/hyperframes-core/references/review-loop.md index 7ae9d5e6df..8a9ecc3d67 100644 --- a/skills/hyperframes-core/references/review-loop.md +++ b/skills/hyperframes-core/references/review-loop.md @@ -6,7 +6,7 @@ This is the shared process for any workflow that plans on a storyboard. The cont ## § 1 — The plan, on a live board -Open the **storyboard board** before presenting the plan: run `npx hyperframes preview` from the project directory in the background, confirm it is serving, and open `http://localhost:/?view=storyboard#project/`. This is an early planning surface, not the final composition preview; it may open before composition checks. The plan appears as frame cards and refreshes as work lands. +Open the **storyboard board** before presenting the plan: run `npx hyperframes preview --background` from the project directory, confirm it is serving, and open `http://localhost:/?view=storyboard#project/`. This is an early planning surface, not the final composition preview; it may open before composition checks. The plan appears as frame cards and refreshes as work lands. Present the plan as a proposal (shape: `hyperframes-creative/references/story-spine.md` § 3): open by echoing **"This video tells [audience] that [message]"**, then the frame table — one row per frame: frame · beat (type, duration) · on screen · why (its `narrativeRole`, traced to the message). Hand the board URL with it, noting feedback lands in both places — comment on the board or reply here, one revision loop — and that a board submit still needs one reply here (anything) to get picked up. diff --git a/skills/motion-graphics/SKILL.md b/skills/motion-graphics/SKILL.md index 90bf727088..e0302dbee9 100644 --- a/skills/motion-graphics/SKILL.md +++ b/skills/motion-graphics/SKILL.md @@ -136,7 +136,7 @@ Choose proof times that show the opening state, signature move, and final hold. Ask one question: “preview first, or render?” If the user chooses preview, open Studio and return to the same approval gate after revisions: ```bash -(cd "$PROJECT_DIR" && npx hyperframes preview) +(cd "$PROJECT_DIR" && npx hyperframes preview --background) ``` Render only after an explicit render answer: diff --git a/skills/product-launch-video/SKILL.md b/skills/product-launch-video/SKILL.md index 1fd786de99..464604cfdd 100644 --- a/skills/product-launch-video/SKILL.md +++ b/skills/product-launch-video/SKILL.md @@ -220,7 +220,7 @@ If a command fails, surface stderr and stop — don't pile on recovery commands. After checks pass, pause for user review — the review loop's final look (`../hyperframes-core/references/review-loop.md` § 4): one question, on the Studio that has been open since Step 3 — render now, or what changes? (Autonomous: the one kept question, preview first or render.) Then deliver the MP4 with the contact sheet and the frame ids so revisions can target a single frame. -Preview: `npx hyperframes preview` +Preview: `npx hyperframes preview --background` Render only after user approval (autonomous mode: after the preview-or-render question): diff --git a/skills/slideshow/SKILL.md b/skills/slideshow/SKILL.md index fffa3f99b2..57df159ff6 100644 --- a/skills/slideshow/SKILL.md +++ b/skills/slideshow/SKILL.md @@ -494,7 +494,7 @@ Studio/`preview` is useful for editing a composition, but it is not a clear fina { "scripts": { "dev": "npx hyperframes present ./composition", - "studio": "npx hyperframes preview ./composition" + "studio": "npx hyperframes preview ./composition --background" } } ``` diff --git a/skills/talking-head-recut/SKILL.md b/skills/talking-head-recut/SKILL.md index a9c797ff52..275c7bf129 100644 --- a/skills/talking-head-recut/SKILL.md +++ b/skills/talking-head-recut/SKILL.md @@ -1204,7 +1204,7 @@ Tell the user: **Optional live preview (on request only).** The clip plays unchanged inside `public/index.html` with the overlays on top, so it previews faithfully. **Don't open it during the run.** When the user asks, start a long-lived server **after** render and report the URL: ```bash -(cd "$WORK_DIR/public" && npx hyperframes preview) # or `npx hyperframes play` for a shareable link +(cd "$WORK_DIR/public" && npx hyperframes preview --background) # or `npx hyperframes play` for a shareable link ``` Do not delete the work directory unless the user asks.