@hyperframes/studio 0.7.58 → 0.7.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-CtTDO63S.js → hyperframes-player-Bm07FLkl.js} +1 -1
- package/dist/assets/{index-C47jAC3Q.js → index-B995FG46.js} +1 -1
- package/dist/assets/index-CrkAdJkb.js +426 -0
- package/dist/assets/index-Dj5p8U_A.css +1 -0
- package/dist/assets/{index-DeQPzqwH.js → index-FvzmPhfG.js} +1 -1
- package/dist/index.d.ts +12 -3
- package/dist/index.html +2 -2
- package/dist/index.js +13514 -8528
- package/dist/index.js.map +1 -1
- package/dist/styles/tailwind-preset.d.ts +5 -0
- package/dist/styles/tailwind-preset.js +8 -1
- package/dist/styles/tailwind-preset.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +3 -3
- package/src/components/DesignPanelPromoteProvider.tsx +15 -2
- package/src/components/StudioRightPanel.tsx +29 -51
- package/src/components/editor/AnimationCard.test.tsx +134 -0
- package/src/components/editor/AnimationCard.tsx +19 -4
- package/src/components/editor/ArcPathControls.tsx +1 -0
- package/src/components/editor/GestureRecordControl.tsx +7 -1
- package/src/components/editor/GsapAnimationSection.tsx +109 -14
- package/src/components/editor/InspectorHeaderActions.tsx +42 -4
- package/src/components/editor/PropertyPanel.test.tsx +948 -0
- package/src/components/editor/PropertyPanel.tsx +144 -148
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
- package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
- package/src/components/editor/PropertyPanelFlat.tsx +580 -0
- package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
- package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
- package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
- package/src/components/editor/gsapLivePreview.ts +34 -0
- package/src/components/editor/manualEditingAvailability.test.ts +10 -0
- package/src/components/editor/manualEditingAvailability.ts +9 -0
- package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
- package/src/components/editor/propertyPanelColor.test.tsx +28 -0
- package/src/components/editor/propertyPanelColor.tsx +31 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
- package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
- package/src/components/editor/propertyPanelFill.tsx +22 -5
- package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
- package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
- package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
- package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
- package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
- package/src/components/editor/propertyPanelFont.test.tsx +30 -0
- package/src/components/editor/propertyPanelFont.tsx +136 -95
- package/src/components/editor/propertyPanelHelpers.ts +73 -0
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
- package/src/components/editor/propertyPanelSections.test.tsx +161 -0
- package/src/components/editor/propertyPanelSections.tsx +154 -90
- package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
- package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
- package/src/components/editor/propertyPanelTypes.ts +14 -1
- package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
- package/src/components/editor/propertyPanelValueTier.ts +28 -0
- package/src/components/editor/useColorGradingController.test.ts +418 -0
- package/src/components/editor/useColorGradingController.ts +596 -0
- package/src/components/panels/VariablesPanel.tsx +4 -0
- package/src/components/sidebar/CompositionsTab.test.ts +28 -2
- package/src/components/sidebar/CompositionsTab.tsx +3 -1
- package/src/components/storyboard/FramePoster.tsx +24 -4
- package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
- package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
- package/src/components/storyboard/StoryboardGrid.tsx +23 -1
- package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
- package/src/components/storyboard/StoryboardView.tsx +9 -2
- package/src/components/storyboard/frameComments.test.ts +95 -0
- package/src/components/storyboard/frameComments.ts +115 -0
- package/src/components/storyboard/useFrameComments.ts +82 -0
- package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
- package/src/contexts/DesignPanelInputContext.tsx +48 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/VariablePromoteContext.test.tsx +67 -0
- package/src/contexts/VariablePromoteContext.tsx +10 -5
- package/src/hooks/domEditCommitRunner.ts +11 -0
- package/src/hooks/gsapScriptCommitTypes.ts +6 -4
- package/src/hooks/timelineTrackVisibility.test.ts +52 -0
- package/src/hooks/timelineTrackVisibility.ts +27 -10
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useDomEditAttributeCommits.ts +123 -1
- package/src/hooks/useDomEditCommits.ts +29 -175
- package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
- package/src/hooks/useDomEditSession.ts +17 -5
- package/src/hooks/useDomEditTextCommits.ts +14 -9
- package/src/hooks/useDomEditWiring.ts +10 -9
- package/src/hooks/useElementLifecycleOps.ts +8 -3
- package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
- package/src/hooks/useFileManager.ts +82 -60
- package/src/hooks/useGsapAnimationOps.ts +7 -6
- package/src/hooks/useGsapKeyframeOps.ts +6 -5
- package/src/hooks/useGsapPropertyDebounce.ts +43 -28
- package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
- package/src/hooks/useGsapScriptCommits.ts +150 -50
- package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
- package/src/hooks/useGsapSelectionHandlers.ts +94 -31
- package/src/hooks/useInspectorSplitResize.ts +51 -0
- package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
- package/src/hooks/usePersistentEditHistory.test.ts +49 -1
- package/src/hooks/usePersistentEditHistory.ts +71 -31
- package/src/hooks/useProjectCompositionVariables.ts +10 -10
- package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
- package/src/hooks/useProjectSignaturePoll.ts +68 -0
- package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
- package/src/hooks/useSdkSession.ts +155 -14
- package/src/hooks/useSlideshowPersist.ts +7 -1
- package/src/hooks/useStoryboard.ts +23 -4
- package/src/hooks/useTimelineEditing.test.tsx +4 -1
- package/src/hooks/useTimelineEditing.ts +16 -10
- package/src/hooks/useTimelineEditingTypes.ts +3 -0
- package/src/hooks/useTimelineGroupEditing.ts +16 -2
- package/src/hooks/useVariablesPersist.ts +9 -7
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/styles/studio.css +22 -0
- package/src/styles/tailwind-preset.shared.js +7 -0
- package/src/utils/designInputTracking.test.ts +97 -0
- package/src/utils/designInputTracking.ts +80 -0
- package/src/utils/sdkCutover.gate.test.ts +7 -7
- package/src/utils/sdkCutover.test.ts +490 -57
- package/src/utils/sdkCutover.ts +181 -219
- package/src/utils/sdkEditTransaction.ts +292 -0
- package/src/utils/sdkResolverAttempts.ts +94 -0
- package/src/utils/sdkResolverShadow.test.ts +148 -0
- package/src/utils/sdkResolverShadow.ts +139 -106
- package/src/utils/setSlideshowManifest.test.ts +0 -9
- package/src/utils/setSlideshowManifest.ts +20 -27
- package/src/utils/studioFileHistory.test.ts +41 -0
- package/src/utils/studioFileHistory.ts +30 -27
- package/src/utils/studioFileMutationCoordinator.ts +45 -0
- package/src/utils/studioHelpers.test.ts +45 -0
- package/src/utils/studioHelpers.ts +45 -14
- package/src/utils/studioSaveDiagnostics.ts +17 -0
- package/dist/assets/index-B_UvTX3E.js +0 -423
- package/dist/assets/index-uahwWkgw.css +0 -1
|
@@ -10,6 +10,13 @@ export interface FramePosterProps {
|
|
|
10
10
|
title: string;
|
|
11
11
|
/** `cover` fills+crops (contact-sheet tile); `contain` letterboxes (focus hero). */
|
|
12
12
|
fit?: "cover" | "contain";
|
|
13
|
+
/**
|
|
14
|
+
* Project content signature to key the poster URL on. The thumbnail route
|
|
15
|
+
* regenerates when the frame's source changes, but the browser only refetches
|
|
16
|
+
* when the URL does — so bake the signature in to pick up fresh posters after
|
|
17
|
+
* a background board refresh.
|
|
18
|
+
*/
|
|
19
|
+
posterVersion?: string;
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
/**
|
|
@@ -18,11 +25,19 @@ export interface FramePosterProps {
|
|
|
18
25
|
* no postMessage seek, and no client-side fps assumption. Shared by the
|
|
19
26
|
* contact-sheet tile and the frame-focus view.
|
|
20
27
|
*/
|
|
21
|
-
export function FramePoster({
|
|
28
|
+
export function FramePoster({
|
|
29
|
+
projectId,
|
|
30
|
+
src,
|
|
31
|
+
seconds,
|
|
32
|
+
title,
|
|
33
|
+
fit = "cover",
|
|
34
|
+
posterVersion,
|
|
35
|
+
}: FramePosterProps) {
|
|
22
36
|
const [failed, setFailed] = useState(false);
|
|
23
37
|
// The <img> is reused (no key) when a tile/hero swaps to a different frame, so a
|
|
24
|
-
// prior load error would stick. Reset when the poster target changes
|
|
25
|
-
|
|
38
|
+
// prior load error would stick. Reset when the poster target changes — including
|
|
39
|
+
// a new posterVersion, so a frame that failed mid-write retries once it settles.
|
|
40
|
+
useEffect(() => setFailed(false), [src, seconds, posterVersion]);
|
|
26
41
|
if (failed) {
|
|
27
42
|
return (
|
|
28
43
|
<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
|
|
|
30
45
|
</div>
|
|
31
46
|
);
|
|
32
47
|
}
|
|
33
|
-
|
|
48
|
+
let url = buildCompositionThumbnailUrl({
|
|
34
49
|
previewUrl: `/api/projects/${projectId}/preview/comp/${src}`,
|
|
35
50
|
seekTime: seconds,
|
|
36
51
|
duration: 0,
|
|
37
52
|
origin: window.location.origin,
|
|
38
53
|
});
|
|
54
|
+
if (posterVersion) {
|
|
55
|
+
const withVersion = new URL(url, window.location.origin);
|
|
56
|
+
withVersion.searchParams.set("sig", posterVersion);
|
|
57
|
+
url = withVersion.toString();
|
|
58
|
+
}
|
|
39
59
|
return (
|
|
40
60
|
<img
|
|
41
61
|
src={url}
|
|
@@ -19,6 +19,8 @@ export interface StoryboardFrameFocusProps {
|
|
|
19
19
|
onSaved: () => void;
|
|
20
20
|
/** Select a composition in the timeline (sets active comp + editing file + sidebar highlight). */
|
|
21
21
|
onSelectComposition: (path: string) => void;
|
|
22
|
+
/** Project signature the board was loaded with (busts the poster cache). */
|
|
23
|
+
posterVersion?: string;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
/**
|
|
@@ -39,6 +41,7 @@ export function StoryboardFrameFocus({
|
|
|
39
41
|
onNavigate,
|
|
40
42
|
onSaved,
|
|
41
43
|
onSelectComposition,
|
|
44
|
+
posterVersion,
|
|
42
45
|
}: StoryboardFrameFocusProps) {
|
|
43
46
|
const { readProjectFile, writeProjectFile } = useFileManagerContext();
|
|
44
47
|
const { setViewMode } = useViewMode();
|
|
@@ -151,6 +154,7 @@ export function StoryboardFrameFocus({
|
|
|
151
154
|
seconds={posterTime(frame)}
|
|
152
155
|
title={title}
|
|
153
156
|
fit="contain"
|
|
157
|
+
posterVersion={posterVersion}
|
|
154
158
|
/>
|
|
155
159
|
) : (
|
|
156
160
|
<div className="flex h-full w-full items-center justify-center text-sm text-neutral-600">
|
|
@@ -7,6 +7,13 @@ export interface StoryboardFrameTileProps {
|
|
|
7
7
|
frame: StoryboardFrameView;
|
|
8
8
|
/** Open this frame in the full-area focus view. */
|
|
9
9
|
onOpen: (index: number) => void;
|
|
10
|
+
/** This frame's pending comment draft ("" when none). */
|
|
11
|
+
commentDraft: string;
|
|
12
|
+
onCommentDraftChange: (index: number, text: string) => void;
|
|
13
|
+
/** A submitted comment the agent has not consumed yet (null when none). */
|
|
14
|
+
pendingComment: string | null;
|
|
15
|
+
/** Project signature the board was loaded with (busts the poster cache). */
|
|
16
|
+
posterVersion?: string;
|
|
10
17
|
}
|
|
11
18
|
|
|
12
19
|
function firstLine(text: string): string {
|
|
@@ -26,7 +33,15 @@ function placeholderMessage(frame: StoryboardFrameView): string {
|
|
|
26
33
|
|
|
27
34
|
/** A single contact-sheet tile: poster preview + its metadata. Click to focus. */
|
|
28
35
|
// fallow-ignore-next-line complexity
|
|
29
|
-
export function StoryboardFrameTile({
|
|
36
|
+
export function StoryboardFrameTile({
|
|
37
|
+
projectId,
|
|
38
|
+
frame,
|
|
39
|
+
onOpen,
|
|
40
|
+
commentDraft,
|
|
41
|
+
onCommentDraftChange,
|
|
42
|
+
pendingComment,
|
|
43
|
+
posterVersion,
|
|
44
|
+
}: StoryboardFrameTileProps) {
|
|
30
45
|
const meta = FRAME_STATUS_META[frame.status];
|
|
31
46
|
const renderable = frame.srcExists && frame.status !== "outline";
|
|
32
47
|
const title = frame.title ?? `Frame ${frame.index}`;
|
|
@@ -48,6 +63,7 @@ export function StoryboardFrameTile({ projectId, frame, onOpen }: StoryboardFram
|
|
|
48
63
|
src={frame.src}
|
|
49
64
|
seconds={posterTime(frame)}
|
|
50
65
|
title={title}
|
|
66
|
+
posterVersion={posterVersion}
|
|
51
67
|
/>
|
|
52
68
|
) : (
|
|
53
69
|
<FrameTilePlaceholder frame={frame} />
|
|
@@ -74,6 +90,19 @@ export function StoryboardFrameTile({ projectId, frame, onOpen }: StoryboardFram
|
|
|
74
90
|
{frame.duration && <span>{frame.duration}</span>}
|
|
75
91
|
{frame.transitionIn && <span>↘ {frame.transitionIn}</span>}
|
|
76
92
|
</div>
|
|
93
|
+
<textarea
|
|
94
|
+
value={commentDraft}
|
|
95
|
+
onChange={(e) => onCommentDraftChange(frame.index, e.target.value)}
|
|
96
|
+
rows={2}
|
|
97
|
+
placeholder="Comment on this frame…"
|
|
98
|
+
aria-label={`Comment on ${title}`}
|
|
99
|
+
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"
|
|
100
|
+
/>
|
|
101
|
+
{pendingComment && (
|
|
102
|
+
<p className="mt-1 text-[11px] text-sky-400/90">
|
|
103
|
+
<span className="font-medium">Pending:</span> “{pendingComment}”
|
|
104
|
+
</p>
|
|
105
|
+
)}
|
|
77
106
|
</article>
|
|
78
107
|
);
|
|
79
108
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { StoryboardFrameView } from "../../hooks/useStoryboard";
|
|
2
|
+
import type { FrameCommentEntry } from "./frameComments";
|
|
2
3
|
import { StoryboardFrameTile } from "./StoryboardFrameTile";
|
|
3
4
|
|
|
4
5
|
export interface StoryboardGridProps {
|
|
@@ -6,10 +7,25 @@ export interface StoryboardGridProps {
|
|
|
6
7
|
frames: StoryboardFrameView[];
|
|
7
8
|
/** Open a frame in the full-area focus view. */
|
|
8
9
|
onOpenFrame: (index: number) => void;
|
|
10
|
+
/** Per-frame comment drafts, keyed by frame index. */
|
|
11
|
+
commentDrafts: Record<number, string>;
|
|
12
|
+
onCommentDraftChange: (index: number, text: string) => void;
|
|
13
|
+
/** Submitted comments the agent has not consumed yet. */
|
|
14
|
+
pendingComments: FrameCommentEntry[] | null;
|
|
15
|
+
/** Project signature the board was loaded with (busts poster caches). */
|
|
16
|
+
posterVersion?: string;
|
|
9
17
|
}
|
|
10
18
|
|
|
11
19
|
/** The contact sheet: ordered frame tiles in a responsive grid. */
|
|
12
|
-
export function StoryboardGrid({
|
|
20
|
+
export function StoryboardGrid({
|
|
21
|
+
projectId,
|
|
22
|
+
frames,
|
|
23
|
+
onOpenFrame,
|
|
24
|
+
commentDrafts,
|
|
25
|
+
onCommentDraftChange,
|
|
26
|
+
pendingComments,
|
|
27
|
+
posterVersion,
|
|
28
|
+
}: StoryboardGridProps) {
|
|
13
29
|
if (frames.length === 0) {
|
|
14
30
|
return (
|
|
15
31
|
<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
|
|
|
26
42
|
projectId={projectId}
|
|
27
43
|
frame={frame}
|
|
28
44
|
onOpen={onOpenFrame}
|
|
45
|
+
commentDraft={commentDrafts[frame.index] ?? ""}
|
|
46
|
+
onCommentDraftChange={onCommentDraftChange}
|
|
47
|
+
pendingComment={
|
|
48
|
+
pendingComments?.find((entry) => entry.frame === frame.index)?.text ?? null
|
|
49
|
+
}
|
|
50
|
+
posterVersion={posterVersion}
|
|
29
51
|
/>
|
|
30
52
|
))}
|
|
31
53
|
</div>
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { useMemo, useState } from "react";
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
2
|
import type { StoryboardResponse } from "../../hooks/useStoryboard";
|
|
3
|
+
import { Button } from "../ui/Button";
|
|
3
4
|
import { StoryboardDirection } from "./StoryboardDirection";
|
|
4
5
|
import { StoryboardGrid } from "./StoryboardGrid";
|
|
5
6
|
import { StoryboardStatusLegend } from "./StoryboardStatusLegend";
|
|
6
7
|
import { StoryboardScriptPanel } from "./StoryboardScriptPanel";
|
|
7
8
|
import { StoryboardSourceEditor, type SourceFile } from "./StoryboardSourceEditor";
|
|
8
9
|
import { StoryboardFrameFocus } from "./StoryboardFrameFocus";
|
|
10
|
+
import { useFrameComments, type CommentsSubmitState } from "./useFrameComments";
|
|
9
11
|
|
|
10
12
|
type SubView = "board" | "source";
|
|
11
13
|
|
|
@@ -33,6 +35,14 @@ export function StoryboardLoaded({
|
|
|
33
35
|
const [subView, setSubView] = useState<SubView>("board");
|
|
34
36
|
const [sourceDirty, setSourceDirty] = useState(false);
|
|
35
37
|
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
|
38
|
+
const comments = useFrameComments(data.frames);
|
|
39
|
+
// When the board refreshes off a project change (agent revised frames), the
|
|
40
|
+
// agent has likely consumed the comments file too — re-check so the pending
|
|
41
|
+
// banner clears the moment revisions land, not on the next window focus.
|
|
42
|
+
const { refreshPending } = comments;
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
void refreshPending();
|
|
45
|
+
}, [data.signature, refreshPending]);
|
|
36
46
|
const sourceFiles = useMemo<SourceFile[]>(() => {
|
|
37
47
|
const files: SourceFile[] = [{ path: data.path, label: data.path }];
|
|
38
48
|
if (data.script?.exists) files.push({ path: data.script.path, label: data.script.path });
|
|
@@ -72,14 +82,23 @@ export function StoryboardLoaded({
|
|
|
72
82
|
}
|
|
73
83
|
onSaved={reload}
|
|
74
84
|
onSelectComposition={onSelectComposition}
|
|
85
|
+
posterVersion={data.signature}
|
|
75
86
|
/>
|
|
76
87
|
);
|
|
77
88
|
}
|
|
78
89
|
|
|
79
90
|
return (
|
|
80
91
|
<div className="flex flex-1 min-h-0 flex-col bg-neutral-950 text-neutral-200">
|
|
81
|
-
<div className="flex items-center border-b border-neutral-800 px-4 py-2">
|
|
92
|
+
<div className="flex items-center gap-3 border-b border-neutral-800 px-4 py-2">
|
|
82
93
|
<SubViewToggle value={subView} onChange={changeSubView} />
|
|
94
|
+
{subView === "board" && (
|
|
95
|
+
<CommentsSubmitBar
|
|
96
|
+
draftCount={comments.draftCount}
|
|
97
|
+
pendingCount={comments.pending?.length ?? 0}
|
|
98
|
+
submitState={comments.submitState}
|
|
99
|
+
onSubmit={() => void comments.submit()}
|
|
100
|
+
/>
|
|
101
|
+
)}
|
|
83
102
|
</div>
|
|
84
103
|
{subView === "board" ? (
|
|
85
104
|
<div className="flex-1 min-h-0 overflow-auto">
|
|
@@ -92,6 +111,10 @@ export function StoryboardLoaded({
|
|
|
92
111
|
projectId={projectId}
|
|
93
112
|
frames={data.frames}
|
|
94
113
|
onOpenFrame={setFocusedIndex}
|
|
114
|
+
commentDrafts={comments.drafts}
|
|
115
|
+
onCommentDraftChange={comments.setDraft}
|
|
116
|
+
pendingComments={comments.pending}
|
|
117
|
+
posterVersion={data.signature}
|
|
95
118
|
/>
|
|
96
119
|
{data.script && <StoryboardScriptPanel script={data.script} />}
|
|
97
120
|
</div>
|
|
@@ -107,6 +130,39 @@ export function StoryboardLoaded({
|
|
|
107
130
|
);
|
|
108
131
|
}
|
|
109
132
|
|
|
133
|
+
/** Batch-submit the per-frame comment drafts to `.hyperframes/frame-comments.json`. */
|
|
134
|
+
function CommentsSubmitBar({
|
|
135
|
+
draftCount,
|
|
136
|
+
pendingCount,
|
|
137
|
+
submitState,
|
|
138
|
+
onSubmit,
|
|
139
|
+
}: {
|
|
140
|
+
draftCount: number;
|
|
141
|
+
pendingCount: number;
|
|
142
|
+
submitState: CommentsSubmitState;
|
|
143
|
+
onSubmit: () => void;
|
|
144
|
+
}) {
|
|
145
|
+
return (
|
|
146
|
+
<div className="ml-auto flex items-center gap-3">
|
|
147
|
+
{pendingCount > 0 && (
|
|
148
|
+
<span className="text-xs text-sky-300">
|
|
149
|
+
{pendingCount} comment{pendingCount > 1 ? "s" : ""} pending — reply anything in your agent
|
|
150
|
+
chat and it will apply them.
|
|
151
|
+
</span>
|
|
152
|
+
)}
|
|
153
|
+
<Button
|
|
154
|
+
variant="primary"
|
|
155
|
+
size="sm"
|
|
156
|
+
loading={submitState === "saving"}
|
|
157
|
+
disabled={draftCount === 0 || submitState === "saving"}
|
|
158
|
+
onClick={onSubmit}
|
|
159
|
+
>
|
|
160
|
+
{draftCount > 0 ? `Submit comments (${draftCount})` : "Submit comments"}
|
|
161
|
+
</Button>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
110
166
|
const SUB_VIEWS: Array<{ value: SubView; label: string }> = [
|
|
111
167
|
{ value: "board", label: "Board" },
|
|
112
168
|
{ value: "source", label: "Source" },
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState, type ReactNode } from "react";
|
|
2
2
|
import { Copy, Check } from "@phosphor-icons/react";
|
|
3
3
|
import { useStoryboard } from "../../hooks/useStoryboard";
|
|
4
|
+
import { useProjectSignaturePoll } from "../../hooks/useProjectSignaturePoll";
|
|
4
5
|
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
5
6
|
import { Button } from "../ui/Button";
|
|
6
7
|
import { StoryboardLoaded } from "./StoryboardLoaded";
|
|
@@ -19,6 +20,10 @@ export interface StoryboardViewProps {
|
|
|
19
20
|
// fallow-ignore-next-line complexity
|
|
20
21
|
export function StoryboardView({ projectId, onSelectComposition }: StoryboardViewProps) {
|
|
21
22
|
const { data, loading, error, reload } = useStoryboard(projectId);
|
|
23
|
+
// Keep the board current while an agent writes to the project: when the
|
|
24
|
+
// project signature moves past the one `data` was loaded with, refetch. Also
|
|
25
|
+
// upgrades the empty state the moment STORYBOARD.md lands on disk.
|
|
26
|
+
useProjectSignaturePoll(projectId, data?.signature, reload);
|
|
22
27
|
|
|
23
28
|
if (loading) return <StoryboardFrame>{<Message>Loading storyboard…</Message>}</StoryboardFrame>;
|
|
24
29
|
if (error) {
|
|
@@ -87,12 +92,14 @@ audience: <who it's for>
|
|
|
87
92
|
## Frame 1 — <title>
|
|
88
93
|
- duration: 5s
|
|
89
94
|
- transition_in: crossfade
|
|
90
|
-
- status:
|
|
95
|
+
- status: outline
|
|
91
96
|
- src: compositions/frames/01-<slug>.html
|
|
92
97
|
|
|
93
98
|
<A sentence or two: what's on screen and what the narration says.>
|
|
94
99
|
|
|
95
|
-
Add one \`## Frame N\` section per beat. Keep the arc tight
|
|
100
|
+
Add one \`## Frame N\` section per beat. Keep the arc tight.
|
|
101
|
+
|
|
102
|
+
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.`;
|
|
96
103
|
}
|
|
97
104
|
|
|
98
105
|
function EmptyState({ path }: { path: string }) {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildCommentsFile, draftEntries, parseCommentsFile, passForFrames } from "./frameComments";
|
|
3
|
+
|
|
4
|
+
const frames = [
|
|
5
|
+
{ index: 1, status: "built" as const, src: "compositions/frames/01-hook.html", title: "Hook" },
|
|
6
|
+
{
|
|
7
|
+
index: 2,
|
|
8
|
+
status: "built" as const,
|
|
9
|
+
src: "compositions/frames/02-thesis.html",
|
|
10
|
+
title: "Thesis",
|
|
11
|
+
},
|
|
12
|
+
{ index: 3, status: "built" as const, src: "compositions/frames/03-proof.html", title: "Proof" },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
describe("passForFrames", () => {
|
|
16
|
+
it("maps the furthest status to the review pass", () => {
|
|
17
|
+
expect(passForFrames([{ status: "outline" }, { status: "outline" }])).toBe("storyboard");
|
|
18
|
+
expect(passForFrames([{ status: "outline" }, { status: "built" }])).toBe("sketch");
|
|
19
|
+
expect(passForFrames([{ status: "built" }, { status: "animated" }])).toBe("final");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("draftEntries", () => {
|
|
24
|
+
it("keeps only non-empty drafts, in board order, with src/title echoed", () => {
|
|
25
|
+
const entries = draftEntries(frames, { 3: "swap the chart", 1: " ", 2: "tighter kerning" });
|
|
26
|
+
expect(entries).toEqual([
|
|
27
|
+
{ frame: 2, src: frames[1]?.src, title: "Thesis", text: "tighter kerning" },
|
|
28
|
+
{ frame: 3, src: frames[2]?.src, title: "Proof", text: "swap the chart" },
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("parseCommentsFile", () => {
|
|
34
|
+
it("reads a well-formed file", () => {
|
|
35
|
+
const parsed = parseCommentsFile(
|
|
36
|
+
JSON.stringify({
|
|
37
|
+
version: 1,
|
|
38
|
+
pass: "sketch",
|
|
39
|
+
submitted_at: "2026-07-10T00:00:00Z",
|
|
40
|
+
comments: [{ frame: 2, text: "hi" }],
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
expect(parsed?.pass).toBe("sketch");
|
|
44
|
+
expect(parsed?.comments).toEqual([{ frame: 2, src: undefined, title: undefined, text: "hi" }]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("treats empty or malformed input as no file", () => {
|
|
48
|
+
expect(parseCommentsFile("")).toBeNull();
|
|
49
|
+
expect(parseCommentsFile("not json")).toBeNull();
|
|
50
|
+
expect(parseCommentsFile(JSON.stringify({ comments: "nope" }))).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("drops malformed entries but keeps the valid ones", () => {
|
|
54
|
+
const parsed = parseCommentsFile(
|
|
55
|
+
JSON.stringify({ comments: [{ frame: 1, text: "ok" }, { frame: "x" }, null] }),
|
|
56
|
+
);
|
|
57
|
+
expect(parsed?.comments).toEqual([{ frame: 1, src: undefined, title: undefined, text: "ok" }]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("buildCommentsFile", () => {
|
|
62
|
+
it("merges an unconsumed previous batch: new frames win, others stay", () => {
|
|
63
|
+
const previous = parseCommentsFile(
|
|
64
|
+
JSON.stringify({
|
|
65
|
+
version: 1,
|
|
66
|
+
pass: "sketch",
|
|
67
|
+
submitted_at: "2026-07-10T00:00:00Z",
|
|
68
|
+
comments: [
|
|
69
|
+
{ frame: 1, text: "old note on hook" },
|
|
70
|
+
{ frame: 3, text: "old note on proof" },
|
|
71
|
+
],
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
const file = buildCommentsFile(
|
|
75
|
+
frames,
|
|
76
|
+
{ 3: "new note on proof" },
|
|
77
|
+
previous,
|
|
78
|
+
"2026-07-10T01:00:00Z",
|
|
79
|
+
);
|
|
80
|
+
expect(file.pass).toBe("sketch");
|
|
81
|
+
expect(file.submitted_at).toBe("2026-07-10T01:00:00Z");
|
|
82
|
+
expect(file.comments.map((c) => [c.frame, c.text])).toEqual([
|
|
83
|
+
[1, "old note on hook"],
|
|
84
|
+
[3, "new note on proof"],
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("writes a fresh batch when no previous file exists", () => {
|
|
89
|
+
const file = buildCommentsFile(frames, { 2: "tighter" }, null, "2026-07-10T01:00:00Z");
|
|
90
|
+
expect(file.version).toBe(1);
|
|
91
|
+
expect(file.comments).toEqual([
|
|
92
|
+
{ frame: 2, src: frames[1]?.src, title: "Thesis", text: "tighter" },
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { FrameStatus } from "@hyperframes/core/storyboard";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Frame comments — the storyboard review's structured feedback channel.
|
|
5
|
+
*
|
|
6
|
+
* The board's per-frame comment boxes write one batch file on submit; the
|
|
7
|
+
* consuming agent revises exactly the frames named, deletes the file, and
|
|
8
|
+
* re-presents. The file shape is a cross-repo contract — keep it in sync with
|
|
9
|
+
* skills/hyperframes-core/references/storyboard-format.md § Frame comments.
|
|
10
|
+
*/
|
|
11
|
+
export const FRAME_COMMENTS_PATH = ".hyperframes/frame-comments.json";
|
|
12
|
+
|
|
13
|
+
export type FrameCommentsPass = "storyboard" | "sketch" | "final";
|
|
14
|
+
|
|
15
|
+
export interface FrameCommentEntry {
|
|
16
|
+
/** The frame's 1-based `index` in the manifest — the key. */
|
|
17
|
+
frame: number;
|
|
18
|
+
/** Copied from the frame at submit time so a post-submit reorder is detectable. */
|
|
19
|
+
src?: string;
|
|
20
|
+
title?: string;
|
|
21
|
+
text: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface FrameCommentsFile {
|
|
25
|
+
version: 1;
|
|
26
|
+
pass: FrameCommentsPass;
|
|
27
|
+
submitted_at: string;
|
|
28
|
+
comments: FrameCommentEntry[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface CommentableFrame {
|
|
32
|
+
index: number;
|
|
33
|
+
status: FrameStatus;
|
|
34
|
+
src?: string;
|
|
35
|
+
title?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Which review the batch belongs to — the furthest status present on the board. */
|
|
39
|
+
export function passForFrames(frames: ReadonlyArray<{ status: FrameStatus }>): FrameCommentsPass {
|
|
40
|
+
if (frames.some((f) => f.status === "animated")) return "final";
|
|
41
|
+
if (frames.some((f) => f.status === "built")) return "sketch";
|
|
42
|
+
return "storyboard";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The non-empty drafts as file entries, in board order. */
|
|
46
|
+
export function draftEntries(
|
|
47
|
+
frames: readonly CommentableFrame[],
|
|
48
|
+
drafts: Readonly<Record<number, string>>,
|
|
49
|
+
): FrameCommentEntry[] {
|
|
50
|
+
const entries: FrameCommentEntry[] = [];
|
|
51
|
+
for (const frame of frames) {
|
|
52
|
+
const text = drafts[frame.index]?.trim();
|
|
53
|
+
if (!text) continue;
|
|
54
|
+
entries.push({ frame: frame.index, src: frame.src, title: frame.title, text });
|
|
55
|
+
}
|
|
56
|
+
return entries;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
60
|
+
return typeof value === "object" && value !== null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseCommentEntry(item: unknown): FrameCommentEntry | null {
|
|
64
|
+
if (!isRecord(item)) return null;
|
|
65
|
+
if (typeof item.frame !== "number" || typeof item.text !== "string") return null;
|
|
66
|
+
return {
|
|
67
|
+
frame: item.frame,
|
|
68
|
+
src: typeof item.src === "string" ? item.src : undefined,
|
|
69
|
+
title: typeof item.title === "string" ? item.title : undefined,
|
|
70
|
+
text: item.text,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parsePass(value: unknown): FrameCommentsPass {
|
|
75
|
+
return value === "sketch" || value === "final" ? value : "storyboard";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Tolerant read of an existing comments file — anything malformed counts as "no file". */
|
|
79
|
+
export function parseCommentsFile(raw: string): FrameCommentsFile | null {
|
|
80
|
+
if (!raw.trim()) return null;
|
|
81
|
+
let data: unknown;
|
|
82
|
+
try {
|
|
83
|
+
data = JSON.parse(raw);
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
if (!isRecord(data) || !Array.isArray(data.comments)) return null;
|
|
88
|
+
const comments = data.comments
|
|
89
|
+
.map(parseCommentEntry)
|
|
90
|
+
.filter((entry): entry is FrameCommentEntry => entry !== null);
|
|
91
|
+
return {
|
|
92
|
+
version: 1,
|
|
93
|
+
pass: parsePass(data.pass),
|
|
94
|
+
submitted_at: typeof data.submitted_at === "string" ? data.submitted_at : "",
|
|
95
|
+
comments,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* One file = the currently pending feedback. A resubmit before the agent
|
|
101
|
+
* consumed the previous batch keeps the pending entries for frames the new
|
|
102
|
+
* batch doesn't mention and overwrites the ones it does.
|
|
103
|
+
*/
|
|
104
|
+
export function buildCommentsFile(
|
|
105
|
+
frames: readonly CommentableFrame[],
|
|
106
|
+
drafts: Readonly<Record<number, string>>,
|
|
107
|
+
previous: FrameCommentsFile | null,
|
|
108
|
+
submittedAt: string,
|
|
109
|
+
): FrameCommentsFile {
|
|
110
|
+
const fresh = draftEntries(frames, drafts);
|
|
111
|
+
const freshIndexes = new Set(fresh.map((entry) => entry.frame));
|
|
112
|
+
const kept = previous?.comments.filter((entry) => !freshIndexes.has(entry.frame)) ?? [];
|
|
113
|
+
const comments = [...kept, ...fresh].sort((a, b) => a.frame - b.frame);
|
|
114
|
+
return { version: 1, pass: passForFrames(frames), submitted_at: submittedAt, comments };
|
|
115
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { useFileManagerContext } from "../../contexts/FileManagerContext";
|
|
3
|
+
import type { StoryboardFrameView } from "../../hooks/useStoryboard";
|
|
4
|
+
import {
|
|
5
|
+
FRAME_COMMENTS_PATH,
|
|
6
|
+
buildCommentsFile,
|
|
7
|
+
parseCommentsFile,
|
|
8
|
+
type FrameCommentEntry,
|
|
9
|
+
} from "./frameComments";
|
|
10
|
+
|
|
11
|
+
export type CommentsSubmitState = "idle" | "saving";
|
|
12
|
+
|
|
13
|
+
export interface FrameCommentsValue {
|
|
14
|
+
/** Draft text per frame index — "" / absent means no comment. */
|
|
15
|
+
drafts: Record<number, string>;
|
|
16
|
+
setDraft: (index: number, text: string) => void;
|
|
17
|
+
/** How many frames currently carry a non-empty draft. */
|
|
18
|
+
draftCount: number;
|
|
19
|
+
submitState: CommentsSubmitState;
|
|
20
|
+
/** Write the batch to `.hyperframes/frame-comments.json` and clear the drafts. */
|
|
21
|
+
submit: () => Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Comments already submitted but not yet consumed by the agent (the file
|
|
24
|
+
* still exists on disk). Refreshed on mount, after submit, and on window
|
|
25
|
+
* focus — the agent deletes the file once it has applied the feedback.
|
|
26
|
+
*/
|
|
27
|
+
pending: FrameCommentEntry[] | null;
|
|
28
|
+
/** Re-read the comments file — callers hook this to board data refreshes. */
|
|
29
|
+
refreshPending: () => Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Per-frame comment drafts + the batch submit that writes the comments file. */
|
|
33
|
+
export function useFrameComments(frames: StoryboardFrameView[]): FrameCommentsValue {
|
|
34
|
+
const { writeProjectFile, readOptionalProjectFile } = useFileManagerContext();
|
|
35
|
+
const [drafts, setDrafts] = useState<Record<number, string>>({});
|
|
36
|
+
const [submitState, setSubmitState] = useState<CommentsSubmitState>("idle");
|
|
37
|
+
const [pending, setPending] = useState<FrameCommentEntry[] | null>(null);
|
|
38
|
+
|
|
39
|
+
const refreshPending = useCallback(async () => {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = parseCommentsFile(await readOptionalProjectFile(FRAME_COMMENTS_PATH));
|
|
42
|
+
setPending(parsed && parsed.comments.length > 0 ? parsed.comments : null);
|
|
43
|
+
} catch {
|
|
44
|
+
// Transient read failure — keep whatever is currently shown.
|
|
45
|
+
}
|
|
46
|
+
}, [readOptionalProjectFile]);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
void refreshPending();
|
|
50
|
+
const onFocus = () => void refreshPending();
|
|
51
|
+
window.addEventListener("focus", onFocus);
|
|
52
|
+
return () => window.removeEventListener("focus", onFocus);
|
|
53
|
+
}, [refreshPending]);
|
|
54
|
+
|
|
55
|
+
const setDraft = useCallback((index: number, text: string) => {
|
|
56
|
+
setDrafts((prev) => ({ ...prev, [index]: text }));
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
const draftCount = useMemo(
|
|
60
|
+
() => Object.values(drafts).filter((text) => text.trim().length > 0).length,
|
|
61
|
+
[drafts],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const submit = useCallback(async () => {
|
|
65
|
+
if (draftCount === 0 || submitState === "saving") return;
|
|
66
|
+
setSubmitState("saving");
|
|
67
|
+
try {
|
|
68
|
+
const previous = parseCommentsFile(await readOptionalProjectFile(FRAME_COMMENTS_PATH));
|
|
69
|
+
const file = buildCommentsFile(frames, drafts, previous, new Date().toISOString());
|
|
70
|
+
await writeProjectFile(FRAME_COMMENTS_PATH, `${JSON.stringify(file, null, 2)}\n`);
|
|
71
|
+
setDrafts({});
|
|
72
|
+
setPending(file.comments);
|
|
73
|
+
} catch {
|
|
74
|
+
// writeProjectFile surfaces save failures through the studio save banner;
|
|
75
|
+
// just re-arm the button so the user can retry.
|
|
76
|
+
} finally {
|
|
77
|
+
setSubmitState("idle");
|
|
78
|
+
}
|
|
79
|
+
}, [draftCount, submitState, frames, drafts, readOptionalProjectFile, writeProjectFile]);
|
|
80
|
+
|
|
81
|
+
return { drafts, setDraft, draftCount, submitState, submit, pending, refreshPending };
|
|
82
|
+
}
|