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}}
+