@hyperframes/studio 0.7.59 → 0.7.61
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-Xvx2hkrc.js +459 -0
- package/dist/assets/{index-CrkAdJkb.js → index-2mxh_HSy.js} +201 -201
- package/dist/assets/{index-B995FG46.js → index-BCpoiv9S.js} +1 -1
- package/dist/assets/index-BhWig0mx.css +1 -0
- package/dist/assets/{index-FvzmPhfG.js → index-D-uFclFj.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/index.js +1441 -770
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -9
- package/src/components/DesignPanelPromoteProvider.tsx +27 -1
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioRightPanel.tsx +47 -28
- package/src/components/editor/PromotableControl.tsx +4 -2
- package/src/components/editor/PropertyPanel.tsx +151 -149
- package/src/components/editor/PropertyPanelFlat.tsx +44 -42
- package/src/components/editor/manualEditingAvailability.test.ts +6 -6
- package/src/components/editor/manualEditingAvailability.ts +5 -3
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +1 -1
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +10 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +3 -2
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +7 -1
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +21 -9
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +15 -32
- package/src/components/editor/propertyPanelFlatTextSection.tsx +3 -3
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +14 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +9 -1
- package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
- package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
- package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
- package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
- package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
- package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
- package/src/components/storyboard/storyboardReviewStage.ts +45 -0
- package/src/components/storyboard/useFrameComments.ts +22 -6
- package/src/contexts/FileManagerContext.tsx +3 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -0
- package/src/contexts/ViewModeContext.tsx +60 -7
- package/src/hooks/timelineEditingHelpers.ts +2 -2
- package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
- package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
- package/src/hooks/useDomEditCommits.test.tsx +41 -1
- package/src/hooks/useDomEditCommits.ts +2 -2
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useFileManager.projectOwnership.test.tsx +14 -3
- package/src/hooks/useFileManager.ts +76 -12
- package/src/hooks/usePanelLayout.test.ts +118 -0
- package/src/hooks/usePanelLayout.ts +24 -1
- package/src/hooks/usePreviewPersistence.ts +4 -1
- package/src/hooks/useRazorSplit.history.test.tsx +12 -4
- package/src/hooks/useRazorSplit.test.tsx +88 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
- package/src/hooks/useRazorSplit.ts +50 -16
- package/src/hooks/useSlideshowTabState.test.ts +96 -0
- package/src/hooks/useSlideshowTabState.ts +61 -0
- package/src/hooks/useTimelineEditing.ts +2 -0
- package/src/hooks/useTimelineEditingTypes.ts +2 -1
- package/src/hooks/useTimelineGroupEditing.ts +1 -1
- package/src/player/lib/playbackTypes.ts +7 -0
- package/src/player/lib/runtimeProtocol.test.ts +6 -1
- package/src/player/lib/timelineDOM.ts +8 -11
- package/src/player/lib/timelineIframeHelpers.ts +153 -107
- package/src/utils/domEditSaveQueue.test.ts +19 -0
- package/src/utils/domEditSaveQueue.ts +2 -1
- package/src/utils/sdkCutover.test.ts +21 -5
- package/src/utils/sdkEditTransaction.ts +5 -4
- package/src/utils/studioFileHistory.ts +6 -4
- package/src/utils/studioFileVersion.test.ts +36 -0
- package/src/utils/studioFileVersion.ts +23 -0
- package/src/utils/studioSaveDiagnostics.test.ts +18 -0
- package/src/utils/studioSaveDiagnostics.ts +21 -0
- package/dist/assets/hyperframes-player-Bm07FLkl.js +0 -459
- package/dist/assets/index-Dj5p8U_A.css +0 -1
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
|
@@ -3,11 +3,7 @@ import { useEffect, useState } from "react";
|
|
|
3
3
|
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
|
|
4
4
|
import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
|
|
5
5
|
import { BorderRadiusEditor } from "./BorderRadiusEditor";
|
|
6
|
-
import {
|
|
7
|
-
formatStrokeSummary,
|
|
8
|
-
parseStrokeSummary,
|
|
9
|
-
STROKE_STYLE_OPTIONS,
|
|
10
|
-
} from "./propertyPanelFlatStyleHelpers";
|
|
6
|
+
import { STROKE_STYLE_OPTIONS } from "./propertyPanelFlatStyleHelpers";
|
|
11
7
|
import {
|
|
12
8
|
buildBoxShadowPresetValue,
|
|
13
9
|
buildClipPathValue,
|
|
@@ -147,10 +143,9 @@ function FlatFillFields({
|
|
|
147
143
|
}
|
|
148
144
|
|
|
149
145
|
/* ------------------------------------------------------------------ */
|
|
150
|
-
/* Flat Stroke row —
|
|
146
|
+
/* Flat Stroke row — width (numeric), style (select), color */
|
|
151
147
|
/* ------------------------------------------------------------------ */
|
|
152
148
|
|
|
153
|
-
// fallow-ignore-next-line complexity
|
|
154
149
|
function FlatStrokeRow({
|
|
155
150
|
styles,
|
|
156
151
|
disabled,
|
|
@@ -167,52 +162,40 @@ function FlatStrokeRow({
|
|
|
167
162
|
const borderStyleValue = styles["border-style"] || styles["border-top-style"] || "none";
|
|
168
163
|
const borderColorValue =
|
|
169
164
|
styles["border-color"] || styles["border-top-color"] || "rgba(255, 255, 255, 0.18)";
|
|
170
|
-
const
|
|
171
|
-
const tier = resolveValueTier(
|
|
172
|
-
styles["border-width"] != null || styles["border-style"] != null ? summary : undefined,
|
|
173
|
-
formatStrokeSummary(0, "none"),
|
|
174
|
-
);
|
|
165
|
+
const widthDisplay = formatPxMetricValue(borderWidthValue);
|
|
175
166
|
|
|
176
167
|
return (
|
|
177
168
|
<>
|
|
178
169
|
<FlatRow
|
|
179
|
-
label="Stroke"
|
|
180
|
-
value={
|
|
181
|
-
tier={
|
|
170
|
+
label="Stroke width"
|
|
171
|
+
value={widthDisplay}
|
|
172
|
+
tier={resolveValueTier(styles["border-width"], "0px")}
|
|
182
173
|
disabled={disabled}
|
|
183
174
|
onCommit={async (next) => {
|
|
184
|
-
const
|
|
185
|
-
if (!parsed) return;
|
|
186
|
-
if (!STROKE_STYLE_OPTIONS.includes(parsed.style)) return;
|
|
187
|
-
const normalizedWidth = normalizePanelPxValue(`${parsed.widthPx}px`, {
|
|
175
|
+
const normalizedWidth = normalizePanelPxValue(next, {
|
|
188
176
|
min: 0,
|
|
189
177
|
max: 200,
|
|
190
178
|
fallback: borderWidthValue,
|
|
191
179
|
});
|
|
192
180
|
if (!normalizedWidth) return;
|
|
181
|
+
// buildStrokeWidthStyleUpdates already covers "a width typed in
|
|
182
|
+
// from 0 needs a style to actually render a visible border" —
|
|
183
|
+
// it only defaults to solid when the current style is none/hidden,
|
|
184
|
+
// never clobbering an already-chosen style (dashed, dotted, …).
|
|
193
185
|
for (const [property, value] of buildStrokeWidthStyleUpdates(
|
|
194
186
|
normalizedWidth,
|
|
195
|
-
|
|
187
|
+
borderStyleValue,
|
|
196
188
|
)) {
|
|
197
189
|
await onSetStyle(property, value);
|
|
198
190
|
}
|
|
199
|
-
for (const [property, value] of buildStrokeStyleUpdates(parsed.style, normalizedWidth)) {
|
|
200
|
-
await onSetStyle(property, value);
|
|
201
|
-
}
|
|
202
191
|
}}
|
|
203
|
-
suffix={
|
|
204
|
-
<>
|
|
205
|
-
<span
|
|
206
|
-
className="h-4 w-4 flex-shrink-0 rounded border border-panel-border-input"
|
|
207
|
-
style={{ backgroundColor: borderColorValue }}
|
|
208
|
-
/>
|
|
209
|
-
<span className="font-mono text-[10px] text-panel-text-3">{borderColorValue}</span>
|
|
210
|
-
</>
|
|
211
|
-
}
|
|
212
192
|
/>
|
|
213
193
|
<FlatSelectRow
|
|
214
194
|
label="Stroke style"
|
|
215
195
|
value={borderStyleValue}
|
|
196
|
+
// Valid border-style keywords — the ONLY way to set this, since a
|
|
197
|
+
// free-text field here would require typing an exact CSS keyword
|
|
198
|
+
// (e.g. "dashed") with no indication of which ones are valid.
|
|
216
199
|
options={STROKE_STYLE_OPTIONS}
|
|
217
200
|
tier={resolveValueTier(styles["border-style"], "none")}
|
|
218
201
|
disabled={disabled}
|
|
@@ -111,7 +111,7 @@ function FlatTextFieldEditor({
|
|
|
111
111
|
>
|
|
112
112
|
Weight
|
|
113
113
|
</span>
|
|
114
|
-
<label className="flex items-center gap-1.5">
|
|
114
|
+
<label className="flex items-center gap-1.5 border-b border-panel-border-input/50 pb-px hover:border-panel-border-input">
|
|
115
115
|
<select
|
|
116
116
|
value={weight}
|
|
117
117
|
onChange={(e) => {
|
|
@@ -267,7 +267,7 @@ export function FlatTextSection({
|
|
|
267
267
|
|
|
268
268
|
if (textFields.length > 1) {
|
|
269
269
|
return (
|
|
270
|
-
<div className="space-y-
|
|
270
|
+
<div className="space-y-2.5">
|
|
271
271
|
<FlatTextLayerList
|
|
272
272
|
fields={textFields}
|
|
273
273
|
activeFieldKey={activeField.key}
|
|
@@ -295,7 +295,7 @@ export function FlatTextSection({
|
|
|
295
295
|
}
|
|
296
296
|
|
|
297
297
|
return (
|
|
298
|
-
<div className="space-y-
|
|
298
|
+
<div className="space-y-2.5">
|
|
299
299
|
<FlatTextFieldEditor
|
|
300
300
|
field={activeField}
|
|
301
301
|
styles={styles}
|
|
@@ -134,6 +134,13 @@ describe("classic property-panel primitive telemetry", () => {
|
|
|
134
134
|
const input = host.querySelector("input");
|
|
135
135
|
if (!input) throw new Error("expected metric input");
|
|
136
136
|
|
|
137
|
+
// Regression guard for CommitField's shared `align` default: the classic
|
|
138
|
+
// panel lays out label-then-value inline, where left-aligned reads
|
|
139
|
+
// naturally — this must stay left even though the flat inspector's
|
|
140
|
+
// FlatRow now opts into `align="right"` for its own justify-between rows.
|
|
141
|
+
expect(input.className).toContain("text-left");
|
|
142
|
+
expect(input.className).not.toContain("text-right");
|
|
143
|
+
|
|
137
144
|
act(() => blurInput(input));
|
|
138
145
|
expect(trackStudioEvent).not.toHaveBeenCalled();
|
|
139
146
|
|
|
@@ -448,6 +455,13 @@ function representativeElement() {
|
|
|
448
455
|
|
|
449
456
|
describe("classic PropertyPanel input coverage", () => {
|
|
450
457
|
it("emits only named, known-section events across body inputs and header/footer chrome", async () => {
|
|
458
|
+
vi.resetModules();
|
|
459
|
+
vi.doMock("./manualEditingAvailability", async () => {
|
|
460
|
+
const actual = await vi.importActual<typeof import("./manualEditingAvailability")>(
|
|
461
|
+
"./manualEditingAvailability",
|
|
462
|
+
);
|
|
463
|
+
return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: false };
|
|
464
|
+
});
|
|
451
465
|
const { PropertyPanel } = await import("./PropertyPanel");
|
|
452
466
|
const host = render(
|
|
453
467
|
<PropertyPanel
|
|
@@ -9,11 +9,17 @@ export function CommitField({
|
|
|
9
9
|
value,
|
|
10
10
|
disabled,
|
|
11
11
|
liveCommit,
|
|
12
|
+
align = "left",
|
|
12
13
|
onCommit,
|
|
13
14
|
}: {
|
|
14
15
|
value: string;
|
|
15
16
|
disabled?: boolean;
|
|
16
17
|
liveCommit?: boolean;
|
|
18
|
+
/** The legacy panel lays out label-then-value inline (left reads naturally);
|
|
19
|
+
* the flat inspector lays out label…value across a `justify-between` row,
|
|
20
|
+
* where a left-aligned value looks stranded at the edge of its own
|
|
21
|
+
* right-hand box instead of lining up with every other row's value. */
|
|
22
|
+
align?: "left" | "right";
|
|
17
23
|
onCommit: (nextValue: string) => void;
|
|
18
24
|
}) {
|
|
19
25
|
const [draft, setDraft] = useState(value);
|
|
@@ -92,7 +98,9 @@ export function CommitField({
|
|
|
92
98
|
scheduleCommit(nextDraft);
|
|
93
99
|
}}
|
|
94
100
|
title={parseNumericToken(value) ? "Scroll or use Arrow keys to adjust" : undefined}
|
|
95
|
-
className=
|
|
101
|
+
className={`min-w-0 w-full bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600 ${
|
|
102
|
+
align === "right" ? "text-right" : "text-left"
|
|
103
|
+
}`}
|
|
96
104
|
/>
|
|
97
105
|
);
|
|
98
106
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { AgentChatMessageButton } from "./AgentChatMessageButton";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
document.body.innerHTML = "";
|
|
12
|
+
vi.useRealTimers();
|
|
13
|
+
vi.restoreAllMocks();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("AgentChatMessageButton", () => {
|
|
17
|
+
it("reports a successful copy and resets to an explicit re-copy action", async () => {
|
|
18
|
+
vi.useFakeTimers();
|
|
19
|
+
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
20
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
21
|
+
configurable: true,
|
|
22
|
+
value: { writeText },
|
|
23
|
+
});
|
|
24
|
+
const onCopied = vi.fn();
|
|
25
|
+
const host = document.createElement("div");
|
|
26
|
+
document.body.append(host);
|
|
27
|
+
const root = createRoot(host);
|
|
28
|
+
act(() => {
|
|
29
|
+
root.render(<AgentChatMessageButton message="handoff" onCopied={onCopied} />);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const button = host.querySelector("button");
|
|
33
|
+
if (!button) throw new Error("copy button not rendered");
|
|
34
|
+
await act(async () => {
|
|
35
|
+
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
36
|
+
await Promise.resolve();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(writeText).toHaveBeenCalledWith("handoff");
|
|
40
|
+
expect(onCopied).toHaveBeenCalledOnce();
|
|
41
|
+
expect(button.textContent).toBe("Copied — paste in your agent chat");
|
|
42
|
+
|
|
43
|
+
act(() => vi.advanceTimersByTime(3000));
|
|
44
|
+
expect(button.textContent).toBe("Copy again");
|
|
45
|
+
act(() => root.unmount());
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { Button } from "../ui/Button";
|
|
3
|
+
|
|
4
|
+
export const APPLY_STORYBOARD_FEEDBACK_MESSAGE =
|
|
5
|
+
"Read the storyboard feedback I saved in .hyperframes/frame-comments.json and revise the frames.";
|
|
6
|
+
|
|
7
|
+
export function AgentChatMessageButton({
|
|
8
|
+
message,
|
|
9
|
+
label = "Copy prompt for agent",
|
|
10
|
+
onCopied,
|
|
11
|
+
}: {
|
|
12
|
+
message: string;
|
|
13
|
+
label?: string;
|
|
14
|
+
onCopied?: () => void;
|
|
15
|
+
}) {
|
|
16
|
+
const [copyState, setCopyState] = useState<"idle" | "copied" | "again" | "failed">("idle");
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (copyState !== "copied") return;
|
|
20
|
+
const timeout = window.setTimeout(() => setCopyState("again"), 3000);
|
|
21
|
+
return () => window.clearTimeout(timeout);
|
|
22
|
+
}, [copyState]);
|
|
23
|
+
|
|
24
|
+
const copyMessage = async () => {
|
|
25
|
+
try {
|
|
26
|
+
await navigator.clipboard.writeText(message);
|
|
27
|
+
setCopyState("copied");
|
|
28
|
+
onCopied?.();
|
|
29
|
+
} catch {
|
|
30
|
+
setCopyState("failed");
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<Button size="sm" variant="secondary" onClick={() => void copyMessage()}>
|
|
36
|
+
{copyState === "copied"
|
|
37
|
+
? "Copied — paste in your agent chat"
|
|
38
|
+
: copyState === "again"
|
|
39
|
+
? "Copy again"
|
|
40
|
+
: copyState === "failed"
|
|
41
|
+
? "Copy failed"
|
|
42
|
+
: label}
|
|
43
|
+
</Button>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { setFrameVoiceover } from "@hyperframes/core/storyboard";
|
|
3
3
|
import type { StoryboardFrameView } from "../../hooks/useStoryboard";
|
|
4
4
|
import { useFileManagerContext } from "../../contexts/FileManagerContext";
|
|
5
5
|
import { useViewMode } from "../../contexts/ViewModeContext";
|
|
6
6
|
import { Button } from "../ui/Button";
|
|
7
7
|
import { FramePoster, posterTime } from "./FramePoster";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
AgentChatMessageButton,
|
|
10
|
+
APPLY_STORYBOARD_FEEDBACK_MESSAGE,
|
|
11
|
+
} from "./AgentChatMessageButton";
|
|
12
|
+
import { FRAME_STATUS_META } from "./frameStatus";
|
|
13
|
+
import type { CommentsSubmitState } from "./useFrameComments";
|
|
9
14
|
|
|
10
15
|
export interface StoryboardFrameFocusProps {
|
|
11
16
|
projectId: string;
|
|
@@ -19,13 +24,26 @@ export interface StoryboardFrameFocusProps {
|
|
|
19
24
|
onSaved: () => void;
|
|
20
25
|
/** Select a composition in the timeline (sets active comp + editing file + sidebar highlight). */
|
|
21
26
|
onSelectComposition: (path: string) => void;
|
|
27
|
+
/** Whether SCRIPT.md exists and owns final narration/TTS. */
|
|
28
|
+
scriptExists: boolean;
|
|
29
|
+
/** Shared board draft for this frame, preserved when entering/leaving focus. */
|
|
30
|
+
commentDraft: string;
|
|
31
|
+
onCommentDraftChange: (text: string) => void;
|
|
32
|
+
pendingComment: string | null;
|
|
33
|
+
pendingCommentCount: number;
|
|
34
|
+
commentDraftCount: number;
|
|
35
|
+
commentsSubmitState: CommentsSubmitState;
|
|
36
|
+
commentsSubmitError: string | null;
|
|
37
|
+
feedbackMessageCopied: boolean;
|
|
38
|
+
onFeedbackMessageCopied: () => void;
|
|
39
|
+
onSaveFeedback: () => void;
|
|
22
40
|
/** Project signature the board was loaded with (busts the poster cache). */
|
|
23
41
|
posterVersion?: string;
|
|
24
42
|
}
|
|
25
43
|
|
|
26
44
|
/**
|
|
27
45
|
* Full-area focus on a single frame: large poster, editable voiceover guide,
|
|
28
|
-
*
|
|
46
|
+
* review feedback, full narrative, and a jump into the live preview. Edits
|
|
29
47
|
* are written back to STORYBOARD.md in place (markdown stays canonical).
|
|
30
48
|
*
|
|
31
49
|
* Mounted with a `key` per frame, so `draft` initializes from the frame and a
|
|
@@ -41,25 +59,39 @@ export function StoryboardFrameFocus({
|
|
|
41
59
|
onNavigate,
|
|
42
60
|
onSaved,
|
|
43
61
|
onSelectComposition,
|
|
62
|
+
scriptExists,
|
|
63
|
+
commentDraft,
|
|
64
|
+
onCommentDraftChange,
|
|
65
|
+
pendingComment,
|
|
66
|
+
pendingCommentCount,
|
|
67
|
+
commentDraftCount,
|
|
68
|
+
commentsSubmitState,
|
|
69
|
+
commentsSubmitError,
|
|
70
|
+
feedbackMessageCopied,
|
|
71
|
+
onFeedbackMessageCopied,
|
|
72
|
+
onSaveFeedback,
|
|
44
73
|
posterVersion,
|
|
45
74
|
}: StoryboardFrameFocusProps) {
|
|
46
75
|
const { readProjectFile, writeProjectFile } = useFileManagerContext();
|
|
47
|
-
const { setViewMode } = useViewMode();
|
|
76
|
+
const { setViewMode, registerViewModeGuard } = useViewMode();
|
|
48
77
|
const [draft, setDraft] = useState(frame.voiceover ?? "");
|
|
78
|
+
const [savedVoiceover, setSavedVoiceover] = useState(frame.voiceover ?? "");
|
|
49
79
|
const [busy, setBusy] = useState(false);
|
|
50
80
|
const [error, setError] = useState<string | null>(null);
|
|
51
81
|
|
|
52
82
|
const applyEdit = useCallback(
|
|
53
83
|
async (edit: (source: string) => string) => {
|
|
54
|
-
if (busy) return; // one read-modify-write at a time; avoids a lost update
|
|
84
|
+
if (busy) return false; // one read-modify-write at a time; avoids a lost update
|
|
55
85
|
setBusy(true);
|
|
56
86
|
setError(null);
|
|
57
87
|
try {
|
|
58
88
|
const source = await readProjectFile(storyboardPath);
|
|
59
89
|
await writeProjectFile(storyboardPath, edit(source));
|
|
60
90
|
onSaved();
|
|
91
|
+
return true;
|
|
61
92
|
} catch (err: unknown) {
|
|
62
93
|
setError(err instanceof Error ? err.message : "failed to save");
|
|
94
|
+
return false;
|
|
63
95
|
} finally {
|
|
64
96
|
setBusy(false);
|
|
65
97
|
}
|
|
@@ -68,11 +100,12 @@ export function StoryboardFrameFocus({
|
|
|
68
100
|
);
|
|
69
101
|
|
|
70
102
|
const title = frame.title ?? `Frame ${frame.index}`;
|
|
71
|
-
const dirty = draft !==
|
|
72
|
-
const canOpenPreview = frame.srcExists && Boolean(frame.src);
|
|
103
|
+
const dirty = draft !== savedVoiceover;
|
|
104
|
+
const canOpenPreview = frame.status !== "outline" && frame.srcExists && Boolean(frame.src);
|
|
73
105
|
|
|
74
|
-
const saveVoiceover = useCallback(() => {
|
|
75
|
-
|
|
106
|
+
const saveVoiceover = useCallback(async () => {
|
|
107
|
+
const saved = await applyEdit((src) => setFrameVoiceover(src, frame.index, draft));
|
|
108
|
+
if (saved) setSavedVoiceover(draft);
|
|
76
109
|
}, [applyEdit, frame.index, draft]);
|
|
77
110
|
|
|
78
111
|
// Closing the tab with a dirty voiceover would lose it silently — same
|
|
@@ -90,7 +123,12 @@ export function StoryboardFrameFocus({
|
|
|
90
123
|
// dirty. An in-flight save does NOT count as safe: if it fails after unmount
|
|
91
124
|
// the error lands on an unmounted component and the draft is silently lost,
|
|
92
125
|
// so keep confirming until the save actually lands (dirty clears on success).
|
|
93
|
-
const confirmLeave = (
|
|
126
|
+
const confirmLeave = useCallback(
|
|
127
|
+
() => !dirty || window.confirm("Discard unsaved voiceover changes?"),
|
|
128
|
+
[dirty],
|
|
129
|
+
);
|
|
130
|
+
useEffect(() => registerViewModeGuard(confirmLeave), [confirmLeave, registerViewModeGuard]);
|
|
131
|
+
|
|
94
132
|
const handleBack = () => {
|
|
95
133
|
if (confirmLeave()) onBack();
|
|
96
134
|
};
|
|
@@ -113,12 +151,12 @@ export function StoryboardFrameFocus({
|
|
|
113
151
|
});
|
|
114
152
|
|
|
115
153
|
const openInPreview = () => {
|
|
154
|
+
if (!setViewMode("timeline")) return;
|
|
116
155
|
if (frame.src) onSelectComposition(frame.src);
|
|
117
|
-
setViewMode("timeline");
|
|
118
156
|
};
|
|
119
157
|
|
|
120
158
|
return (
|
|
121
|
-
<div className="flex flex-1 min-h-0 flex-col bg-neutral-950 text-neutral-200">
|
|
159
|
+
<div className="flex w-full max-w-[100vw] flex-1 min-h-0 min-w-0 flex-col overflow-hidden bg-neutral-950 text-neutral-200">
|
|
122
160
|
<div className="flex items-center gap-3 border-b border-neutral-800 px-4 py-2">
|
|
123
161
|
<button
|
|
124
162
|
type="button"
|
|
@@ -127,10 +165,10 @@ export function StoryboardFrameFocus({
|
|
|
127
165
|
>
|
|
128
166
|
← Board
|
|
129
167
|
</button>
|
|
130
|
-
<span className="text-sm font-medium text-neutral-200">
|
|
168
|
+
<span className="min-w-0 flex-1 truncate text-sm font-medium text-neutral-200">
|
|
131
169
|
Frame {frame.number ?? frame.index} — {title}
|
|
132
170
|
</span>
|
|
133
|
-
<div className="
|
|
171
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
134
172
|
<NavButton
|
|
135
173
|
label="‹ Prev"
|
|
136
174
|
disabled={frame.index <= 1}
|
|
@@ -144,8 +182,41 @@ export function StoryboardFrameFocus({
|
|
|
144
182
|
</div>
|
|
145
183
|
</div>
|
|
146
184
|
|
|
147
|
-
|
|
148
|
-
<div className="flex
|
|
185
|
+
{(commentDraftCount > 0 || pendingCommentCount > 0) && (
|
|
186
|
+
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-neutral-800 bg-neutral-900/80 px-4 py-2">
|
|
187
|
+
<div>
|
|
188
|
+
<div className="text-xs font-medium text-neutral-200">
|
|
189
|
+
{commentDraftCount > 0 ? "Feedback ready to save" : "Feedback saved"}
|
|
190
|
+
</div>
|
|
191
|
+
<div className="text-[11px] text-neutral-500">
|
|
192
|
+
{commentDraftCount > 0
|
|
193
|
+
? "Save this batch and copy the message for your agent."
|
|
194
|
+
: feedbackMessageCopied
|
|
195
|
+
? "Message copied — paste it in your terminal or IDE agent chat."
|
|
196
|
+
: "The agent has not been notified yet."}
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
{commentDraftCount > 0 ? (
|
|
200
|
+
<Button
|
|
201
|
+
size="sm"
|
|
202
|
+
variant="primary"
|
|
203
|
+
onClick={onSaveFeedback}
|
|
204
|
+
loading={commentsSubmitState === "saving"}
|
|
205
|
+
>
|
|
206
|
+
Save & copy message ({commentDraftCount})
|
|
207
|
+
</Button>
|
|
208
|
+
) : (
|
|
209
|
+
<AgentChatMessageButton
|
|
210
|
+
message={APPLY_STORYBOARD_FEEDBACK_MESSAGE}
|
|
211
|
+
label={feedbackMessageCopied ? "Copy again" : "Copy prompt for agent"}
|
|
212
|
+
onCopied={onFeedbackMessageCopied}
|
|
213
|
+
/>
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
|
|
218
|
+
<div className="flex flex-1 min-h-0 flex-col overflow-auto lg:flex-row lg:overflow-hidden">
|
|
219
|
+
<div className="flex w-full shrink-0 items-center justify-center bg-neutral-900/40 p-4 sm:p-8 lg:h-full lg:w-3/5">
|
|
149
220
|
<div className="aspect-video w-full max-w-[900px] overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900">
|
|
150
221
|
{canOpenPreview && frame.src ? (
|
|
151
222
|
<FramePoster
|
|
@@ -157,19 +228,13 @@ export function StoryboardFrameFocus({
|
|
|
157
228
|
posterVersion={posterVersion}
|
|
158
229
|
/>
|
|
159
230
|
) : (
|
|
160
|
-
<
|
|
161
|
-
{frame.status === "outline" ? "Not built yet" : "No preview"}
|
|
162
|
-
</div>
|
|
231
|
+
<FramePlan frame={frame} />
|
|
163
232
|
)}
|
|
164
233
|
</div>
|
|
165
234
|
</div>
|
|
166
235
|
|
|
167
|
-
<div className="w-
|
|
168
|
-
<
|
|
169
|
-
status={frame.status}
|
|
170
|
-
busy={busy}
|
|
171
|
-
onSet={(s) => applyEdit((src) => setFrameStatus(src, frame.index, s))}
|
|
172
|
-
/>
|
|
236
|
+
<div className="w-full shrink-0 space-y-6 border-t border-neutral-800 px-4 py-5 sm:px-6 lg:h-full lg:w-2/5 lg:overflow-auto lg:border-t-0 lg:border-l">
|
|
237
|
+
<ReadOnlyStatus status={frame.status} />
|
|
173
238
|
|
|
174
239
|
<div className="flex flex-wrap gap-x-6 gap-y-1 text-[11px] text-neutral-500">
|
|
175
240
|
{frame.duration && <span>Duration {frame.duration}</span>}
|
|
@@ -178,7 +243,10 @@ export function StoryboardFrameFocus({
|
|
|
178
243
|
|
|
179
244
|
<section>
|
|
180
245
|
<div className="mb-1 flex items-center justify-between">
|
|
181
|
-
<h3
|
|
246
|
+
<h3
|
|
247
|
+
className="text-xs font-semibold uppercase tracking-wider text-neutral-400"
|
|
248
|
+
title="Storyboard voiceover is a guide; SCRIPT.md is the final TTS source."
|
|
249
|
+
>
|
|
182
250
|
🎙 Voiceover <span className="font-normal normal-case text-neutral-600">guide</span>
|
|
183
251
|
</h3>
|
|
184
252
|
<Button
|
|
@@ -187,31 +255,70 @@ export function StoryboardFrameFocus({
|
|
|
187
255
|
onClick={saveVoiceover}
|
|
188
256
|
disabled={!dirty}
|
|
189
257
|
loading={busy}
|
|
190
|
-
className="bg-emerald-600 text-white enabled:hover:bg-emerald-500 shadow-none"
|
|
191
258
|
>
|
|
192
|
-
{busy ? "Saving…" : "Save"}
|
|
259
|
+
{busy ? "Saving…" : "Save voiceover"}
|
|
193
260
|
</Button>
|
|
194
261
|
</div>
|
|
195
262
|
<textarea
|
|
196
263
|
value={draft}
|
|
197
264
|
onChange={(e) => setDraft(e.target.value)}
|
|
198
|
-
onBlur={() => {
|
|
199
|
-
// Same autosave paradigm as the status row above — mixed save
|
|
200
|
-
// models inside one panel taught users the panel autosaves,
|
|
201
|
-
// then lost their voiceover. Explicit Save stays as the
|
|
202
|
-
// affordance; blur is the safety net.
|
|
203
|
-
if (dirty && !busy) void saveVoiceover();
|
|
204
|
-
}}
|
|
205
265
|
rows={3}
|
|
206
266
|
placeholder="What the narrator says over this frame…"
|
|
207
267
|
className="w-full resize-y rounded border border-neutral-800 bg-neutral-900 p-2 text-sm text-neutral-200 outline-none focus:border-neutral-600"
|
|
208
268
|
/>
|
|
209
269
|
<p className="mt-1 text-[11px] text-neutral-600">
|
|
210
|
-
|
|
270
|
+
{dirty
|
|
271
|
+
? "Unsaved changes"
|
|
272
|
+
: scriptExists
|
|
273
|
+
? "Saved. SCRIPT.md drives final TTS."
|
|
274
|
+
: "Saved to STORYBOARD.md. This voiceover guides narration for the frame."}
|
|
211
275
|
</p>
|
|
212
276
|
{error && <p className="mt-1 text-[11px] text-red-400">{error}</p>}
|
|
213
277
|
</section>
|
|
214
278
|
|
|
279
|
+
<section>
|
|
280
|
+
<div className="mb-1">
|
|
281
|
+
<h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
|
282
|
+
Frame feedback
|
|
283
|
+
</h3>
|
|
284
|
+
</div>
|
|
285
|
+
<textarea
|
|
286
|
+
value={commentDraft}
|
|
287
|
+
onChange={(e) => onCommentDraftChange(e.target.value)}
|
|
288
|
+
rows={3}
|
|
289
|
+
placeholder="Tell your agent what to change in this frame…"
|
|
290
|
+
aria-label={`Comment on ${title}`}
|
|
291
|
+
className="w-full resize-y rounded border border-neutral-800 bg-neutral-900 p-2 text-sm text-neutral-200 placeholder:text-neutral-600 outline-none focus:border-sky-700"
|
|
292
|
+
/>
|
|
293
|
+
{pendingCommentCount > 0 ? (
|
|
294
|
+
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 rounded-md border border-sky-900/70 bg-sky-950/20 px-2.5 py-2">
|
|
295
|
+
<p className="text-[11px] text-sky-200">
|
|
296
|
+
Paste the agent prompt in your terminal or IDE chat.
|
|
297
|
+
</p>
|
|
298
|
+
<AgentChatMessageButton
|
|
299
|
+
message={APPLY_STORYBOARD_FEEDBACK_MESSAGE}
|
|
300
|
+
onCopied={onFeedbackMessageCopied}
|
|
301
|
+
/>
|
|
302
|
+
</div>
|
|
303
|
+
) : (
|
|
304
|
+
<p className="mt-1 text-[11px] text-neutral-600">
|
|
305
|
+
{commentDraftCount > 0
|
|
306
|
+
? "Your change is ready. Save it using the review bar above."
|
|
307
|
+
: "Add a change to prepare feedback for the agent."}
|
|
308
|
+
</p>
|
|
309
|
+
)}
|
|
310
|
+
{pendingComment && (
|
|
311
|
+
<p className="mt-1 text-[11px] text-sky-400/90">
|
|
312
|
+
<span className="font-medium">Pending:</span> “{pendingComment}”
|
|
313
|
+
</p>
|
|
314
|
+
)}
|
|
315
|
+
{commentsSubmitError && (
|
|
316
|
+
<p className="mt-1 text-[11px] text-red-400">
|
|
317
|
+
Couldn’t submit: {commentsSubmitError}
|
|
318
|
+
</p>
|
|
319
|
+
)}
|
|
320
|
+
</section>
|
|
321
|
+
|
|
215
322
|
{frame.narrative && (
|
|
216
323
|
<section>
|
|
217
324
|
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
|
@@ -221,9 +328,15 @@ export function StoryboardFrameFocus({
|
|
|
221
328
|
</section>
|
|
222
329
|
)}
|
|
223
330
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
331
|
+
{canOpenPreview ? (
|
|
332
|
+
<Button size="sm" variant="secondary" onClick={openInPreview}>
|
|
333
|
+
Open in Preview →
|
|
334
|
+
</Button>
|
|
335
|
+
) : (
|
|
336
|
+
<div className="rounded-md border border-neutral-800 bg-neutral-900/60 px-3 py-2 text-xs text-neutral-500">
|
|
337
|
+
Preview becomes available after your agent builds this frame.
|
|
338
|
+
</div>
|
|
339
|
+
)}
|
|
227
340
|
</div>
|
|
228
341
|
</div>
|
|
229
342
|
</div>
|
|
@@ -251,38 +364,48 @@ function NavButton({
|
|
|
251
364
|
);
|
|
252
365
|
}
|
|
253
366
|
|
|
254
|
-
function
|
|
255
|
-
status
|
|
256
|
-
busy,
|
|
257
|
-
onSet,
|
|
258
|
-
}: {
|
|
259
|
-
status: FrameStatus;
|
|
260
|
-
busy: boolean;
|
|
261
|
-
onSet: (next: FrameStatus) => void;
|
|
262
|
-
}) {
|
|
367
|
+
function ReadOnlyStatus({ status }: { status: StoryboardFrameView["status"] }) {
|
|
368
|
+
const meta = FRAME_STATUS_META[status];
|
|
263
369
|
return (
|
|
264
370
|
<div className="flex items-center gap-2">
|
|
265
371
|
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-500">
|
|
266
372
|
Status
|
|
267
373
|
</span>
|
|
268
|
-
<
|
|
269
|
-
{
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
374
|
+
<span className={`rounded px-2 py-1 text-xs font-medium ${meta.chipClass}`}>
|
|
375
|
+
{meta.label}
|
|
376
|
+
</span>
|
|
377
|
+
<span className="text-[11px] text-neutral-600">Updated by your agent</span>
|
|
378
|
+
</div>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// The empty state selects copy from frame status and whichever storyboard fields are available.
|
|
383
|
+
// fallow-ignore-next-line complexity
|
|
384
|
+
function FramePlan({ frame }: { frame: StoryboardFrameView }) {
|
|
385
|
+
const title = frame.title ?? `Frame ${frame.index}`;
|
|
386
|
+
const isOutline = frame.status === "outline";
|
|
387
|
+
return (
|
|
388
|
+
<div className="flex h-full w-full items-center justify-center bg-[radial-gradient(circle_at_center,_rgba(38,38,38,0.8),_rgba(10,10,10,1))] p-10">
|
|
389
|
+
<div className="max-w-xl text-center">
|
|
390
|
+
<span className="rounded-full border border-neutral-700 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider text-neutral-400">
|
|
391
|
+
{isOutline ? "Planned frame" : "Preview unavailable"}
|
|
392
|
+
</span>
|
|
393
|
+
<h2 className="mt-4 text-2xl font-semibold text-neutral-100">{title}</h2>
|
|
394
|
+
{frame.scene && (
|
|
395
|
+
<p className="mt-3 text-base leading-relaxed text-neutral-300">{frame.scene}</p>
|
|
396
|
+
)}
|
|
397
|
+
{!frame.scene && frame.narrative && (
|
|
398
|
+
<p className="mt-3 line-clamp-4 text-sm leading-relaxed text-neutral-400">
|
|
399
|
+
{frame.narrative}
|
|
400
|
+
</p>
|
|
401
|
+
)}
|
|
402
|
+
<p className="mt-5 text-xs text-neutral-600">
|
|
403
|
+
{isOutline
|
|
404
|
+
? "A visual preview will appear when your agent builds the sketch."
|
|
405
|
+
: frame.src
|
|
406
|
+
? `Frame file not found: ${frame.src}`
|
|
407
|
+
: "This frame does not link to a source file."}
|
|
408
|
+
</p>
|
|
286
409
|
</div>
|
|
287
410
|
</div>
|
|
288
411
|
);
|