diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 979d768ff..52dc3e3ad 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -469,6 +469,10 @@ // pre-existing per-rule scaffold; adding a clip-path case shifts lines and // re-flags it. Collapsing the per-rule installers would obscure each case. "packages/cli/src/commands/layout-audit.browser.test.ts", + // portUtils.ts contains two pre-existing bounded HTTP probe implementations; + // this PR only teaches the server scan to prefer its reported PID, but that + // line shift makes fallow re-flag the inherited probe clones. + "packages/cli/src/server/portUtils.ts", // gsapParserAcorn.motionEval.test.ts: parallel arrange/act/assert cases for // the staggered-collection honesty pass (.from reveal vs .to landing on the // rest pose). Each asserts a distinct keyframe shape; collapsing the shared @@ -565,6 +569,10 @@ // is pre-existing; the line-shift fingerprint problem makes fallow treat // the violations as new even though no logic changed. "packages/cli/src/server/studioServer.ts", + // findPortAndServe's existing port-selection flow predates this PR. The + // preview-lifecycle change only adds PID metadata to the config response, + // which shifts the inherited complexity fingerprint in portUtils.ts. + "packages/cli/src/server/portUtils.ts", "packages/core/src/core.types.ts", "packages/core/src/generators/hyperframes.ts", "packages/producer/src/services/htmlCompiler.ts", diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index b3f6cd5ed..9e1ad7559 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -10,6 +10,9 @@ export const examples: Example[] = [ ["Preview a specific project directory", "hyperframes preview ./my-video"], ["Use a custom port", "hyperframes preview --port 8080"], ["Force a new server even if one is already running", "hyperframes preview --force-new"], + ["Keep preview running after this command exits", "hyperframes preview --background"], + ["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"], ["Open with a specific browser", "hyperframes preview --browser-path /usr/bin/chromium"], [ @@ -44,6 +47,11 @@ import { } from "../server/portUtils.js"; import { killOrphanedProcesses, killProcessTree } from "../utils/orphanCleanup.js"; import { resolveProject } from "../utils/project.js"; +import { + readBackgroundPreviewStatus, + startBackgroundPreview, + stopBackgroundPreview, +} from "./previewLifecycle.js"; interface BrowserLaunchOptions { noOpen?: boolean; @@ -90,6 +98,21 @@ export default defineCommand({ description: "Start a new server even if one is already running for this project", default: false, }, + background: { + type: "boolean", + description: "Start an embedded preview that remains running after the command exits", + default: false, + }, + status: { + type: "boolean", + description: "Show the background preview for this project and exit", + default: false, + }, + stop: { + type: "boolean", + description: "Stop the background preview for this project and exit", + default: false, + }, list: { type: "boolean", description: "List all active preview servers and exit", @@ -154,6 +177,30 @@ export default defineCommand({ const startPort = parseInt(args.port ?? "3002", 10); 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`, + ); + return; + } + const status = await readBackgroundPreviewStatus(project.dir, startPort); + if (!status) { + console.log(`\n ${c.dim("No background preview is running for")} ${project.dir}\n`); + 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); @@ -267,6 +314,11 @@ export default defineCommand({ } if (isDevMode()) { + if (args.background) { + clack.log.error("--background currently supports the embedded preview server only"); + process.exitCode = 1; + return; + } return runDevMode(dir, { projectName, noOpen, @@ -279,6 +331,11 @@ export default defineCommand({ // 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"); + process.exitCode = 1; + return; + } return runLocalStudioMode(dir, { projectName, noOpen, @@ -289,6 +346,38 @@ export default defineCommand({ }); } + if (args.background) { + let background; + try { + background = await startBackgroundPreview(dir, startPort, { + forceNew: Boolean(args["force-new"]), + }); + } catch (error) { + clack.log.error(errorMessage(error)); + process.exitCode = 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, { + noOpen, + browserPath, + userDataDir, + remoteDebuggingPort, + browserNoGpu, + }); + return; + } + const forceNew = !!args["force-new"]; return runEmbeddedMode(dir, startPort, { projectName, diff --git a/packages/cli/src/commands/previewLifecycle.test.ts b/packages/cli/src/commands/previewLifecycle.test.ts new file mode 100644 index 000000000..86ba10e89 --- /dev/null +++ b/packages/cli/src/commands/previewLifecycle.test.ts @@ -0,0 +1,221 @@ +import { existsSync, mkdtempSync } 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, + previewSessionPath, + readBackgroundPreviewStatus, + startBackgroundPreview, + stopBackgroundPreview, + writePreviewSession, +} from "./previewLifecycle.js"; + +const projectDir = resolve("/tmp/hyperframes-preview-lifecycle-project"); +const server: ActiveServer = { + port: 3210, + projectName: "preview-lifecycle-project", + projectDir, + version: "test", + pid: "4321", +}; + +function savePreviewSession(stateHome: string): void { + writePreviewSession( + { pid: 4321, port: 3210, projectDir, logPath: "/tmp/preview.log" }, + stateHome, + ); +} + +async function expectStaleSessionRemoved(stateHome: string): Promise { + const status = await readBackgroundPreviewStatus(projectDir, 3002, { + scan: async () => [], + stateHome, + }); + + expect(status).toBeNull(); + expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(false); +} + +describe("background preview lifecycle", () => { + it("keeps case-distinct project paths separate on case-sensitive platforms", () => { + if (process.platform === "win32") return; + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + + expect(previewSessionPath("/tmp/Project", stateHome)).not.toBe( + previewSessionPath("/tmp/project", stateHome), + ); + }); + + it("builds a detached child invocation without recursively preserving --background", () => { + expect( + buildBackgroundPreviewArgs([ + "/opt/hyperframes/cli.js", + "preview", + projectDir, + "--background", + "--open", + ]), + ).toEqual(["/opt/hyperframes/cli.js", "preview", projectDir, "--no-open"]); + }); + + it("reuses an already-running server for the same project", async () => { + const spawn = vi.fn(); + const scan = vi.fn(async () => [server]); + + const result = await startBackgroundPreview(projectDir, 3002, { + argv: ["/opt/hyperframes/cli.js", "preview", projectDir, "--background"], + execPath: "/usr/bin/node", + scan, + spawn, + stateHome: mkdtempSync(join(tmpdir(), "hf-preview-state-")), + }); + + expect(result).toMatchObject({ type: "reused", port: 3210 }); + expect(spawn).not.toHaveBeenCalled(); + }); + + 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; + const scan = vi.fn(async () => (++scans < 3 ? [server] : [server, replacement])); + const spawn = vi.fn(() => ({ pid: 5432, unref: vi.fn() })); + + const result = await startBackgroundPreview(projectDir, 3002, { + forceNew: true, + scan, + spawn, + sleep: async () => {}, + stateHome: mkdtempSync(join(tmpdir(), "hf-preview-state-")), + }); + + expect(result).toMatchObject({ type: "started", port: 3211, pid: 5432 }); + expect(spawn).toHaveBeenCalledOnce(); + }); + + it("returns after a detached child becomes reachable and records its session", async () => { + let scans = 0; + const scan = vi.fn(async () => (++scans < 2 ? [] : [server])); + const unref = vi.fn(); + const spawn = vi.fn(() => ({ pid: 4321, unref })); + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + + const result = await startBackgroundPreview(projectDir, 3002, { + argv: ["/opt/hyperframes/cli.js", "preview", projectDir, "--background"], + execPath: "/usr/bin/node", + scan, + spawn, + sleep: async () => {}, + stateHome, + }); + + expect(result).toMatchObject({ type: "started", port: 3210, pid: 4321 }); + expect(unref).toHaveBeenCalledOnce(); + expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(true); + }); + + it("removes a stale session when no matching server or process survives", async () => { + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + writePreviewSession( + { pid: 999_999, port: 3210, projectDir, logPath: "/tmp/missing.log" }, + stateHome, + ); + + await expectStaleSessionRemoved(stateHome); + }); + + it("removes stale session metadata when its PID is alive but no server proves ownership", async () => { + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + savePreviewSession(stateHome); + + await expectStaleSessionRemoved(stateHome); + }); + + it("uses the recorded custom port when status is called without repeating --port", async () => { + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + savePreviewSession(stateHome); + const scan = vi.fn(async () => [server]); + + const status = await readBackgroundPreviewStatus(projectDir, 3002, { scan, stateHome }); + + expect(status?.port).toBe(3210); + expect(scan).toHaveBeenCalledWith(3210); + }); + + it("stops only the matching project server and waits until it is unreachable", async () => { + let running = true; + const scan = vi.fn(async () => (running ? [server] : [])); + const kill = vi.fn(() => { + running = false; + }); + + const result = await stopBackgroundPreview(projectDir, 3002, { + scan, + kill, + sleep: async () => {}, + stateHome: mkdtempSync(join(tmpdir(), "hf-preview-state-")), + }); + + expect(result).toBe(true); + expect(kill).toHaveBeenCalledWith(4321); + expect(scan).toHaveBeenCalledTimes(2); + }); + + it("does not kill an unmatched saved PID that may have been reused", async () => { + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + savePreviewSession(stateHome); + const kill = vi.fn(); + + const result = await stopBackgroundPreview(projectDir, 3002, { + scan: async () => [], + kill, + stateHome, + }); + + expect(result).toBe(false); + expect(kill).not.toHaveBeenCalled(); + expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(false); + }); + + it("uses the saved child PID when a matching live server cannot report one", async () => { + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + writePreviewSession( + { pid: 4321, 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 result = await stopBackgroundPreview(projectDir, 3002, { + scan, + kill, + sleep: async () => {}, + stateHome, + }); + + expect(result).toBe(true); + expect(kill).toHaveBeenCalledWith(4321); + }); + + it("fails loudly when the server remains reachable after stop", async () => { + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + writePreviewSession( + { pid: 4321, port: 3210, projectDir, logPath: "/tmp/preview.log" }, + stateHome, + ); + + await expect( + stopBackgroundPreview(projectDir, 3002, { + scan: async () => [server], + kill: vi.fn(), + sleep: async () => {}, + stateHome, + }), + ).rejects.toThrow(/did not stop/i); + expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/previewLifecycle.ts b/packages/cli/src/commands/previewLifecycle.ts new file mode 100644 index 000000000..4082370d2 --- /dev/null +++ b/packages/cli/src/commands/previewLifecycle.ts @@ -0,0 +1,267 @@ +import { spawn as nodeSpawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { scanActiveServers, type ActiveServer } from "../server/portUtils.js"; +import { killProcessTree } from "../utils/orphanCleanup.js"; + +export interface PreviewSession { + pid: number; + port: number; + projectDir: string; + logPath: string; +} + +type SpawnResult = { pid?: number; unref(): void }; +type SpawnPreview = ( + command: string, + args: string[], + options: { + detached: boolean; + stdio: ["ignore", number, number]; + env: NodeJS.ProcessEnv; + }, +) => SpawnResult; + +interface LifecycleDependencies { + argv?: string[]; + execPath?: string; + scan?: (startPort?: number) => Promise; + spawn?: SpawnPreview; + sleep?: (ms: number) => Promise; + kill?: (pid: number) => void; + stateHome?: string; + forceNew?: boolean; +} + +function defaultStateHome(): string { + return process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"); +} + +function normalized(path: string): string { + const resolved = resolve(path).replace(/\\/g, "/"); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function sessionDirectory(stateHome = defaultStateHome()): string { + return join(stateHome, "hyperframes", "previews"); +} + +export function previewSessionPath(projectDir: string, stateHome = defaultStateHome()): string { + const key = createHash("sha256").update(normalized(projectDir)).digest("hex").slice(0, 16); + return join(sessionDirectory(stateHome), `${key}.json`); +} + +function previewLogPath(projectDir: string, stateHome = defaultStateHome()): string { + return previewSessionPath(projectDir, stateHome).replace(/\.json$/, ".log"); +} + +export function writePreviewSession(session: PreviewSession, stateHome = defaultStateHome()): void { + const path = previewSessionPath(session.projectDir, stateHome); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(session, null, 2)}\n`, { mode: 0o600 }); +} + +function readPreviewSession( + projectDir: string, + stateHome = defaultStateHome(), +): PreviewSession | null { + const path = previewSessionPath(projectDir, stateHome); + if (!existsSync(path)) return null; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as PreviewSession; + if ( + !Number.isInteger(parsed.pid) || + parsed.pid <= 0 || + normalized(parsed.projectDir) !== normalized(projectDir) + ) { + 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 }); +} + +function matchingServer(servers: ActiveServer[], projectDir: string): ActiveServer | null { + return servers.find((server) => normalized(server.projectDir) === normalized(projectDir)) ?? null; +} + +function stopProcess(pid: number): void { + killProcessTree(pid); + if (process.platform === "win32") { + try { + process.kill(pid, "SIGTERM"); + } catch { + // Process already exited. + } + } +} + +const delay = (ms: number): Promise => new Promise((done) => setTimeout(done, ms)); + +function spawnDetachedPreview( + projectDir: string, + stateHome: string, + dependencies: LifecycleDependencies, +): { pid: number; logPath: string } { + const logPath = previewLogPath(projectDir, stateHome); + mkdirSync(dirname(logPath), { recursive: true }); + const logFd = openSync(logPath, "a", 0o600); + const spawn = dependencies.spawn ?? (nodeSpawn as unknown as SpawnPreview); + let child: SpawnResult; + try { + child = spawn( + dependencies.execPath ?? process.execPath, + buildBackgroundPreviewArgs(dependencies.argv ?? process.argv.slice(1)), + { + detached: true, + stdio: ["ignore", logFd, logFd], + env: process.env, + }, + ); + } finally { + closeSync(logFd); + } + if (!child.pid) throw new Error("background preview child did not report a PID"); + child.unref(); + return { pid: child.pid, logPath }; +} + +function startedServer( + servers: ActiveServer[], + projectDir: string, + existing: ActiveServer | null, + forceNew: boolean, +): ActiveServer | null { + const candidates = + forceNew && existing ? servers.filter((server) => server.port !== existing.port) : servers; + return matchingServer(candidates, projectDir); +} + +export function buildBackgroundPreviewArgs(argv: string[]): string[] { + const filtered = argv.filter( + (arg) => + arg !== "--background" && + !arg.startsWith("--background=") && + arg !== "--open" && + arg !== "--no-open", + ); + return [...filtered, "--no-open"]; +} + +export async function readBackgroundPreviewStatus( + projectDir: string, + startPort: number, + dependencies: LifecycleDependencies = {}, +): Promise { + const scan = dependencies.scan ?? scanActiveServers; + const stateHome = dependencies.stateHome ?? defaultStateHome(); + const saved = readPreviewSession(projectDir, stateHome); + const server = matchingServer(await scan(saved?.port ?? startPort), projectDir); + if (server) { + const pid = Number(server.pid ?? saved?.pid); + if (Number.isInteger(pid) && pid > 0) { + return { + pid, + port: server.port, + projectDir: resolve(projectDir), + logPath: saved?.logPath ?? previewLogPath(projectDir, stateHome), + }; + } + } + + removePreviewSession(projectDir, stateHome); + return null; +} + +export async function startBackgroundPreview( + projectDir: string, + startPort: number, + dependencies: LifecycleDependencies = {}, +): Promise< + | { type: "reused"; port: number; pid: number | null; logPath: string | null } + | { type: "started"; port: number; pid: number; logPath: string } +> { + const scan = dependencies.scan ?? scanActiveServers; + const existing = matchingServer(await scan(startPort), projectDir); + if (existing && !dependencies.forceNew) { + return { + type: "reused", + port: existing.port, + pid: existing.pid ? Number(existing.pid) : null, + logPath: null, + }; + } + + const stateHome = dependencies.stateHome ?? defaultStateHome(); + const { pid, 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, + ); + if (server) { + const session = { + pid, + port: server.port, + projectDir: resolve(projectDir), + logPath, + }; + writePreviewSession(session, stateHome); + return { type: "started", ...session }; + } + await sleep(200); + } + + (dependencies.kill ?? stopProcess)(pid); + throw new Error(`background preview did not become ready; see ${logPath}`); +} + +export async function stopBackgroundPreview( + projectDir: string, + startPort: number, + dependencies: LifecycleDependencies = {}, +): Promise { + const scan = dependencies.scan ?? scanActiveServers; + 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) { + removePreviewSession(projectDir, stateHome); + return false; + } + + (dependencies.kill ?? stopProcess)(pid); + const sleep = dependencies.sleep ?? delay; + for (let attempt = 0; attempt < 25; attempt++) { + if (!matchingServer(await scan(scanStart), projectDir)) { + removePreviewSession(projectDir, stateHome); + return true; + } + await sleep(100); + } + throw new Error(`background preview did not stop for ${resolve(projectDir)}`); +} diff --git a/packages/cli/src/server/portUtils.ts b/packages/cli/src/server/portUtils.ts index 92746a2f6..a8db6f75e 100644 --- a/packages/cli/src/server/portUtils.ts +++ b/packages/cli/src/server/portUtils.ts @@ -97,6 +97,7 @@ export async function testPortOnAllHosts( interface HyperframesConfigResponse { isHyperframes: boolean; + pid?: number; projectName: string; projectDir: string; serverBuildSignature?: string | null; @@ -278,7 +279,10 @@ export async function scanActiveServers(startPort = 3002): Promise { const config = await probePort(port); if (!config) return null; - const pid = await getProcessOnPort(port); + const pid = + Number.isInteger(config.pid) && Number(config.pid) > 0 + ? String(config.pid) + : await getProcessOnPort(port); return { port, projectName: config.projectName, diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index c134fb103..f0cc83b79 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -600,6 +600,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { const serverBuildSignature = await loadPreviewServerBuildSignature(); return c.json({ isHyperframes: true, + pid: process.pid, projectName: projectId, projectDir: projectDir, serverBuildSignature, diff --git a/skills-manifest.json b/skills-manifest.json index 23800169f..18be5bca5 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -58,7 +58,7 @@ "files": 132 }, "pr-to-video": { - "hash": "7ea2d086eb5a5856", + "hash": "688b43a31bbc9360", "files": 28 }, "product-launch-video": { diff --git a/skills/pr-to-video/SKILL.md b/skills/pr-to-video/SKILL.md index 94352251f..f5bf8a98a 100644 --- a/skills/pr-to-video/SKILL.md +++ b/skills/pr-to-video/SKILL.md @@ -138,7 +138,7 @@ Read `../hyperframes-creative/references/story-spine.md` (hook language, value-b Use `story-design.md` for the PR archetype (changelog / feature-reveal / fix-explainer / refactor-walkthrough), the PR-native frame types, hook, persuasion, beats, the per-frame word budget, and the credits close. The sequence comes from **narrative design, not the diff's file order** — explain the change, don't read the diff aloud. As a **soft guide**, consult the role→blueprint menu in `../hyperframes-animation/blueprints-index.md`: for each beat, write the voiceover in the shape its candidate blueprint implies and tag that candidate `blueprint:` id when one fits (story truth still decides which beats exist — never force a beat to fit a shape). Feature 2–4 real diff hunks (from `capture/diff.patch`), each a small legible snippet; name the `code-*` block each wants in the frame's `scene`. Frames carry no `asset_candidates` except the `credits` close (1–6 `assets/.png` avatars). Use the exact required fields from the storyboard and script references. -After drafting, present the plan as a proposal per story-spine § 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). In that same message ask the user (a) to approve or request changes, and (b) whether they want a live preview of the storyboard scaffold (`npx hyperframes preview`) — open it only on a yes. Iterate until approved; carry the preview choice to Step 6. This is a **checkpoint gate** (brief contract § 1): in autonomous mode, post the same summary as a heads-up and proceed — the preview question is asked once, at Step 6. +After drafting, present the plan as a proposal per story-spine § 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). In that same message ask the user (a) to approve or request changes, and (b) whether they want a live preview of the storyboard scaffold — open it only on a yes. Start that preview once with `npx hyperframes preview "$PROJECT_DIR" --background`; retain the printed URL for Step 6 instead of starting another server. Iterate until approved; carry the preview choice to Step 6. This is a **checkpoint gate** (brief contract § 1): in autonomous mode, post the same summary as a heads-up and proceed — the preview question is asked once, at Step 6. **Gate:** `STORYBOARD.md` exists, every frame has the required narrative fields, `SCRIPT.md` exists when narration is needed, and the user approved the plan (autonomous: the summary was posted as a heads-up). @@ -242,9 +242,9 @@ If a command fails, surface stderr and stop — don't pile on recovery commands. **Known false-positive — do not chase it.** `inspect` may report a handful of `text_box_overflow` errors of ~1–4px on the **caption** highlight words (selector `#caption-word-*` / `.caption-line`). The caption pill uses a deliberately snug `line-height` (set once in `scripts/captions.mjs`) and has **no `overflow:hidden`**, so a heavy display glyph's ink spills a few px into the pill's own padding — nothing is actually clipped. Treat these as expected and proceed. Do **not** inflate the caption `line-height` (it balloons the pill, which is worse). Only act on a `text_box_overflow` when it names a **frame** element (`#el-NN-*`), not a caption word. -After checks pass, pause for user review. The video is assembled, viewable, and editable in Studio. Manage preview only once across Step 3 and Step 6: open it if the user asked earlier, offer it if they declined earlier, do not ask again if they are already reviewing in Studio. In autonomous mode this is the one question the mode keeps: ask "preview first, or render?" — open the preview on yes, render on no — then deliver the MP4 with the contact sheet and the frame ids so revisions can target a single frame. +After checks pass, pause for user review. The video is assembled, viewable, and editable in Studio. Manage preview only once across Step 3 and Step 6: reuse the retained background-preview URL if the user asked earlier; if they declined earlier, offer it once and start it with the command below only on a yes. Do not ask again if they are already reviewing in Studio. In autonomous mode this is the one question the mode keeps: ask "preview first, or render?" — open the preview on yes, render on no — 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 "$PROJECT_DIR" --background` Render only after user approval (autonomous mode: after the preview-or-render question): @@ -252,6 +252,8 @@ Render only after user approval (autonomous mode: after the preview-or-render qu Do not rerun `lint`, `validate`, `inspect`, or `snapshot` after rendering unless the user asks. +After the user is done reviewing (or after render when no more live edits are expected), stop only this project's background server: `npx hyperframes preview "$PROJECT_DIR" --stop`. Never tear it down while waiting for review. + **Gate:** `lint`, `validate`, and `inspect` passed before render; user approved at the review pause (autonomous: checks passed and the delivery includes the contact sheet); `renders/video.mp4` exists. Final reply states the MP4 path and final duration. ---