From 382c93ee3defe74f2f4dec4a75f800d2de414c3d Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Fri, 10 Jul 2026 23:00:02 +0800 Subject: [PATCH 1/4] feat(studio,cli): per-frame board comments, self-refreshing storyboard, status-aware preview landing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-frame comment boxes on the storyboard board batch into .hyperframes/frame-comments.json (a resubmit wins per frame; unconsumed comments on other frames are kept). Submitted-but-unconsumed comments stay visible — a toolbar banner plus a per-tile echo — until the agent consumes the file; the banner also says what to do next (reply anything in the agent chat). The board keeps itself current: GET /projects/:id/signature exposes the watcher-cached project signature, the storyboard payload carries the signature it was derived from, and the view polls at 2s (hidden tabs skipped, re-checked on visibility), refetching in place with no loading flash. Posters bake the signature into their URL so tiles fill in as sketches land and a poster that failed mid-write retries on the next version; the empty state upgrades itself when STORYBOARD.md appears, and its handoff prompt now points the agent at the review loop and uses the parser's real status vocabulary (outline, not planned). preview lands the browser on the storyboard view while the board is the review surface — any frame built, or pure planning (srcs declared, none on disk yet) — and on the timeline once the video is assembled. --- packages/cli/src/commands/preview.test.ts | 53 +++++++ packages/cli/src/commands/preview.ts | 64 +++++++-- .../src/helpers/projectSignature.ts | 20 +++ packages/studio-server/src/routes/preview.ts | 22 ++- .../studio-server/src/routes/projects.test.ts | 44 ++++++ packages/studio-server/src/routes/projects.ts | 10 ++ .../src/routes/storyboard.test.ts | 13 ++ .../studio-server/src/routes/storyboard.ts | 10 +- .../src/components/storyboard/FramePoster.tsx | 28 +++- .../storyboard/StoryboardFrameFocus.tsx | 4 + .../storyboard/StoryboardFrameTile.tsx | 31 +++- .../components/storyboard/StoryboardGrid.tsx | 24 +++- .../storyboard/StoryboardLoaded.tsx | 60 +++++++- .../components/storyboard/StoryboardView.tsx | 11 +- .../storyboard/frameComments.test.ts | 95 ++++++++++++ .../components/storyboard/frameComments.ts | 115 +++++++++++++++ .../components/storyboard/useFrameComments.ts | 82 +++++++++++ .../hooks/useProjectSignaturePoll.test.tsx | 135 ++++++++++++++++++ .../src/hooks/useProjectSignaturePoll.ts | 68 +++++++++ packages/studio/src/hooks/useStoryboard.ts | 27 +++- 20 files changed, 880 insertions(+), 36 deletions(-) create mode 100644 packages/cli/src/commands/preview.test.ts create mode 100644 packages/studio/src/components/storyboard/frameComments.test.ts create mode 100644 packages/studio/src/components/storyboard/frameComments.ts create mode 100644 packages/studio/src/components/storyboard/useFrameComments.ts create mode 100644 packages/studio/src/hooks/useProjectSignaturePoll.test.tsx create mode 100644 packages/studio/src/hooks/useProjectSignaturePoll.ts diff --git a/packages/cli/src/commands/preview.test.ts b/packages/cli/src/commands/preview.test.ts new file mode 100644 index 0000000000..38b02f58e6 --- /dev/null +++ b/packages/cli/src/commands/preview.test.ts @@ -0,0 +1,53 @@ +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"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function projectWith(storyboard: string | null, frameFiles: string[] = []): string { + const dir = mkdtempSync(join(tmpdir(), "hf-preview-landing-")); + tempDirs.push(dir); + if (storyboard !== null) writeFileSync(join(dir, "STORYBOARD.md"), storyboard); + for (const file of frameFiles) { + mkdirSync(join(dir, file, ".."), { recursive: true }); + writeFileSync(join(dir, file), "
"); + } + return dir; +} + +const FRAME = (n: number, status: string) => + `## Frame ${n} — F${n}\n- status: ${status}\n- src: compositions/frames/0${n}.html\n\nBeat.\n`; + +describe("studioLandingSearch", () => { + it("returns no search without a storyboard", () => { + expect(studioLandingSearch(projectWith(null))).toBe(""); + }); + + it("lands on the board while sketches are under review (any built frame)", () => { + const dir = projectWith(`${FRAME(1, "built")}${FRAME(2, "outline")}`, [ + "compositions/frames/01.html", + ]); + expect(studioLandingSearch(dir)).toBe("?view=storyboard"); + }); + + it("lands on the board during pure planning (srcs declared, none exist)", () => { + const dir = projectWith(`${FRAME(1, "outline")}${FRAME(2, "outline")}`); + expect(studioLandingSearch(dir)).toBe("?view=storyboard"); + }); + + it("lands on the timeline once frames exist without a built status", () => { + const dir = projectWith(`${FRAME(1, "outline")}`, ["compositions/frames/01.html"]); + expect(studioLandingSearch(dir)).toBe(""); + }); + + it("lands on the timeline for fully animated boards", () => { + const dir = projectWith(`${FRAME(1, "animated")}`, ["compositions/frames/01.html"]); + expect(studioLandingSearch(dir)).toBe(""); + }); +}); diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 598d6e92e9..0b11a65e82 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -19,7 +19,16 @@ export const examples: Example[] = [ ["List all active preview servers", "hyperframes preview --list"], ["Kill all active preview servers", "hyperframes preview --kill-all"], ]; -import { existsSync, lstatSync, symlinkSync, unlinkSync, readlinkSync, mkdirSync } from "node:fs"; +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + symlinkSync, + unlinkSync, +} from "node:fs"; +import { parseStoryboard, STORYBOARD_FILENAME } from "@hyperframes/core/storyboard"; import { resolve, dirname, basename, join } from "node:path"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; @@ -635,9 +644,47 @@ function compactSelectionPayload(selection: StudioSelectionSnapshot): CompactSel }; } -function openStudioBrowser(url: string, projectName: string, options?: BrowserLaunchOptions): void { +// Land the browser on the Storyboard view while the project is still planning +// or sketching — the timeline only becomes the right landing once frames are +// animated (or the storyboard never tracked statuses at all, e.g. beat plans). +export function studioLandingSearch(projectDir: string): string { + const storyboardPath = join(projectDir, STORYBOARD_FILENAME); + if (!existsSync(storyboardPath)) return ""; + let frames; + try { + frames = parseStoryboard(readFileSync(storyboardPath, "utf8")).frames; + } catch { + return ""; + } + // Sketch review in progress — the board is the review surface. + if (frames.some((f) => f.status === "built")) return "?view=storyboard"; + // Pure planning stage: frames declare src paths but none are built yet. + const srcs = frames + .map((f) => f.src) + .filter((s): s is string => typeof s === "string" && s.length > 0); + const planning = + frames.length > 0 && + frames.every((f) => f.status === "outline") && + srcs.length > 0 && + !srcs.some((s) => existsSync(join(projectDir, s))); + return planning ? "?view=storyboard" : ""; +} + +// 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}`; +} + +function openStudioBrowser( + url: string, + projectName: string, + projectDir: string, + options?: BrowserLaunchOptions, +): void { if (options?.noOpen) return; - openBrowser(`${url}#project/${projectName}`, { + openBrowser(studioDeepLink(url, projectName, projectDir), { browserPath: options?.browserPath, userDataDir: options?.userDataDir, remoteDebuggingPort: options?.remoteDebuggingPort, @@ -719,6 +766,7 @@ function attachStudioReadyHandler( child: StudioChildProcess, spinner: ReturnType, projectName: string, + projectDir: string, options?: BrowserLaunchOptions, ): void { let detected = false; @@ -730,7 +778,7 @@ function attachStudioReadyHandler( detected = true; spinner.stop(c.success("Studio running")); printStudioSummary(projectName, url, { footer: "Press Ctrl+C to stop" }); - openStudioBrowser(url, projectName, options); + openStudioBrowser(url, projectName, projectDir, options); child.stdout.removeListener("data", handleOutput); child.stderr.removeListener("data", handleOutput); } @@ -768,7 +816,7 @@ async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise { + const project = await adapter.resolveProject(projectId); + if (!project) return null; + return { project, signature: resolveProjectSignature(adapter, project.dir) }; +} + /** * Creates a stable preview cache-busting signature for project source plus Studio manifests. */ diff --git a/packages/studio-server/src/routes/preview.ts b/packages/studio-server/src/routes/preview.ts index ebb522dcd6..f9d399f34b 100644 --- a/packages/studio-server/src/routes/preview.ts +++ b/packages/studio-server/src/routes/preview.ts @@ -7,7 +7,10 @@ import type { StudioApiAdapter } from "../types.js"; import { resolveWithinProject } from "../helpers/safePath.js"; import { getMimeType } from "../helpers/mime.js"; import { buildSubCompositionHtml } from "../helpers/subComposition.js"; -import { createProjectSignature } from "../helpers/projectSignature.js"; +import { + resolveProjectAndSignature, + resolveProjectSignature, +} from "../helpers/projectSignature.js"; import { createStudioMotionRenderBodyScript, STUDIO_MOTION_PATH, @@ -22,10 +25,6 @@ const GSAP_CDN_SCRIPT = ``; const GSAP_MOTION_PATH_CDN_SCRIPT = ``; -function resolveProjectSignature(adapter: StudioApiAdapter, projectDir: string): string { - return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir); -} - function injectProjectSignature(html: string, signature: string): string { const tag = ``; if (html.includes(`name="${PROJECT_SIGNATURE_META}"`)) { @@ -309,15 +308,15 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi // Bundled composition preview // fallow-ignore-next-line complexity api.get("/projects/:id/preview", async (c) => { - const project = await adapter.resolveProject(c.req.param("id")); - if (!project) return c.json({ error: "not found" }, 404); + const resolved = await resolveProjectAndSignature(adapter, c.req.param("id")); + if (!resolved) return c.json({ error: "not found" }, 404); + const { project, signature } = resolved; // fallow-ignore-next-line code-duplication const vars = previewVariablesFromRequest(c.req.query("variables")); if (vars.error !== undefined) return c.json({ error: vars.error }, 400); const previewVariables = vars.values; - const signature = resolveProjectSignature(adapter, project.dir); const etag = `"preview:${signature}${variablesEtagSalt(vars.raw)}"`; const ifNoneMatch = c.req.header("If-None-Match"); if (ifNoneMatch === etag) { @@ -422,15 +421,14 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi // Sub-composition preview // fallow-ignore-next-line complexity api.get("/projects/:id/preview/comp/*", async (c) => { - const project = await adapter.resolveProject(c.req.param("id")); - if (!project) return c.json({ error: "not found" }, 404); + const resolved = await resolveProjectAndSignature(adapter, c.req.param("id")); + if (!resolved) return c.json({ error: "not found" }, 404); + const { project, signature } = resolved; // fallow-ignore-next-line code-duplication const vars = previewVariablesFromRequest(c.req.query("variables")); if (vars.error !== undefined) return c.json({ error: vars.error }, 400); const previewVariables = vars.values; - - const signature = resolveProjectSignature(adapter, project.dir); const compPath = decodeURIComponent( c.req.path.replace(`/projects/${project.id}/preview/comp/`, "").split("?")[0] ?? "", ); diff --git a/packages/studio-server/src/routes/projects.test.ts b/packages/studio-server/src/routes/projects.test.ts index 7414048bdc..e6a6fcfe32 100644 --- a/packages/studio-server/src/routes/projects.test.ts +++ b/packages/studio-server/src/routes/projects.test.ts @@ -58,6 +58,50 @@ function createAdapter(projectDir: string): StudioApiAdapter { }; } +describe("GET /projects/:id/signature", () => { + it("returns the adapter's cached signature when provided", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerProjectRoutes(app, { + ...createAdapter(projectDir), + getProjectSignature: () => "cached-sig", + }); + + const response = await app.request("http://localhost/projects/demo/signature"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ signature: "cached-sig" }); + }); + + it("computes a signature that moves when project files change", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerProjectRoutes(app, createAdapter(projectDir)); + + const first = (await ( + await app.request("http://localhost/projects/demo/signature") + ).json()) as { + signature: string; + }; + expect(first.signature).toMatch(/^[0-9a-f]{24}$/); + + writeFileSync(join(projectDir, "compositions", "scene.html"), "new"); + const second = (await ( + await app.request("http://localhost/projects/demo/signature") + ).json()) as { signature: string }; + expect(second.signature).not.toBe(first.signature); + }); + + it("404s for an unknown project", async () => { + const app = new Hono(); + registerProjectRoutes(app, { + ...createAdapter(createProjectDir()), + resolveProject: async () => null, + }); + const response = await app.request("http://localhost/projects/nope/signature"); + expect(response.status).toBe(404); + }); +}); + describe("registerProjectRoutes — composition discovery (#1384)", () => { it("excludes HTML inside dot-directories from compositions", async () => { const projectDir = createProjectDir(); diff --git a/packages/studio-server/src/routes/projects.ts b/packages/studio-server/src/routes/projects.ts index 76fb0079ec..b0ca6ad2a5 100644 --- a/packages/studio-server/src/routes/projects.ts +++ b/packages/studio-server/src/routes/projects.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import type { Hono } from "hono"; import type { StudioApiAdapter } from "../types.js"; import { isInHiddenOrVendorDir, walkDir } from "../helpers/safePath.js"; +import { resolveProjectSignature } from "../helpers/projectSignature.js"; const COMPOSITION_ID_RE = /data-composition-id\s*=/; @@ -39,6 +40,15 @@ export function registerProjectRoutes(api: Hono, adapter: StudioApiAdapter): voi return c.json(result); }); + // Current content signature for a project — a cheap poll target for clients + // that refresh themselves when files change on disk (the storyboard board + // re-fetches when this differs from the signature its data was loaded with). + api.get("/projects/:id/signature", async (c) => { + const project = await adapter.resolveProject(c.req.param("id")); + if (!project) return c.json({ error: "not found" }, 404); + return c.json({ signature: resolveProjectSignature(adapter, project.dir) }); + }); + // Project file tree api.get("/projects/:id", async (c) => { const project = await adapter.resolveProject(c.req.param("id")); diff --git a/packages/studio-server/src/routes/storyboard.test.ts b/packages/studio-server/src/routes/storyboard.test.ts index 94f040c375..a2fd535235 100644 --- a/packages/studio-server/src/routes/storyboard.test.ts +++ b/packages/studio-server/src/routes/storyboard.test.ts @@ -106,4 +106,17 @@ Not built yet. const { body } = await getStoryboard(dir); expect(body.frames[0].srcExists).toBe(false); }); + + it("carries a project signature in both the absent and present branches", async () => { + const dir = makeProject(); + const absent = await getStoryboard(dir); + expect(absent.body.exists).toBe(false); + expect(absent.body.signature).toMatch(/^[0-9a-f]{24}$/); + + writeFileSync(join(dir, "STORYBOARD.md"), "## Frame 1\n\nHi.\n"); + const present = await getStoryboard(dir); + expect(present.body.exists).toBe(true); + expect(present.body.signature).toMatch(/^[0-9a-f]{24}$/); + expect(present.body.signature).not.toBe(absent.body.signature); + }); }); diff --git a/packages/studio-server/src/routes/storyboard.ts b/packages/studio-server/src/routes/storyboard.ts index 874204208b..519463b295 100644 --- a/packages/studio-server/src/routes/storyboard.ts +++ b/packages/studio-server/src/routes/storyboard.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs"; import type { Hono } from "hono"; import type { StudioApiAdapter } from "../types.js"; import { resolveWithinProject } from "../helpers/safePath.js"; +import { resolveProjectAndSignature } from "../helpers/projectSignature.js"; import { parseStoryboard, SCRIPT_FILENAME, @@ -45,8 +46,11 @@ export function registerStoryboardRoutes(api: Hono, adapter: StudioApiAdapter): // file is absent we return `exists: false` with empty frames rather than 404, // so the Studio can render an opt-in empty state. api.get("/projects/:id/storyboard", async (c) => { - const project = await adapter.resolveProject(c.req.param("id")); - if (!project) return c.json({ error: "not found" }, 404); + // The signature lets the board bust poster caches and lets the client tell + // whether this payload is already current (see /projects/:id/signature). + const resolved = await resolveProjectAndSignature(adapter, c.req.param("id")); + if (!resolved) return c.json({ error: "not found" }, 404); + const { project, signature } = resolved; const abs = resolveWithinProject(project.dir, STORYBOARD_FILENAME); if (!abs || !existsSync(abs)) { @@ -57,6 +61,7 @@ export function registerStoryboardRoutes(api: Hono, adapter: StudioApiAdapter): frames: [], warnings: [], script: readScript(project.dir), + signature, }); } @@ -75,6 +80,7 @@ export function registerStoryboardRoutes(api: Hono, adapter: StudioApiAdapter): frames: resolveFrames(project.dir, manifest.frames), warnings: manifest.warnings, script: readScript(project.dir), + signature, }); }); } diff --git a/packages/studio/src/components/storyboard/FramePoster.tsx b/packages/studio/src/components/storyboard/FramePoster.tsx index 722f3dbd65..60d41e044b 100644 --- a/packages/studio/src/components/storyboard/FramePoster.tsx +++ b/packages/studio/src/components/storyboard/FramePoster.tsx @@ -10,6 +10,13 @@ export interface FramePosterProps { title: string; /** `cover` fills+crops (contact-sheet tile); `contain` letterboxes (focus hero). */ fit?: "cover" | "contain"; + /** + * Project content signature to key the poster URL on. The thumbnail route + * regenerates when the frame's source changes, but the browser only refetches + * when the URL does — so bake the signature in to pick up fresh posters after + * a background board refresh. + */ + posterVersion?: string; } /** @@ -18,11 +25,19 @@ export interface FramePosterProps { * no postMessage seek, and no client-side fps assumption. Shared by the * contact-sheet tile and the frame-focus view. */ -export function FramePoster({ projectId, src, seconds, title, fit = "cover" }: FramePosterProps) { +export function FramePoster({ + projectId, + src, + seconds, + title, + fit = "cover", + posterVersion, +}: FramePosterProps) { const [failed, setFailed] = useState(false); // The is reused (no key) when a tile/hero swaps to a different frame, so a - // prior load error would stick. Reset when the poster target changes. - useEffect(() => setFailed(false), [src, seconds]); + // prior load error would stick. Reset when the poster target changes — including + // a new posterVersion, so a frame that failed mid-write retries once it settles. + useEffect(() => setFailed(false), [src, seconds, posterVersion]); if (failed) { return (
@@ -30,12 +45,17 @@ export function FramePoster({ projectId, src, seconds, title, fit = "cover" }: F
); } - const url = buildCompositionThumbnailUrl({ + let url = buildCompositionThumbnailUrl({ previewUrl: `/api/projects/${projectId}/preview/comp/${src}`, seekTime: seconds, duration: 0, origin: window.location.origin, }); + if (posterVersion) { + const withVersion = new URL(url, window.location.origin); + withVersion.searchParams.set("sig", posterVersion); + url = withVersion.toString(); + } return ( void; /** Select a composition in the timeline (sets active comp + editing file + sidebar highlight). */ onSelectComposition: (path: string) => void; + /** Project signature the board was loaded with (busts the poster cache). */ + posterVersion?: string; } /** @@ -39,6 +41,7 @@ export function StoryboardFrameFocus({ onNavigate, onSaved, onSelectComposition, + posterVersion, }: StoryboardFrameFocusProps) { const { readProjectFile, writeProjectFile } = useFileManagerContext(); const { setViewMode } = useViewMode(); @@ -151,6 +154,7 @@ export function StoryboardFrameFocus({ seconds={posterTime(frame)} title={title} fit="contain" + posterVersion={posterVersion} /> ) : (
diff --git a/packages/studio/src/components/storyboard/StoryboardFrameTile.tsx b/packages/studio/src/components/storyboard/StoryboardFrameTile.tsx index 62b0173bfb..a128a6fbe8 100644 --- a/packages/studio/src/components/storyboard/StoryboardFrameTile.tsx +++ b/packages/studio/src/components/storyboard/StoryboardFrameTile.tsx @@ -7,6 +7,13 @@ export interface StoryboardFrameTileProps { frame: StoryboardFrameView; /** Open this frame in the full-area focus view. */ onOpen: (index: number) => void; + /** This frame's pending comment draft ("" when none). */ + commentDraft: string; + onCommentDraftChange: (index: number, text: string) => void; + /** A submitted comment the agent has not consumed yet (null when none). */ + pendingComment: string | null; + /** Project signature the board was loaded with (busts the poster cache). */ + posterVersion?: string; } function firstLine(text: string): string { @@ -26,7 +33,15 @@ function placeholderMessage(frame: StoryboardFrameView): string { /** A single contact-sheet tile: poster preview + its metadata. Click to focus. */ // fallow-ignore-next-line complexity -export function StoryboardFrameTile({ projectId, frame, onOpen }: StoryboardFrameTileProps) { +export function StoryboardFrameTile({ + projectId, + frame, + onOpen, + commentDraft, + onCommentDraftChange, + pendingComment, + posterVersion, +}: StoryboardFrameTileProps) { const meta = FRAME_STATUS_META[frame.status]; const renderable = frame.srcExists && frame.status !== "outline"; const title = frame.title ?? `Frame ${frame.index}`; @@ -48,6 +63,7 @@ export function StoryboardFrameTile({ projectId, frame, onOpen }: StoryboardFram src={frame.src} seconds={posterTime(frame)} title={title} + posterVersion={posterVersion} /> ) : ( @@ -74,6 +90,19 @@ export function StoryboardFrameTile({ projectId, frame, onOpen }: StoryboardFram {frame.duration && {frame.duration}} {frame.transitionIn && ↘ {frame.transitionIn}}
+