mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +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:
@@ -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