mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
feat(skills,studio,media-use): the intent layer, review loop, and user memory — BRIEF.md, companion mode, recipes; /website-to-video folds into /product-launch-video (#2133)
* feat(studio,cli): per-frame board comments, self-refreshing storyboard, status-aware preview landing 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. * feat(skills): the review loop — plan, sketch, build as one shared process hyperframes-core/references/review-loop.md is the single source for the three-pass collaborative review: the plan proposed on a live board (§ 1), wireframe sketches marked built with one layout question (§ 2 — real words on plain blocks, run no CLI; a confirmed board is itself a valid deliverable when the user asked for a storyboard, not a video), the build dressing confirmed layouts (§ 3, worker or inline), and the final look (§ 4). Autonomous runs skip every gate and keep one question before render. The three narrative workflows' Steps 3/4/6 collapse to references plus their sketch stand-ins (captured-asset blocks for product-launch-video, plain code panels for pr-to-video); the confirmed-sketch handoff stays in each frame-worker prompt. general-video plans on a board for multi-scene narrative pieces in collaborative mode — its sketch pass is layout-before-animation with the user watching. The router treats "I want a storyboard" as a process request rather than a route, and closes exploratory intake by recommending a route plus how the run will review. The supporting contracts land next door: the comments channel (silent submit, one reply picks it up, check the file before the words) in brief-contract § 1; the sidecar schema and the built status rung in storyboard-format; the mode question asked first and alone in the three workflows' Step 0. * feat(media-use): user memory — remembered preferences and frozen recipes Two tiers of memory on media-use's existing two-tier storage split. Preferences (lightweight): confirmed brief answers — destination, aspect, language, mode, voice, style preset — recorded to the project's .media/preferences.json (committed, the team inherits it) and promoted to the personal ~/.media/preferences.json once the same value is confirmed in two different projects (a sightings ledger accumulates the cross-project evidence user-side, since project files can't see each other). prefs.mjs get/record; merge reads project-over-user; a changed value restarts its provenance. Recipes (heavyweight): one approved run frozen as a named, versioned bundle — frame.md, the storyboard skeleton (structure kept: durations, transitions, srcs, Video direction; statuses reset to outline; content blanked to per-frame fill-ins naming the beat's role), and the confirmed brief values. Named folders, not content hashes: re-freezing bumps version and archives <name>@v<N>; a freeze is already confirmed, so it promotes to the user tier immediately. recipe.mjs freeze/list/use, plus resolve --type recipe --entity <name> delegating like grade/lut. 16 new node --test cases; the media-use lib suite is 168/168. * feat(skills): wire user memory into the brief and the review loop brief-contract § 2 gains Remembered defaults: read the merged preferences before Round 2 and let a remembered value become the recommended option with a receipt naming its source project. Memory changes the default, never the question — every ask-marked field still gets asked, and what the request says this time beats what was picked last time. Record only what the user actually confirmed (a defaulted voice nobody chose is not an answer; a "go" that accepts the recommended defaults is). The first record announces itself once; after that the receipts carry the reminder. In autonomous mode a remembered value becomes the decided value, receipt included. The three narrative workflows read the remembered defaults before Round 2, record the confirmed answers at the Step 0 gate, record the chosen preset at the Step 2 gate (pr-to-video excepted — its preset is fixed), and fall back to the remembered voice when the request names none. general-video's discovery reads the same defaults. Recipes wire in at both ends: Step 0 checks for a matching recipe before the mode question — one question, plural-aware, and adopting one fills the brief, skips the design step, and drafts the storyboard from the frozen skeleton while every review gate still runs. The review loop's final look (§ 4) offers the freeze once after approval, and the confirmation teaches the recall phrase — the name is something the system reminds the user of, never something they must remember. The router recognizes a named recipe or "like last time" as a route. * docs(skills): the sketch pass names check, not the deprecated validate * feat(skills): intent-layer references — process, route briefs, capability menu, BRIEF.md format * feat(media-use): brief skeleton as the recipe's fourth artifact; flow/storyboard preference keys * feat(skills): the intent layer conducts every brief — workflows execute BRIEF.md * feat(skills): retire the mode preference key; sync catalog surfaces for intent layer * refactor(skills): dedupe router vs intent-layer guidance — one owner per rule * feat(skills): the design ask — own spec, pick by eye from showcases, or defer * docs(skills): the design ask says the honest line on capture routes * feat(skills): product-launch-video absorbs website-to-video as the tour angle * refactor(skills): keep product-launch-video pristine — a tour is brief intent, not a pipeline branch * feat(skills): production loop + genre lenses; general-video goes freeform (route yours, laws hold) * refactor(skills): /hyperframes is the front door - route tables and scope lists leave the workflows * docs(skills): review-loop pass across skill catalog * fix(cli): pass project dir to openStudioBrowser in background-server path * feat(skills): add pitch-round reference - verbalized sampling concept gate * feat(skills): wire pitch round into intent layer - completeness triage + route eligibility * feat(skills): editorial capability recommendations, handoff disciplines, menu-probe split * feat(skills): pitches carry their machinery; source-only-formed requests pitch the telling * feat(skills): companion goes director - ceiling treatment plus blueprint/rule citation discipline * fix(scripts): sandbox npx-leak guard - private npm global prefix keeps npx on the branch CLI * chore(skills): resync manifest hash after formatter pass reflowed general-video tables * fix(skills): recipe freeze reads workflow from BRIEF.md; style_preset records require workflow scope Two holes found by a live companion-run freeze: the agent-supplied --workflow contradicted the run's actual workflow (recipe.json said faceless-explainer, brief-skeleton said general-video), and the style_preset lookup missed because the preference had been recorded under the bare key. - freezeRecipe resolves the workflow from BRIEF.md frontmatter; the flag is a fallback for briefless projects and a contradicting flag is ignored (noted). - recordPreference refuses a bare style_preset — the scoped key is the only writable shape; freeze tolerates legacy bare records via read fallback. - review-loop § 4 / media-use SKILL / brief-format wording follow the machinery.
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
*
|
||||
* This file generates a DATA INVENTORY that tells the AI agent what files
|
||||
* exist and what they contain. The actual workflow lives in the
|
||||
* website-to-video skill — this file points agents there.
|
||||
* product-launch-video skill — this file points agents there.
|
||||
*/
|
||||
|
||||
import { writeFileSync, readdirSync, existsSync } from "node:fs";
|
||||
@@ -198,7 +198,7 @@ function buildPrompt(
|
||||
|
||||
Source: ${url}
|
||||
|
||||
To create a video from this capture, use the \`website-to-video\` skill.
|
||||
To create a video from this capture, use the \`product-launch-video\` skill.
|
||||
|
||||
## What's in This Capture
|
||||
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,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";
|
||||
@@ -368,7 +377,7 @@ export default defineCommand({
|
||||
],
|
||||
footer: `Stop with: hyperframes preview ${JSON.stringify(dir)} --stop`,
|
||||
});
|
||||
openStudioBrowser(url, projectName, {
|
||||
openStudioBrowser(url, projectName, dir, {
|
||||
noOpen,
|
||||
browserPath,
|
||||
userDataDir,
|
||||
@@ -742,9 +751,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,
|
||||
@@ -829,6 +876,7 @@ function attachStudioReadyHandler(
|
||||
child: StudioChildProcess,
|
||||
spinner: ReturnType<typeof clack.spinner>,
|
||||
projectName: string,
|
||||
projectDir: string,
|
||||
options?: BrowserLaunchOptions,
|
||||
): void {
|
||||
let detected = false;
|
||||
@@ -840,7 +888,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);
|
||||
}
|
||||
@@ -878,7 +926,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
|
||||
@@ -927,7 +975,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).
|
||||
@@ -998,7 +1046,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;
|
||||
}
|
||||
|
||||
@@ -1016,7 +1064,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
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
|
||||
**Always invoke the relevant skill before writing or modifying compositions.** Skills encode framework-specific patterns (e.g., `window.__timelines` registration, `data-*` attribute semantics, shader-compatible CSS rules) that are NOT in generic web docs. Skipping them produces broken compositions.
|
||||
|
||||
**Doing anything with HyperFrames?** Start at `/hyperframes` — it tells you what HyperFrames can do and which skill or workflow handles your intent (make a video, TTS / BGM, prep footage, author / animate, render, install blocks), and routes every "make me a…" request (a video, a deck, a composition port) to the right workflow. Read it first, especially when there's no project context to orient you. The workflows it routes to:
|
||||
**Doing anything with HyperFrames?** Start at `/hyperframes` — it tells you what HyperFrames can do and which skill or workflow handles your intent (make a video, TTS / BGM, prep footage, author / animate, render, install blocks), confirms your brief up front (the intent layer), and routes every "make me a…" request (a video, a deck, a composition port) to the right workflow. Read it first, especially when there's no project context to orient you. The workflows it routes to:
|
||||
|
||||
- `/product-launch-video` — a **product** URL or brief / script → 60-90s product launch / SaaS / promo video.
|
||||
- `/website-to-video` — a **general** website / URL → a video _of_ the site (tour / showcase / social clip from captured visuals); a product **launch / promo** is `/product-launch-video`.
|
||||
- `/product-launch-video` — any **website** URL or brief / script → a product launch / SaaS / promo video, or a site tour / showcase featuring the site's own captured visuals.
|
||||
- `/faceless-explainer` — arbitrary text (topic / article / notes), **no URL, no website capture** → 60-90s faceless explainer.
|
||||
- `/embedded-captions` — an existing talking-head video (MP4) → the same footage with captions / subtitles added (rail + embed, or pure-cinematic embed); the footage itself is untouched.
|
||||
- `/talking-head-recut` — an existing talking-head / interview / podcast video (MP4) → the same footage **packaged with designed graphic overlays** (kinetic titles, lower-thirds, data callouts, pull-quotes, side panels, pip) synced to the transcript; the clip plays unchanged underneath. (Plain captions/subtitles → `/embedded-captions`.)
|
||||
@@ -15,7 +14,7 @@
|
||||
- `/motion-graphics` — a short (typically under 10s) design-led **motion graphic**, motion-is-the-message, no narration: kinetic type, a stat / number count-up, a chart, a logo sting, a lower-third / overlay, or an animated tweet / headline / captured-page highlight; rendered to MP4 or a transparent overlay. Longer / narrated / custom → `/general-video`.
|
||||
- `/music-to-video` — a **music track** (audio file, or video to pull audio from) → beat-synced video (lyric / slideshow / kinetic promo). Music drives pacing; user-supplied images / videos are cut onto the same beat grid.
|
||||
- `/slideshow` — a **presentation / pitch deck / interactive deck** — discrete slides, fragment reveals, branching, hotspot navigation, presenter mode. Output is a navigable deck, not a rendered video.
|
||||
- `/general-video` — fallback for any other video (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition); the original hyperframes authoring flow, any length.
|
||||
- `/general-video` — fallback for any other video (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition) and the home of **companion mode** — co-create with the full HyperFrames toolbox; the original hyperframes authoring flow, any length.
|
||||
|
||||
**Porting an existing composition?** `/remotion-to-hyperframes` translates a Remotion (React) composition into HyperFrames HTML — a source migration, separate from the creation workflows above.
|
||||
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
|
||||
**Always invoke the relevant skill before writing or modifying compositions.** Skills encode framework-specific patterns (e.g., `window.__timelines` registration, `data-*` attribute semantics, shader-compatible CSS rules) that are NOT in generic web docs. Skipping them produces broken compositions.
|
||||
|
||||
**Doing anything with HyperFrames?** Start at `/hyperframes` — it tells you what HyperFrames can do and which skill or workflow handles your intent (make a video, TTS / BGM, prep footage, author / animate, render, install blocks), and routes every "make me a…" request (a video, a deck, a composition port) to the right workflow. Read it first, especially when there's no project context to orient you. The workflows it routes to:
|
||||
**Doing anything with HyperFrames?** Start at `/hyperframes` — it tells you what HyperFrames can do and which skill or workflow handles your intent (make a video, TTS / BGM, prep footage, author / animate, render, install blocks), confirms your brief up front (the intent layer), and routes every "make me a…" request (a video, a deck, a composition port) to the right workflow. Read it first, especially when there's no project context to orient you. The workflows it routes to:
|
||||
|
||||
- `/product-launch-video` — a **product** URL or brief / script → 60-90s product launch / SaaS / promo video.
|
||||
- `/website-to-video` — a **general** website / URL → a video _of_ the site (tour / showcase / social clip from captured visuals); a product **launch / promo** is `/product-launch-video`.
|
||||
- `/product-launch-video` — any **website** URL or brief / script → a product launch / SaaS / promo video, or a site tour / showcase featuring the site's own captured visuals.
|
||||
- `/faceless-explainer` — arbitrary text (topic / article / notes), **no URL, no website capture** → 60-90s faceless explainer.
|
||||
- `/embedded-captions` — an existing talking-head video (MP4) → the same footage with captions / subtitles added (rail + embed, or pure-cinematic embed); the footage itself is untouched.
|
||||
- `/talking-head-recut` — an existing talking-head / interview / podcast video (MP4) → the same footage **packaged with designed graphic overlays** (kinetic titles, lower-thirds, data callouts, pull-quotes, side panels, pip) synced to the transcript; the clip plays unchanged underneath. (Plain captions/subtitles → `/embedded-captions`.)
|
||||
@@ -15,7 +14,7 @@
|
||||
- `/motion-graphics` — a short (typically under 10s) design-led **motion graphic**, motion-is-the-message, no narration: kinetic type, a stat / number count-up, a chart, a logo sting, a lower-third / overlay, or an animated tweet / headline / captured-page highlight; rendered to MP4 or a transparent overlay. Longer / narrated / custom → `/general-video`.
|
||||
- `/music-to-video` — a **music track** (audio file, or video to pull audio from) → beat-synced video (lyric / slideshow / kinetic promo). Music drives pacing; user-supplied images / videos are cut onto the same beat grid.
|
||||
- `/slideshow` — a **presentation / pitch deck / interactive deck** — discrete slides, fragment reveals, branching, hotspot navigation, presenter mode. Output is a navigable deck, not a rendered video.
|
||||
- `/general-video` — fallback for any other video (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition); the original hyperframes authoring flow, any length.
|
||||
- `/general-video` — fallback for any other video (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition) and the home of **companion mode** — co-create with the full HyperFrames toolbox; the original hyperframes authoring flow, any length.
|
||||
|
||||
**Porting an existing composition?** `/remotion-to-hyperframes` translates a Remotion (React) composition into HyperFrames HTML — a source migration, separate from the creation workflows above.
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 = `<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}"`)) {
|
||||
@@ -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] ?? "",
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 <img> 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 (
|
||||
<div className="flex h-full w-full items-center justify-center text-[11px] text-neutral-600">
|
||||
@@ -30,12 +45,17 @@ export function FramePoster({ projectId, src, seconds, title, fit = "cover" }: F
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<img
|
||||
src={url}
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface StoryboardFrameFocusProps {
|
||||
onSaved: () => 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}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-neutral-600">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
<FrameTilePlaceholder frame={frame} />
|
||||
@@ -74,6 +90,19 @@ export function StoryboardFrameTile({ projectId, frame, onOpen }: StoryboardFram
|
||||
{frame.duration && <span>{frame.duration}</span>}
|
||||
{frame.transitionIn && <span>↘ {frame.transitionIn}</span>}
|
||||
</div>
|
||||
<textarea
|
||||
value={commentDraft}
|
||||
onChange={(e) => onCommentDraftChange(frame.index, e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Comment on this frame…"
|
||||
aria-label={`Comment on ${title}`}
|
||||
className="mt-2 w-full resize-none rounded-md border border-neutral-800 bg-neutral-900/60 px-2 py-1.5 text-xs text-neutral-200 placeholder:text-neutral-600 focus:border-sky-700 focus:outline-none"
|
||||
/>
|
||||
{pendingComment && (
|
||||
<p className="mt-1 text-[11px] text-sky-400/90">
|
||||
<span className="font-medium">Pending:</span> “{pendingComment}”
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { StoryboardFrameView } from "../../hooks/useStoryboard";
|
||||
import type { FrameCommentEntry } from "./frameComments";
|
||||
import { StoryboardFrameTile } from "./StoryboardFrameTile";
|
||||
|
||||
export interface StoryboardGridProps {
|
||||
@@ -6,10 +7,25 @@ export interface StoryboardGridProps {
|
||||
frames: StoryboardFrameView[];
|
||||
/** Open a frame in the full-area focus view. */
|
||||
onOpenFrame: (index: number) => void;
|
||||
/** Per-frame comment drafts, keyed by frame index. */
|
||||
commentDrafts: Record<number, string>;
|
||||
onCommentDraftChange: (index: number, text: string) => void;
|
||||
/** Submitted comments the agent has not consumed yet. */
|
||||
pendingComments: FrameCommentEntry[] | null;
|
||||
/** Project signature the board was loaded with (busts poster caches). */
|
||||
posterVersion?: string;
|
||||
}
|
||||
|
||||
/** The contact sheet: ordered frame tiles in a responsive grid. */
|
||||
export function StoryboardGrid({ projectId, frames, onOpenFrame }: StoryboardGridProps) {
|
||||
export function StoryboardGrid({
|
||||
projectId,
|
||||
frames,
|
||||
onOpenFrame,
|
||||
commentDrafts,
|
||||
onCommentDraftChange,
|
||||
pendingComments,
|
||||
posterVersion,
|
||||
}: StoryboardGridProps) {
|
||||
if (frames.length === 0) {
|
||||
return (
|
||||
<div className="mt-8 rounded-lg border border-dashed border-neutral-800 px-6 py-12 text-center text-sm text-neutral-500">
|
||||
@@ -26,6 +42,12 @@ export function StoryboardGrid({ projectId, frames, onOpenFrame }: StoryboardGri
|
||||
projectId={projectId}
|
||||
frame={frame}
|
||||
onOpen={onOpenFrame}
|
||||
commentDraft={commentDrafts[frame.index] ?? ""}
|
||||
onCommentDraftChange={onCommentDraftChange}
|
||||
pendingComment={
|
||||
pendingComments?.find((entry) => entry.frame === frame.index)?.text ?? null
|
||||
}
|
||||
posterVersion={posterVersion}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { StoryboardResponse } from "../../hooks/useStoryboard";
|
||||
import { Button } from "../ui/Button";
|
||||
import { StoryboardDirection } from "./StoryboardDirection";
|
||||
import { StoryboardGrid } from "./StoryboardGrid";
|
||||
import { StoryboardStatusLegend } from "./StoryboardStatusLegend";
|
||||
import { StoryboardScriptPanel } from "./StoryboardScriptPanel";
|
||||
import { StoryboardSourceEditor, type SourceFile } from "./StoryboardSourceEditor";
|
||||
import { StoryboardFrameFocus } from "./StoryboardFrameFocus";
|
||||
import { useFrameComments, type CommentsSubmitState } from "./useFrameComments";
|
||||
|
||||
type SubView = "board" | "source";
|
||||
|
||||
@@ -33,6 +35,14 @@ export function StoryboardLoaded({
|
||||
const [subView, setSubView] = useState<SubView>("board");
|
||||
const [sourceDirty, setSourceDirty] = useState(false);
|
||||
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
||||
const comments = useFrameComments(data.frames);
|
||||
// When the board refreshes off a project change (agent revised frames), the
|
||||
// agent has likely consumed the comments file too — re-check so the pending
|
||||
// banner clears the moment revisions land, not on the next window focus.
|
||||
const { refreshPending } = comments;
|
||||
useEffect(() => {
|
||||
void refreshPending();
|
||||
}, [data.signature, refreshPending]);
|
||||
const sourceFiles = useMemo<SourceFile[]>(() => {
|
||||
const files: SourceFile[] = [{ path: data.path, label: data.path }];
|
||||
if (data.script?.exists) files.push({ path: data.script.path, label: data.script.path });
|
||||
@@ -72,14 +82,23 @@ export function StoryboardLoaded({
|
||||
}
|
||||
onSaved={reload}
|
||||
onSelectComposition={onSelectComposition}
|
||||
posterVersion={data.signature}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col bg-neutral-950 text-neutral-200">
|
||||
<div className="flex items-center border-b border-neutral-800 px-4 py-2">
|
||||
<div className="flex items-center gap-3 border-b border-neutral-800 px-4 py-2">
|
||||
<SubViewToggle value={subView} onChange={changeSubView} />
|
||||
{subView === "board" && (
|
||||
<CommentsSubmitBar
|
||||
draftCount={comments.draftCount}
|
||||
pendingCount={comments.pending?.length ?? 0}
|
||||
submitState={comments.submitState}
|
||||
onSubmit={() => void comments.submit()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{subView === "board" ? (
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
@@ -92,6 +111,10 @@ export function StoryboardLoaded({
|
||||
projectId={projectId}
|
||||
frames={data.frames}
|
||||
onOpenFrame={setFocusedIndex}
|
||||
commentDrafts={comments.drafts}
|
||||
onCommentDraftChange={comments.setDraft}
|
||||
pendingComments={comments.pending}
|
||||
posterVersion={data.signature}
|
||||
/>
|
||||
{data.script && <StoryboardScriptPanel script={data.script} />}
|
||||
</div>
|
||||
@@ -107,6 +130,39 @@ export function StoryboardLoaded({
|
||||
);
|
||||
}
|
||||
|
||||
/** Batch-submit the per-frame comment drafts to `.hyperframes/frame-comments.json`. */
|
||||
function CommentsSubmitBar({
|
||||
draftCount,
|
||||
pendingCount,
|
||||
submitState,
|
||||
onSubmit,
|
||||
}: {
|
||||
draftCount: number;
|
||||
pendingCount: number;
|
||||
submitState: CommentsSubmitState;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{pendingCount > 0 && (
|
||||
<span className="text-xs text-sky-300">
|
||||
{pendingCount} comment{pendingCount > 1 ? "s" : ""} pending — reply anything in your agent
|
||||
chat and it will apply them.
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={submitState === "saving"}
|
||||
disabled={draftCount === 0 || submitState === "saving"}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{draftCount > 0 ? `Submit comments (${draftCount})` : "Submit comments"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SUB_VIEWS: Array<{ value: SubView; label: string }> = [
|
||||
{ value: "board", label: "Board" },
|
||||
{ value: "source", label: "Source" },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Copy, Check } from "@phosphor-icons/react";
|
||||
import { useStoryboard } from "../../hooks/useStoryboard";
|
||||
import { useProjectSignaturePoll } from "../../hooks/useProjectSignaturePoll";
|
||||
import { copyTextToClipboard } from "../../utils/clipboard";
|
||||
import { Button } from "../ui/Button";
|
||||
import { StoryboardLoaded } from "./StoryboardLoaded";
|
||||
@@ -19,6 +20,10 @@ export interface StoryboardViewProps {
|
||||
// fallow-ignore-next-line complexity
|
||||
export function StoryboardView({ projectId, onSelectComposition }: StoryboardViewProps) {
|
||||
const { data, loading, error, reload } = useStoryboard(projectId);
|
||||
// Keep the board current while an agent writes to the project: when the
|
||||
// project signature moves past the one `data` was loaded with, refetch. Also
|
||||
// upgrades the empty state the moment STORYBOARD.md lands on disk.
|
||||
useProjectSignaturePoll(projectId, data?.signature, reload);
|
||||
|
||||
if (loading) return <StoryboardFrame>{<Message>Loading storyboard…</Message>}</StoryboardFrame>;
|
||||
if (error) {
|
||||
@@ -87,12 +92,14 @@ audience: <who it's for>
|
||||
## Frame 1 — <title>
|
||||
- duration: 5s
|
||||
- transition_in: crossfade
|
||||
- status: planned
|
||||
- status: outline
|
||||
- src: compositions/frames/01-<slug>.html
|
||||
|
||||
<A sentence or two: what's on screen and what the narration says.>
|
||||
|
||||
Add one \`## Frame N\` section per beat. Keep the arc tight.`;
|
||||
Add one \`## Frame N\` section per beat. Keep the arc tight.
|
||||
|
||||
Then run the review loop from the hyperframes-core skill (references/review-loop.md): present the plan as a proposal, offer wireframe sketches on this board, and build on the confirmed layouts.`;
|
||||
}
|
||||
|
||||
function EmptyState({ path }: { path: string }) {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCommentsFile, draftEntries, parseCommentsFile, passForFrames } from "./frameComments";
|
||||
|
||||
const frames = [
|
||||
{ index: 1, status: "built" as const, src: "compositions/frames/01-hook.html", title: "Hook" },
|
||||
{
|
||||
index: 2,
|
||||
status: "built" as const,
|
||||
src: "compositions/frames/02-thesis.html",
|
||||
title: "Thesis",
|
||||
},
|
||||
{ index: 3, status: "built" as const, src: "compositions/frames/03-proof.html", title: "Proof" },
|
||||
];
|
||||
|
||||
describe("passForFrames", () => {
|
||||
it("maps the furthest status to the review pass", () => {
|
||||
expect(passForFrames([{ status: "outline" }, { status: "outline" }])).toBe("storyboard");
|
||||
expect(passForFrames([{ status: "outline" }, { status: "built" }])).toBe("sketch");
|
||||
expect(passForFrames([{ status: "built" }, { status: "animated" }])).toBe("final");
|
||||
});
|
||||
});
|
||||
|
||||
describe("draftEntries", () => {
|
||||
it("keeps only non-empty drafts, in board order, with src/title echoed", () => {
|
||||
const entries = draftEntries(frames, { 3: "swap the chart", 1: " ", 2: "tighter kerning" });
|
||||
expect(entries).toEqual([
|
||||
{ frame: 2, src: frames[1]?.src, title: "Thesis", text: "tighter kerning" },
|
||||
{ frame: 3, src: frames[2]?.src, title: "Proof", text: "swap the chart" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCommentsFile", () => {
|
||||
it("reads a well-formed file", () => {
|
||||
const parsed = parseCommentsFile(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
pass: "sketch",
|
||||
submitted_at: "2026-07-10T00:00:00Z",
|
||||
comments: [{ frame: 2, text: "hi" }],
|
||||
}),
|
||||
);
|
||||
expect(parsed?.pass).toBe("sketch");
|
||||
expect(parsed?.comments).toEqual([{ frame: 2, src: undefined, title: undefined, text: "hi" }]);
|
||||
});
|
||||
|
||||
it("treats empty or malformed input as no file", () => {
|
||||
expect(parseCommentsFile("")).toBeNull();
|
||||
expect(parseCommentsFile("not json")).toBeNull();
|
||||
expect(parseCommentsFile(JSON.stringify({ comments: "nope" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("drops malformed entries but keeps the valid ones", () => {
|
||||
const parsed = parseCommentsFile(
|
||||
JSON.stringify({ comments: [{ frame: 1, text: "ok" }, { frame: "x" }, null] }),
|
||||
);
|
||||
expect(parsed?.comments).toEqual([{ frame: 1, src: undefined, title: undefined, text: "ok" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCommentsFile", () => {
|
||||
it("merges an unconsumed previous batch: new frames win, others stay", () => {
|
||||
const previous = parseCommentsFile(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
pass: "sketch",
|
||||
submitted_at: "2026-07-10T00:00:00Z",
|
||||
comments: [
|
||||
{ frame: 1, text: "old note on hook" },
|
||||
{ frame: 3, text: "old note on proof" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const file = buildCommentsFile(
|
||||
frames,
|
||||
{ 3: "new note on proof" },
|
||||
previous,
|
||||
"2026-07-10T01:00:00Z",
|
||||
);
|
||||
expect(file.pass).toBe("sketch");
|
||||
expect(file.submitted_at).toBe("2026-07-10T01:00:00Z");
|
||||
expect(file.comments.map((c) => [c.frame, c.text])).toEqual([
|
||||
[1, "old note on hook"],
|
||||
[3, "new note on proof"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes a fresh batch when no previous file exists", () => {
|
||||
const file = buildCommentsFile(frames, { 2: "tighter" }, null, "2026-07-10T01:00:00Z");
|
||||
expect(file.version).toBe(1);
|
||||
expect(file.comments).toEqual([
|
||||
{ frame: 2, src: frames[1]?.src, title: "Thesis", text: "tighter" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { FrameStatus } from "@hyperframes/core/storyboard";
|
||||
|
||||
/**
|
||||
* Frame comments — the storyboard review's structured feedback channel.
|
||||
*
|
||||
* The board's per-frame comment boxes write one batch file on submit; the
|
||||
* consuming agent revises exactly the frames named, deletes the file, and
|
||||
* re-presents. The file shape is a cross-repo contract — keep it in sync with
|
||||
* skills/hyperframes-core/references/storyboard-format.md § Frame comments.
|
||||
*/
|
||||
export const FRAME_COMMENTS_PATH = ".hyperframes/frame-comments.json";
|
||||
|
||||
export type FrameCommentsPass = "storyboard" | "sketch" | "final";
|
||||
|
||||
export interface FrameCommentEntry {
|
||||
/** The frame's 1-based `index` in the manifest — the key. */
|
||||
frame: number;
|
||||
/** Copied from the frame at submit time so a post-submit reorder is detectable. */
|
||||
src?: string;
|
||||
title?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface FrameCommentsFile {
|
||||
version: 1;
|
||||
pass: FrameCommentsPass;
|
||||
submitted_at: string;
|
||||
comments: FrameCommentEntry[];
|
||||
}
|
||||
|
||||
interface CommentableFrame {
|
||||
index: number;
|
||||
status: FrameStatus;
|
||||
src?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/** Which review the batch belongs to — the furthest status present on the board. */
|
||||
export function passForFrames(frames: ReadonlyArray<{ status: FrameStatus }>): FrameCommentsPass {
|
||||
if (frames.some((f) => f.status === "animated")) return "final";
|
||||
if (frames.some((f) => f.status === "built")) return "sketch";
|
||||
return "storyboard";
|
||||
}
|
||||
|
||||
/** The non-empty drafts as file entries, in board order. */
|
||||
export function draftEntries(
|
||||
frames: readonly CommentableFrame[],
|
||||
drafts: Readonly<Record<number, string>>,
|
||||
): FrameCommentEntry[] {
|
||||
const entries: FrameCommentEntry[] = [];
|
||||
for (const frame of frames) {
|
||||
const text = drafts[frame.index]?.trim();
|
||||
if (!text) continue;
|
||||
entries.push({ frame: frame.index, src: frame.src, title: frame.title, text });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function parseCommentEntry(item: unknown): FrameCommentEntry | null {
|
||||
if (!isRecord(item)) return null;
|
||||
if (typeof item.frame !== "number" || typeof item.text !== "string") return null;
|
||||
return {
|
||||
frame: item.frame,
|
||||
src: typeof item.src === "string" ? item.src : undefined,
|
||||
title: typeof item.title === "string" ? item.title : undefined,
|
||||
text: item.text,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePass(value: unknown): FrameCommentsPass {
|
||||
return value === "sketch" || value === "final" ? value : "storyboard";
|
||||
}
|
||||
|
||||
/** Tolerant read of an existing comments file — anything malformed counts as "no file". */
|
||||
export function parseCommentsFile(raw: string): FrameCommentsFile | null {
|
||||
if (!raw.trim()) return null;
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(data) || !Array.isArray(data.comments)) return null;
|
||||
const comments = data.comments
|
||||
.map(parseCommentEntry)
|
||||
.filter((entry): entry is FrameCommentEntry => entry !== null);
|
||||
return {
|
||||
version: 1,
|
||||
pass: parsePass(data.pass),
|
||||
submitted_at: typeof data.submitted_at === "string" ? data.submitted_at : "",
|
||||
comments,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One file = the currently pending feedback. A resubmit before the agent
|
||||
* consumed the previous batch keeps the pending entries for frames the new
|
||||
* batch doesn't mention and overwrites the ones it does.
|
||||
*/
|
||||
export function buildCommentsFile(
|
||||
frames: readonly CommentableFrame[],
|
||||
drafts: Readonly<Record<number, string>>,
|
||||
previous: FrameCommentsFile | null,
|
||||
submittedAt: string,
|
||||
): FrameCommentsFile {
|
||||
const fresh = draftEntries(frames, drafts);
|
||||
const freshIndexes = new Set(fresh.map((entry) => entry.frame));
|
||||
const kept = previous?.comments.filter((entry) => !freshIndexes.has(entry.frame)) ?? [];
|
||||
const comments = [...kept, ...fresh].sort((a, b) => a.frame - b.frame);
|
||||
return { version: 1, pass: passForFrames(frames), submitted_at: submittedAt, comments };
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useFileManagerContext } from "../../contexts/FileManagerContext";
|
||||
import type { StoryboardFrameView } from "../../hooks/useStoryboard";
|
||||
import {
|
||||
FRAME_COMMENTS_PATH,
|
||||
buildCommentsFile,
|
||||
parseCommentsFile,
|
||||
type FrameCommentEntry,
|
||||
} from "./frameComments";
|
||||
|
||||
export type CommentsSubmitState = "idle" | "saving";
|
||||
|
||||
export interface FrameCommentsValue {
|
||||
/** Draft text per frame index — "" / absent means no comment. */
|
||||
drafts: Record<number, string>;
|
||||
setDraft: (index: number, text: string) => void;
|
||||
/** How many frames currently carry a non-empty draft. */
|
||||
draftCount: number;
|
||||
submitState: CommentsSubmitState;
|
||||
/** Write the batch to `.hyperframes/frame-comments.json` and clear the drafts. */
|
||||
submit: () => Promise<void>;
|
||||
/**
|
||||
* Comments already submitted but not yet consumed by the agent (the file
|
||||
* still exists on disk). Refreshed on mount, after submit, and on window
|
||||
* focus — the agent deletes the file once it has applied the feedback.
|
||||
*/
|
||||
pending: FrameCommentEntry[] | null;
|
||||
/** Re-read the comments file — callers hook this to board data refreshes. */
|
||||
refreshPending: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Per-frame comment drafts + the batch submit that writes the comments file. */
|
||||
export function useFrameComments(frames: StoryboardFrameView[]): FrameCommentsValue {
|
||||
const { writeProjectFile, readOptionalProjectFile } = useFileManagerContext();
|
||||
const [drafts, setDrafts] = useState<Record<number, string>>({});
|
||||
const [submitState, setSubmitState] = useState<CommentsSubmitState>("idle");
|
||||
const [pending, setPending] = useState<FrameCommentEntry[] | null>(null);
|
||||
|
||||
const refreshPending = useCallback(async () => {
|
||||
try {
|
||||
const parsed = parseCommentsFile(await readOptionalProjectFile(FRAME_COMMENTS_PATH));
|
||||
setPending(parsed && parsed.comments.length > 0 ? parsed.comments : null);
|
||||
} catch {
|
||||
// Transient read failure — keep whatever is currently shown.
|
||||
}
|
||||
}, [readOptionalProjectFile]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshPending();
|
||||
const onFocus = () => void refreshPending();
|
||||
window.addEventListener("focus", onFocus);
|
||||
return () => window.removeEventListener("focus", onFocus);
|
||||
}, [refreshPending]);
|
||||
|
||||
const setDraft = useCallback((index: number, text: string) => {
|
||||
setDrafts((prev) => ({ ...prev, [index]: text }));
|
||||
}, []);
|
||||
|
||||
const draftCount = useMemo(
|
||||
() => Object.values(drafts).filter((text) => text.trim().length > 0).length,
|
||||
[drafts],
|
||||
);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (draftCount === 0 || submitState === "saving") return;
|
||||
setSubmitState("saving");
|
||||
try {
|
||||
const previous = parseCommentsFile(await readOptionalProjectFile(FRAME_COMMENTS_PATH));
|
||||
const file = buildCommentsFile(frames, drafts, previous, new Date().toISOString());
|
||||
await writeProjectFile(FRAME_COMMENTS_PATH, `${JSON.stringify(file, null, 2)}\n`);
|
||||
setDrafts({});
|
||||
setPending(file.comments);
|
||||
} catch {
|
||||
// writeProjectFile surfaces save failures through the studio save banner;
|
||||
// just re-arm the button so the user can retry.
|
||||
} finally {
|
||||
setSubmitState("idle");
|
||||
}
|
||||
}, [draftCount, submitState, frames, drafts, readOptionalProjectFile, writeProjectFile]);
|
||||
|
||||
return { drafts, setDraft, draftCount, submitState, submit, pending, refreshPending };
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useProjectSignaturePoll } from "./useProjectSignaturePoll";
|
||||
|
||||
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
function HookHost({
|
||||
signature,
|
||||
onChange,
|
||||
}: {
|
||||
signature: string | undefined;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
useProjectSignaturePoll("demo", signature, onChange);
|
||||
return null;
|
||||
}
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
function mount(signature: string | undefined, onChange: () => void): void {
|
||||
act(() => {
|
||||
if (!root) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
}
|
||||
root.render(<HookHost signature={signature} onChange={onChange} />);
|
||||
});
|
||||
}
|
||||
|
||||
function unmount(): void {
|
||||
if (root) {
|
||||
act(() => root?.unmount());
|
||||
root = null;
|
||||
}
|
||||
container?.remove();
|
||||
container = null;
|
||||
}
|
||||
|
||||
function mockSignatureResponse(signature: string): ReturnType<typeof vi.fn> {
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ signature }) }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
describe("useProjectSignaturePoll", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unmount();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("fires onChange when the polled signature moves past the loaded one", async () => {
|
||||
const fetchMock = mockSignatureResponse("sig-b");
|
||||
const onChange = vi.fn();
|
||||
mount("sig-a", onChange);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/projects/demo/signature");
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stays quiet while the polled signature matches", async () => {
|
||||
mockSignatureResponse("sig-a");
|
||||
const onChange = vi.fn();
|
||||
mount("sig-a", onChange);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6000);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not poll before a baseline signature exists", async () => {
|
||||
const fetchMock = mockSignatureResponse("sig-a");
|
||||
const onChange = vi.fn();
|
||||
mount(undefined, onChange);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6000);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-arms against the caller's refreshed signature without re-mounting", async () => {
|
||||
mockSignatureResponse("sig-b");
|
||||
const onChange = vi.fn();
|
||||
mount("sig-a", onChange);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The caller refetched and now holds the polled signature — no more firing.
|
||||
mount("sig-b", onChange);
|
||||
await vi.advanceTimersByTimeAsync(6000);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips ticks while the tab is hidden and re-checks on visibility", async () => {
|
||||
const fetchMock = mockSignatureResponse("sig-b");
|
||||
const onChange = vi.fn();
|
||||
let hidden = true;
|
||||
vi.spyOn(document, "hidden", "get").mockImplementation(() => hidden);
|
||||
mount("sig-a", onChange);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6000);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
hidden = false;
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops polling after unmount", async () => {
|
||||
mockSignatureResponse("sig-b");
|
||||
const onChange = vi.fn();
|
||||
mount("sig-a", onChange);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
unmount();
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { buildProjectApiPath } from "../utils/projectRouting";
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
/** One poll: the current signature, or null on any failure (skip this tick). */
|
||||
async function fetchProjectSignature(projectId: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(buildProjectApiPath(projectId, "/signature"));
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { signature?: string };
|
||||
return typeof body.signature === "string" ? body.signature : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll the project's content signature and fire `onChange` when it no longer
|
||||
* matches `currentSignature` — the storyboard board uses this to refresh itself
|
||||
* while an agent writes sketch frames to disk.
|
||||
*
|
||||
* The comparison baseline is the signature the caller's data was loaded with,
|
||||
* so a refetch triggered by `onChange` naturally re-arms the poll with the new
|
||||
* value. Ticks are skipped while the tab is hidden (a visibility flip re-checks
|
||||
* immediately) and while a previous request is still in flight; request
|
||||
* failures are ignored — polling degrades to today's manual-reload behavior.
|
||||
*/
|
||||
export function useProjectSignaturePoll(
|
||||
projectId: string | null,
|
||||
currentSignature: string | undefined,
|
||||
onChange: () => void,
|
||||
): void {
|
||||
const signatureRef = useRef(currentSignature);
|
||||
const onChangeRef = useRef(onChange);
|
||||
signatureRef.current = currentSignature;
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
let disposed = false;
|
||||
let inFlight = false;
|
||||
|
||||
const tick = async () => {
|
||||
if (disposed || inFlight || document.hidden) return;
|
||||
// No baseline yet (initial storyboard fetch still loading, or an older
|
||||
// server without the signature field) — nothing to compare against.
|
||||
if (signatureRef.current === undefined) return;
|
||||
inFlight = true;
|
||||
const latest = await fetchProjectSignature(projectId);
|
||||
inFlight = false;
|
||||
if (disposed || latest === null) return;
|
||||
if (latest !== signatureRef.current) onChangeRef.current();
|
||||
};
|
||||
|
||||
const interval = window.setInterval(() => void tick(), POLL_INTERVAL_MS);
|
||||
const onVisibilityChange = () => {
|
||||
if (!document.hidden) void tick();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearInterval(interval);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
}, [projectId]);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type {
|
||||
StoryboardFrame,
|
||||
StoryboardGlobals,
|
||||
@@ -27,6 +27,8 @@ export interface StoryboardResponse {
|
||||
frames: StoryboardFrameView[];
|
||||
warnings: StoryboardWarning[];
|
||||
script?: StoryboardScript;
|
||||
/** Project content signature this payload was derived from (poster cache-bust + change poll). */
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export interface UseStoryboardResult {
|
||||
@@ -39,19 +41,30 @@ export interface UseStoryboardResult {
|
||||
/**
|
||||
* Load the parsed storyboard manifest for a project. Markdown stays canonical on
|
||||
* disk; this fetches the server-derived JSON the storyboard view renders.
|
||||
*
|
||||
* `reload()` refetches in place: the blocking spinner only shows on first load
|
||||
* and project switches, so background refreshes (file-change polling, post-save
|
||||
* reloads) swap data without flashing the board.
|
||||
*/
|
||||
export function useStoryboard(projectId: string | null): UseStoryboardResult {
|
||||
const [data, setData] = useState<StoryboardResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const hasDataRef = useRef(false);
|
||||
const lastProjectRef = useRef<string | null>(null);
|
||||
|
||||
const reload = useCallback(() => setReloadKey((k) => k + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
if (lastProjectRef.current !== projectId) {
|
||||
lastProjectRef.current = projectId;
|
||||
hasDataRef.current = false;
|
||||
setData(null);
|
||||
}
|
||||
if (!hasDataRef.current) setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Route through buildProjectApiPath so the (URL-derived) projectId is encoded
|
||||
@@ -62,10 +75,16 @@ export function useStoryboard(projectId: string | null): UseStoryboardResult {
|
||||
return res.json() as Promise<StoryboardResponse>;
|
||||
})
|
||||
.then((json) => {
|
||||
if (!cancelled) setData(json);
|
||||
if (cancelled) return;
|
||||
hasDataRef.current = true;
|
||||
setData(json);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : "failed to load storyboard");
|
||||
if (cancelled) return;
|
||||
// A failed background refresh keeps the last good board on screen;
|
||||
// the blocking error state is only for loads with nothing to show.
|
||||
if (hasDataRef.current) return;
|
||||
setError(err instanceof Error ? err.message : "failed to load storyboard");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
|
||||
Reference in New Issue
Block a user