@hyperframes/studio 0.7.60 → 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.
Files changed (47) hide show
  1. package/dist/assets/hyperframes-player-Xvx2hkrc.js +459 -0
  2. package/dist/assets/{index-cH6NfVV_.js → index-2mxh_HSy.js} +177 -177
  3. package/dist/assets/{index-D6etaey-.js → index-BCpoiv9S.js} +1 -1
  4. package/dist/assets/index-BhWig0mx.css +1 -0
  5. package/dist/assets/{index-Dh_WhagG.js → index-D-uFclFj.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +1202 -673
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +3 -8
  11. package/src/components/DesignPanelPromoteProvider.tsx +27 -1
  12. package/src/components/StudioHeader.tsx +2 -3
  13. package/src/components/StudioRightPanel.tsx +34 -26
  14. package/src/components/editor/PromotableControl.tsx +4 -2
  15. package/src/components/editor/PropertyPanel.tsx +151 -149
  16. package/src/components/editor/PropertyPanelFlat.tsx +44 -42
  17. package/src/components/editor/manualEditingAvailability.test.ts +6 -6
  18. package/src/components/editor/manualEditingAvailability.ts +5 -3
  19. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +10 -0
  20. package/src/components/editor/propertyPanelFlatPrimitives.tsx +1 -0
  21. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
  22. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +21 -9
  23. package/src/components/editor/propertyPanelFlatStyleSections.tsx +15 -32
  24. package/src/components/editor/propertyPanelFlatTextSection.tsx +2 -2
  25. package/src/components/editor/propertyPanelInputCoverage.test.tsx +14 -0
  26. package/src/components/editor/propertyPanelPrimitives.tsx +9 -1
  27. package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
  28. package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
  29. package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
  30. package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
  31. package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
  32. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
  33. package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
  34. package/src/components/storyboard/storyboardReviewStage.ts +45 -0
  35. package/src/components/storyboard/useFrameComments.ts +22 -6
  36. package/src/contexts/ViewModeContext.tsx +60 -7
  37. package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
  38. package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
  39. package/src/hooks/useSlideshowTabState.test.ts +96 -0
  40. package/src/hooks/useSlideshowTabState.ts +61 -0
  41. package/src/player/lib/playbackTypes.ts +7 -0
  42. package/src/player/lib/runtimeProtocol.test.ts +6 -1
  43. package/src/player/lib/timelineDOM.ts +8 -11
  44. package/src/player/lib/timelineIframeHelpers.ts +153 -107
  45. package/dist/assets/hyperframes-player-3XTTaVNf.js +0 -459
  46. package/dist/assets/index-DXbu6IPT.css +0 -1
  47. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
@@ -4,6 +4,7 @@ import {
4
4
  useContext,
5
5
  useEffect,
6
6
  useMemo,
7
+ useRef,
7
8
  useState,
8
9
  type ReactNode,
9
10
  } from "react";
@@ -17,6 +18,7 @@ import {
17
18
  * user straight into the storyboard by navigating the tab to `?view=storyboard`.
18
19
  */
19
20
  export type StudioViewMode = "timeline" | "storyboard";
21
+ export type ViewModeGuard = (nextMode: StudioViewMode) => boolean;
20
22
 
21
23
  const VIEW_QUERY_PARAM = "view";
22
24
 
@@ -38,9 +40,24 @@ function writeViewModeToUrl(mode: StudioViewMode): void {
38
40
  window.history.replaceState(window.history.state, "", url);
39
41
  }
40
42
 
43
+ interface HistoryLocation {
44
+ href: string;
45
+ state: unknown;
46
+ }
47
+
48
+ function readHistoryLocation(): HistoryLocation | null {
49
+ if (typeof window === "undefined") return null;
50
+ return {
51
+ href: window.location.href,
52
+ state: window.history.state,
53
+ };
54
+ }
55
+
41
56
  export interface ViewModeValue {
42
57
  viewMode: StudioViewMode;
43
- setViewMode: (mode: StudioViewMode) => void;
58
+ /** Returns false when an active editor vetoes the transition. */
59
+ setViewMode: (mode: StudioViewMode) => boolean;
60
+ registerViewModeGuard: (guard: ViewModeGuard) => () => void;
44
61
  }
45
62
 
46
63
  /**
@@ -49,6 +66,15 @@ export interface ViewModeValue {
49
66
  */
50
67
  export function useViewModeState(): ViewModeValue {
51
68
  const [viewMode, setMode] = useState<StudioViewMode>(() => readViewModeFromUrl());
69
+ const guardsRef = useRef(new Set<ViewModeGuard>());
70
+ const acceptedLocationRef = useRef<HistoryLocation | null>(readHistoryLocation());
71
+
72
+ const canSetViewMode = useCallback((mode: StudioViewMode) => {
73
+ for (const guard of guardsRef.current) {
74
+ if (!guard(mode)) return false;
75
+ }
76
+ return true;
77
+ }, []);
52
78
 
53
79
  // Reflect genuine browser back/forward between history entries with a different
54
80
  // `?view=`. Note: our own writes use `replaceState` (below), which does NOT fire
@@ -57,17 +83,44 @@ export function useViewModeState(): ViewModeValue {
57
83
  // the mount-time read); a scripted `pushState`/`replaceState` to `?view=` would not be
58
84
  // reflected here, by design.
59
85
  useEffect(() => {
60
- const onPopState = () => setMode(readViewModeFromUrl());
86
+ const onPopState = () => {
87
+ const mode = readViewModeFromUrl();
88
+ if (mode !== viewMode && !canSetViewMode(mode)) {
89
+ const acceptedLocation = acceptedLocationRef.current;
90
+ if (acceptedLocation) {
91
+ window.history.pushState(acceptedLocation.state, "", acceptedLocation.href);
92
+ }
93
+ return;
94
+ }
95
+ setMode(mode);
96
+ acceptedLocationRef.current = readHistoryLocation();
97
+ };
61
98
  window.addEventListener("popstate", onPopState);
62
99
  return () => window.removeEventListener("popstate", onPopState);
63
- }, []);
100
+ }, [canSetViewMode, viewMode]);
101
+
102
+ const setViewMode = useCallback(
103
+ (mode: StudioViewMode) => {
104
+ if (!canSetViewMode(mode)) return false;
105
+ setMode(mode);
106
+ writeViewModeToUrl(mode);
107
+ acceptedLocationRef.current = readHistoryLocation();
108
+ return true;
109
+ },
110
+ [canSetViewMode],
111
+ );
64
112
 
65
- const setViewMode = useCallback((mode: StudioViewMode) => {
66
- setMode(mode);
67
- writeViewModeToUrl(mode);
113
+ const registerViewModeGuard = useCallback((guard: ViewModeGuard) => {
114
+ guardsRef.current.add(guard);
115
+ return () => {
116
+ guardsRef.current.delete(guard);
117
+ };
68
118
  }, []);
69
119
 
70
- return useMemo(() => ({ viewMode, setViewMode }), [viewMode, setViewMode]);
120
+ return useMemo(
121
+ () => ({ viewMode, setViewMode, registerViewModeGuard }),
122
+ [viewMode, setViewMode, registerViewModeGuard],
123
+ );
71
124
  }
72
125
 
73
126
  const ViewModeContext = createContext<ViewModeValue | null>(null);
@@ -0,0 +1,44 @@
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 { usePlayerStore } from "../player";
7
+ import { useAddAssetAtPlayhead } from "./useAddAssetAtPlayhead";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ afterEach(() => {
12
+ document.body.innerHTML = "";
13
+ });
14
+
15
+ describe("useAddAssetAtPlayhead", () => {
16
+ it("drops the asset on track 0 at the current playhead time", () => {
17
+ usePlayerStore.getState().setCurrentTime(4.5);
18
+ const handleTimelineAssetDrop = vi.fn();
19
+ let addAsset: (assetPath: string) => unknown = () => undefined;
20
+
21
+ function Harness() {
22
+ addAsset = useAddAssetAtPlayhead(handleTimelineAssetDrop);
23
+ return null;
24
+ }
25
+
26
+ const host = document.createElement("div");
27
+ document.body.append(host);
28
+ const root = createRoot(host);
29
+ act(() => {
30
+ root.render(React.createElement(Harness));
31
+ });
32
+
33
+ act(() => {
34
+ addAsset("assets/clip.mp4");
35
+ });
36
+
37
+ expect(handleTimelineAssetDrop).toHaveBeenCalledWith("assets/clip.mp4", {
38
+ start: 4.5,
39
+ track: 0,
40
+ });
41
+
42
+ act(() => root.unmount());
43
+ });
44
+ });
@@ -0,0 +1,21 @@
1
+ import { useCallback } from "react";
2
+ import type { TimelineElement } from "../player";
3
+ import { usePlayerStore } from "../player";
4
+
5
+ /** Drops an asset onto track 0 at the current playhead time. */
6
+ export function useAddAssetAtPlayhead(
7
+ handleTimelineAssetDrop: (
8
+ assetPath: string,
9
+ placement: Pick<TimelineElement, "start" | "track">,
10
+ durationOverride?: number,
11
+ ) => unknown,
12
+ ) {
13
+ return useCallback(
14
+ (assetPath: string) =>
15
+ handleTimelineAssetDrop(assetPath, {
16
+ start: usePlayerStore.getState().currentTime,
17
+ track: 0,
18
+ }),
19
+ [handleTimelineAssetDrop],
20
+ );
21
+ }
@@ -0,0 +1,96 @@
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 } from "vitest";
6
+ import { useSlideshowTabState } from "./useSlideshowTabState";
7
+ import type { RightPanelTab } from "../utils/studioHelpers";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ const SLIDESHOW_HTML = `<html><body><script type="application/hyperframes-slideshow+json">{"slides":[]}</script></body></html>`;
12
+ const PLAIN_HTML = `<html><body><div id="title">hi</div></body></html>`;
13
+
14
+ afterEach(() => {
15
+ document.body.innerHTML = "";
16
+ });
17
+
18
+ function renderHook(params: {
19
+ editingFileContent: string | null | undefined;
20
+ rightPanelTab: RightPanelTab;
21
+ }) {
22
+ const host = document.createElement("div");
23
+ document.body.append(host);
24
+ const root = createRoot(host);
25
+ const setRightPanelTabCalls: RightPanelTab[] = [];
26
+ let current: ReturnType<typeof useSlideshowTabState> | null = null;
27
+
28
+ function Harness() {
29
+ current = useSlideshowTabState({
30
+ editingFileContent: params.editingFileContent,
31
+ previewIframeRef: { current: null },
32
+ refreshKey: 0,
33
+ rightPanelTab: params.rightPanelTab,
34
+ setRightPanelTab: (tab) => setRightPanelTabCalls.push(tab),
35
+ });
36
+ return null;
37
+ }
38
+
39
+ act(() => {
40
+ root.render(React.createElement(Harness));
41
+ });
42
+
43
+ return {
44
+ getState: (): ReturnType<typeof useSlideshowTabState> => {
45
+ if (!current) throw new Error("useSlideshowTabState did not render");
46
+ return current;
47
+ },
48
+ setRightPanelTabCalls,
49
+ unmount: () => act(() => root.unmount()),
50
+ };
51
+ }
52
+
53
+ describe("useSlideshowTabState", () => {
54
+ it("detects a slideshow composition via the JSON island", () => {
55
+ const harness = renderHook({ editingFileContent: SLIDESHOW_HTML, rightPanelTab: "design" });
56
+ expect(harness.getState().isSlideshowComposition).toBe(true);
57
+ harness.unmount();
58
+ });
59
+
60
+ it("reports false for a plain (non-slideshow) composition", () => {
61
+ const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "design" });
62
+ expect(harness.getState().isSlideshowComposition).toBe(false);
63
+ harness.unmount();
64
+ });
65
+
66
+ it("reports false when there is no editing file yet", () => {
67
+ const harness = renderHook({ editingFileContent: undefined, rightPanelTab: "design" });
68
+ expect(harness.getState().isSlideshowComposition).toBe(false);
69
+ harness.unmount();
70
+ });
71
+
72
+ it("still detects a malformed island — presence-only, not full manifest validation", () => {
73
+ const malformed = `<html><body><script type="application/hyperframes-slideshow+json">{not valid json</script></body></html>`;
74
+ const harness = renderHook({ editingFileContent: malformed, rightPanelTab: "design" });
75
+ expect(harness.getState().isSlideshowComposition).toBe(true);
76
+ harness.unmount();
77
+ });
78
+
79
+ it("bounces rightPanelTab off 'slideshow' to 'renders' on a non-slideshow composition", () => {
80
+ const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "slideshow" });
81
+ expect(harness.setRightPanelTabCalls).toEqual(["renders"]);
82
+ harness.unmount();
83
+ });
84
+
85
+ it("does not bounce when the composition is a slideshow", () => {
86
+ const harness = renderHook({ editingFileContent: SLIDESHOW_HTML, rightPanelTab: "slideshow" });
87
+ expect(harness.setRightPanelTabCalls).toEqual([]);
88
+ harness.unmount();
89
+ });
90
+
91
+ it("does not bounce a tab other than 'slideshow'", () => {
92
+ const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "renders" });
93
+ expect(harness.setRightPanelTabCalls).toEqual([]);
94
+ harness.unmount();
95
+ });
96
+ });
@@ -0,0 +1,61 @@
1
+ import { useEffect, useMemo, type MutableRefObject } from "react";
2
+ import { SLIDESHOW_ISLAND_TYPE, slideshowIslandRegex } from "@hyperframes/core/slideshow";
3
+ import type { SceneInfo } from "../components/panels/SlideshowPanel";
4
+ import type { IframeWindow } from "../player/lib/playbackTypes";
5
+ import type { RightPanelTab } from "../utils/studioHelpers";
6
+
7
+ /**
8
+ * Derives whether the currently-edited composition is a slideshow (carries
9
+ * the slideshow JSON island — the same definitive marker the CLI's `present`
10
+ * command requires; it refuses to run without one) and the live scene list
11
+ * for the Slideshow panel, and bounces `rightPanelTab` off "slideshow" the
12
+ * moment it stops applying (e.g. the user switches to a non-slideshow file
13
+ * while that tab was open) so the panel never shows a dangling active tab
14
+ * whose button is no longer even rendered.
15
+ *
16
+ * Extracted from StudioRightPanel to keep that file under the 600-LOC gate.
17
+ */
18
+ export function useSlideshowTabState(params: {
19
+ editingFileContent: string | null | undefined;
20
+ previewIframeRef: MutableRefObject<HTMLIFrameElement | null>;
21
+ refreshKey: number;
22
+ rightPanelTab: RightPanelTab;
23
+ setRightPanelTab: (tab: RightPanelTab) => void;
24
+ }): { isSlideshowComposition: boolean; slideshowScenes: SceneInfo[] } {
25
+ const { editingFileContent, previewIframeRef, refreshKey, rightPanelTab, setRightPanelTab } =
26
+ params;
27
+
28
+ // Presence-only (not full manifest validation): a malformed island should
29
+ // still surface the Slideshow tab so the user can see/fix it, rather than
30
+ // making the whole panel disappear. The plain substring check short-circuits
31
+ // the regex scan on every non-slideshow file (the common case) without
32
+ // paying for a full-content RegExp pass.
33
+ const isSlideshowComposition = useMemo(() => {
34
+ if (!editingFileContent || !editingFileContent.includes(SLIDESHOW_ISLAND_TYPE)) return false;
35
+ return slideshowIslandRegex("i").test(editingFileContent);
36
+ }, [editingFileContent]);
37
+
38
+ // Derive scene list from the live clip manifest in the preview iframe.
39
+ const slideshowScenes = useMemo<SceneInfo[]>(() => {
40
+ try {
41
+ const win = previewIframeRef.current?.contentWindow as IframeWindow | null;
42
+ return (win?.__clipManifest?.scenes ?? []).map((s) => ({
43
+ id: s.id,
44
+ label: s.label,
45
+ start: s.start,
46
+ duration: s.duration,
47
+ }));
48
+ } catch {
49
+ return [];
50
+ }
51
+ // eslint-disable-next-line react-hooks/exhaustive-deps
52
+ }, [previewIframeRef, rightPanelTab, refreshKey]);
53
+
54
+ useEffect(() => {
55
+ if (rightPanelTab === "slideshow" && !isSlideshowComposition) {
56
+ setRightPanelTab("renders");
57
+ }
58
+ }, [rightPanelTab, isSlideshowComposition, setRightPanelTab]);
59
+
60
+ return { isSlideshowComposition, slideshowScenes };
61
+ }
@@ -50,9 +50,16 @@ export interface ClipManifestClip {
50
50
  }
51
51
 
52
52
  export interface ClipManifest {
53
+ protocolVersion?: number;
54
+ compositionContractVersion?: number;
55
+ capabilities?: readonly string[];
56
+ fps?: { numerator: number; denominator: number };
57
+ durationSeconds?: number;
53
58
  clips: ClipManifestClip[];
54
59
  scenes: Array<{ id: string; label: string; start: number; duration: number }>;
55
60
  durationInFrames: number;
61
+ compositionWidth?: number;
62
+ compositionHeight?: number;
56
63
  }
57
64
 
58
65
  export type IframeWindow = Window & {
@@ -14,7 +14,12 @@ describe("Studio runtime protocol", () => {
14
14
  type: "control",
15
15
  action: "seek",
16
16
  protocolVersion: 1,
17
- capabilities: ["seconds-time", "rational-fps", "seek-keep-playing"],
17
+ capabilities: [
18
+ "seconds-time",
19
+ "rational-fps",
20
+ "seek-keep-playing",
21
+ "composition-manifest-v1",
22
+ ],
18
23
  fps: { numerator: 60, denominator: 1 },
19
24
  timeSeconds: 1.25,
20
25
  });
@@ -11,6 +11,7 @@
11
11
  import type { TimelineElement } from "../store/playerStore";
12
12
  import type { ClipManifestClip } from "./playbackTypes";
13
13
  import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
14
+ import { readClipTiming } from "@hyperframes/core/composition-contract";
14
15
  import {
15
16
  resolveMediaElement,
16
17
  applyMediaMetadataFromElement,
@@ -255,24 +256,20 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
255
256
  if (node === rootComp) return;
256
257
  if (isTimelineIgnoredElement(node)) return;
257
258
  const el = node as HTMLElement;
258
- const startStr = el.getAttribute("data-start");
259
- if (startStr == null) return;
260
- const start = parseFloat(startStr);
261
- if (isNaN(start)) return;
259
+ const timing = readClipTiming(el);
260
+ const start = timing.start;
261
+ if (start == null) return;
262
262
  if (Number.isFinite(rootDuration) && rootDuration > 0 && start >= rootDuration) return;
263
263
 
264
264
  const tagLower = el.tagName.toLowerCase();
265
- let dur = 0;
266
- const durStr = el.getAttribute("data-duration");
267
- if (durStr != null) dur = parseFloat(durStr);
268
- if (isNaN(dur) || dur <= 0) dur = Math.max(0, rootDuration - start);
265
+ let dur = timing.duration ?? 0;
266
+ if (dur <= 0) dur = Math.max(0, rootDuration - start);
269
267
  if (Number.isFinite(rootDuration) && rootDuration > 0) {
270
268
  dur = Math.min(dur, Math.max(0, rootDuration - start));
271
269
  }
272
270
  if (!Number.isFinite(dur) || dur <= 0) return;
273
271
 
274
- const trackStr = el.getAttribute("data-track-index");
275
- const track = trackStr != null ? parseInt(trackStr, 10) : trackCounter++;
272
+ const track = timing.trackSource === "default" ? trackCounter++ : timing.trackIndex;
276
273
  // fallow-ignore-next-line code-duplication
277
274
  const compId = el.getAttribute("data-composition-id");
278
275
  const selector = getTimelineElementSelector(el);
@@ -299,7 +296,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
299
296
  tag: tagLower,
300
297
  start,
301
298
  duration: dur,
302
- track: isNaN(track) ? 0 : track,
299
+ track,
303
300
  domId: el.id || undefined,
304
301
  hfId: el.getAttribute("data-hf-id") || undefined,
305
302
  selector,