Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
382c93e
feat(studio,cli): per-frame board comments, self-refreshing storyboar…
WaterrrForever Jul 10, 2026
cc9c76a
feat(skills): the review loop — plan, sketch, build as one shared pro…
WaterrrForever Jul 10, 2026
18c345e
feat(media-use): user memory — remembered preferences and frozen recipes
WaterrrForever Jul 10, 2026
6c051df
feat(skills): wire user memory into the brief and the review loop
WaterrrForever Jul 10, 2026
90c6ae3
Merge remote-tracking branch 'origin/main' into feat/skills-review-loop
WaterrrForever Jul 13, 2026
565695a
docs(skills): the sketch pass names check, not the deprecated validate
WaterrrForever Jul 13, 2026
abd9679
feat(skills): intent-layer references — process, route briefs, capabi…
WaterrrForever Jul 13, 2026
61c309b
feat(media-use): brief skeleton as the recipe's fourth artifact; flow…
WaterrrForever Jul 13, 2026
cc81027
feat(skills): the intent layer conducts every brief — workflows execu…
WaterrrForever Jul 13, 2026
e0e897b
feat(skills): retire the mode preference key; sync catalog surfaces f…
WaterrrForever Jul 13, 2026
7abb9d8
refactor(skills): dedupe router vs intent-layer guidance — one owner …
WaterrrForever Jul 13, 2026
5d7c1e3
feat(skills): the design ask — own spec, pick by eye from showcases, …
WaterrrForever Jul 13, 2026
5bb0a5b
docs(skills): the design ask says the honest line on capture routes
WaterrrForever Jul 13, 2026
a6d3c1d
feat(skills): product-launch-video absorbs website-to-video as the to…
WaterrrForever Jul 13, 2026
81fad42
refactor(skills): keep product-launch-video pristine — a tour is brie…
WaterrrForever Jul 13, 2026
ee554df
Merge origin/main into feat/skills-review-loop
WaterrrForever Jul 14, 2026
662334e
feat(skills): production loop + genre lenses; general-video goes free…
WaterrrForever Jul 14, 2026
3518abe
refactor(skills): /hyperframes is the front door - route tables and s…
WaterrrForever Jul 14, 2026
01020d1
docs(skills): review-loop pass across skill catalog
WaterrrForever Jul 14, 2026
823dc4e
Merge remote-tracking branch 'origin/main' into feat/skills-review-loop
WaterrrForever Jul 15, 2026
a074e8c
fix(cli): pass project dir to openStudioBrowser in background-server …
WaterrrForever Jul 15, 2026
d2123d1
feat(skills): add pitch-round reference - verbalized sampling concept…
WaterrrForever Jul 15, 2026
1972198
feat(skills): wire pitch round into intent layer - completeness triag…
WaterrrForever Jul 15, 2026
649031b
feat(skills): editorial capability recommendations, handoff disciplin…
WaterrrForever Jul 15, 2026
bcb080e
feat(skills): pitches carry their machinery; source-only-formed reque…
WaterrrForever Jul 15, 2026
6a13832
feat(skills): companion goes director - ceiling treatment plus bluepr…
WaterrrForever Jul 15, 2026
33d0e91
fix(scripts): sandbox npx-leak guard - private npm global prefix keep…
WaterrrForever Jul 15, 2026
286ac44
chore(skills): resync manifest hash after formatter pass reflowed gen…
WaterrrForever Jul 15, 2026
b831ed8
fix(skills): recipe freeze reads workflow from BRIEF.md; style_preset…
WaterrrForever Jul 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions packages/cli/src/commands/preview.test.ts
Original file line number Diff line number Diff line change
@@ -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), "<div></div>");
}
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("");
});
});
64 changes: 56 additions & 8 deletions packages/cli/src/commands/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -719,6 +766,7 @@ function attachStudioReadyHandler(
child: StudioChildProcess,
spinner: ReturnType<typeof clack.spinner>,
projectName: string,
projectDir: string,
options?: BrowserLaunchOptions,
): void {
let detected = false;
Expand All @@ -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);
}
Expand Down Expand Up @@ -768,7 +816,7 @@ async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise<v
stdio: ["ignore", "pipe", "pipe"],
});

attachStudioReadyHandler(child, s, pName, options);
attachStudioReadyHandler(child, s, pName, dir, options);
removeSymlinkOnExit(createdSymlink, symlinkPath);

// Kill the child's entire process tree on SIGTERM/SIGINT. Ctrl+C sends
Expand Down Expand Up @@ -817,7 +865,7 @@ async function runLocalStudioMode(dir: string, options?: StudioLaunchOptions): P
stdio: ["ignore", "pipe", "pipe"],
});

attachStudioReadyHandler(child, s, pName, options);
attachStudioReadyHandler(child, s, pName, dir, options);
removeSymlinkOnExit(createdSymlink, symlinkPath);

// Same tree-kill handler as dev mode. No-op on Windows (see comment above).
Expand Down Expand Up @@ -888,7 +936,7 @@ async function runEmbeddedMode(
printStudioSummary(pName, url, {
details: ["Reusing existing server. Use --force-new to start a fresh instance."],
});
openStudioBrowser(url, pName, options);
openStudioBrowser(url, pName, dir, options);
return;
}

Expand All @@ -906,7 +954,7 @@ async function runEmbeddedMode(
],
footer: "Press Ctrl+C to stop",
});
openStudioBrowser(url, pName, options);
openStudioBrowser(url, pName, dir, options);

// Block until Ctrl+C. Node would normally exit on SIGINT, but the listening
// HTTP server keeps handles open, so the event loop stays alive after the
Expand Down
20 changes: 20 additions & 0 deletions packages/studio-server/src/helpers/projectSignature.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import { lstatSync, readFileSync, readdirSync } from "node:fs";
import { extname, isAbsolute, relative, resolve } from "node:path";
import type { ResolvedProject, StudioApiAdapter } from "../types.js";

const SIGNATURE_TEXT_EXTENSIONS = new Set([
".cjs",
Expand Down Expand Up @@ -137,6 +138,25 @@ function createProjectFingerprint(projectDir: string, files: ProjectSignatureFil
return hash.digest("hex").slice(0, 24);
}

/**
* Resolve the project signature through the adapter's cached path when the host
* provides one (the CLI invalidates its cache from the file watcher), falling
* back to a direct computation.
*/
export function resolveProjectSignature(adapter: StudioApiAdapter, projectDir: string): string {
return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir);
}

/** The shared route opening: resolve the project (null → caller 404s) with its signature. */
export async function resolveProjectAndSignature(
adapter: StudioApiAdapter,
projectId: string,
): Promise<{ project: ResolvedProject; signature: string } | null> {
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.
*/
Expand Down
22 changes: 10 additions & 12 deletions packages/studio-server/src/routes/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,10 +25,6 @@ const GSAP_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_C
const GSAP_CUSTOM_EASE_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/CustomEase.min.js"></script>`;
const GSAP_MOTION_PATH_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/MotionPathPlugin.min.js"></script>`;

function resolveProjectSignature(adapter: StudioApiAdapter, projectDir: string): string {
return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir);
}

function injectProjectSignature(html: string, signature: string): string {
const tag = `<meta name="${PROJECT_SIGNATURE_META}" content="${signature}">`;
if (html.includes(`name="${PROJECT_SIGNATURE_META}"`)) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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] ?? "",
);
Expand Down
44 changes: 44 additions & 0 deletions packages/studio-server/src/routes/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"), "<html><body>new</body></html>");
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();
Expand Down
10 changes: 10 additions & 0 deletions packages/studio-server/src/routes/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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*=/;

Expand Down Expand Up @@ -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"));
Expand Down
13 changes: 13 additions & 0 deletions packages/studio-server/src/routes/storyboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading
Loading