@hyperframes/studio 0.4.12 → 0.4.13-alpha.1

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 (33) hide show
  1. package/dist/assets/hyperframes-player-BOs_kypk.js +198 -0
  2. package/dist/assets/index-BKkR67xb.css +1 -0
  3. package/dist/assets/index-rN5doSq1.js +93 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +289 -11
  7. package/src/components/nle/NLELayout.tsx +24 -7
  8. package/src/components/nle/NLEPreview.test.ts +32 -0
  9. package/src/components/nle/NLEPreview.tsx +12 -1
  10. package/src/player/components/CompositionThumbnail.tsx +94 -17
  11. package/src/player/components/EditModal.tsx +48 -29
  12. package/src/player/components/Player.tsx +5 -2
  13. package/src/player/components/PlayerControls.test.ts +20 -0
  14. package/src/player/components/PlayerControls.tsx +12 -1
  15. package/src/player/components/Timeline.test.ts +44 -1
  16. package/src/player/components/Timeline.tsx +686 -169
  17. package/src/player/components/TimelineClip.tsx +112 -16
  18. package/src/player/components/timelineEditing.test.ts +310 -0
  19. package/src/player/components/timelineEditing.ts +213 -0
  20. package/src/player/components/timelineTheme.test.ts +56 -0
  21. package/src/player/components/timelineTheme.ts +141 -0
  22. package/src/player/components/timelineZoom.test.ts +62 -0
  23. package/src/player/components/timelineZoom.ts +38 -0
  24. package/src/player/hooks/useTimelinePlayer.test.ts +96 -0
  25. package/src/player/hooks/useTimelinePlayer.ts +313 -59
  26. package/src/player/store/playerStore.test.ts +30 -12
  27. package/src/player/store/playerStore.ts +23 -9
  28. package/src/types/hyperframes-player.d.ts +1 -0
  29. package/src/utils/sourcePatcher.test.ts +84 -0
  30. package/src/utils/sourcePatcher.ts +143 -0
  31. package/dist/assets/hyperframes-player-5iD9BZnx.js +0 -198
  32. package/dist/assets/index-CVDXfFQ6.js +0 -93
  33. package/dist/assets/index-jmDaI2F7.css +0 -1
@@ -1,52 +1,129 @@
1
- /**
2
- * CompositionThumbnail Single server-rendered JPEG stretched across the clip.
3
- *
4
- * Takes one screenshot at the midpoint of the clip and covers the full width —
5
- * same approach as After Effects for precomps. This avoids the 1-2s per-frame
6
- * Puppeteer cost of rendering multiple filmstrip frames.
7
- */
8
-
9
- import { memo } from "react";
1
+ import { memo, useCallback, useState, useRef } from "react";
2
+ import { useMountEffect } from "../../hooks/useMountEffect";
10
3
 
11
4
  interface CompositionThumbnailProps {
12
5
  previewUrl: string;
13
6
  label: string;
14
7
  labelColor: string;
8
+ accentColor?: string;
9
+ selector?: string;
15
10
  seekTime?: number;
16
11
  duration?: number;
17
12
  width?: number;
18
13
  height?: number;
19
14
  }
20
15
 
16
+ const CLIP_HEIGHT = 66;
17
+ const THUMBNAIL_URL_VERSION = "v2";
18
+
21
19
  export const CompositionThumbnail = memo(function CompositionThumbnail({
22
20
  previewUrl,
23
21
  label,
24
22
  labelColor,
23
+ accentColor = "#6B7280",
24
+ selector,
25
25
  seekTime = 2,
26
26
  duration = 5,
27
27
  }: CompositionThumbnailProps) {
28
- // Single screenshot at the midpoint of the clip
28
+ const [containerWidth, setContainerWidth] = useState(0);
29
+ const [loaded, setLoaded] = useState(false);
30
+ const [aspect, setAspect] = useState(16 / 9);
31
+ const roRef = useRef<ResizeObserver | null>(null);
32
+
33
+ const setContainerRef = useCallback((el: HTMLDivElement | null) => {
34
+ roRef.current?.disconnect();
35
+ if (!el) return;
36
+
37
+ const measured = el.parentElement?.clientWidth || el.clientWidth;
38
+ setContainerWidth(measured);
39
+
40
+ const target = el.parentElement || el;
41
+ roRef.current = new ResizeObserver(([entry]) => {
42
+ setContainerWidth(entry.contentRect.width);
43
+ });
44
+ roRef.current.observe(target);
45
+ }, []);
46
+
47
+ useMountEffect(() => () => {
48
+ roRef.current?.disconnect();
49
+ });
50
+
29
51
  const thumbnailBase = previewUrl
30
52
  .replace("/preview/comp/", "/thumbnail/")
31
53
  .replace(/\/preview$/, "/thumbnail/index.html");
32
54
  const midTime = seekTime + duration / 2;
33
- const url = `${thumbnailBase}?t=${midTime.toFixed(2)}`;
55
+ const thumbnailUrl = new URL(thumbnailBase, window.location.origin);
56
+ thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
57
+ thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
58
+ if (selector) thumbnailUrl.searchParams.set("selector", selector);
59
+ const url = thumbnailUrl.toString();
60
+ const frameW = Math.max(48, Math.round(CLIP_HEIGHT * aspect));
61
+ const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
34
62
 
35
63
  return (
36
- <div className="absolute inset-0 overflow-hidden bg-neutral-950">
64
+ <div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
37
65
  <img
38
66
  src={url}
39
67
  alt=""
40
68
  draggable={false}
41
69
  loading="lazy"
42
70
  onLoad={(e) => {
43
- (e.target as HTMLImageElement).style.opacity = "1";
71
+ const img = e.currentTarget;
72
+ if (img.naturalWidth > 0 && img.naturalHeight > 0) {
73
+ setAspect(img.naturalWidth / img.naturalHeight);
74
+ }
75
+ setLoaded(true);
44
76
  }}
45
- className="absolute inset-0 w-full h-full object-cover"
46
- style={{ opacity: 0, transition: "opacity 200ms ease-out" }}
77
+ className="hidden"
47
78
  />
48
79
 
49
- {/* Label */}
80
+ {loaded ? (
81
+ <div className="absolute inset-0 flex">
82
+ {Array.from({ length: frameCount }).map((_, i) => (
83
+ <div
84
+ key={i}
85
+ className="relative h-full flex-shrink-0 overflow-hidden"
86
+ style={{ width: frameW }}
87
+ >
88
+ <img
89
+ src={url}
90
+ alt=""
91
+ draggable={false}
92
+ className="absolute inset-0 h-full w-full object-cover opacity-60"
93
+ />
94
+ </div>
95
+ ))}
96
+ </div>
97
+ ) : (
98
+ <div
99
+ className="absolute inset-0 animate-pulse"
100
+ style={{
101
+ background:
102
+ "linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
103
+ }}
104
+ />
105
+ )}
106
+
107
+ <div
108
+ className="absolute inset-0"
109
+ style={{
110
+ background: `linear-gradient(120deg, ${accentColor}2e, transparent 34%), linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0.08))`,
111
+ }}
112
+ />
113
+
114
+ <div className="absolute left-2 top-2 z-10">
115
+ <span
116
+ className="block max-w-full truncate rounded-md px-1.5 py-0.5 text-[9px] font-semibold uppercase leading-none"
117
+ style={{
118
+ color: labelColor,
119
+ background: `${accentColor}2e`,
120
+ boxShadow: `inset 0 0 0 1px ${accentColor}40`,
121
+ }}
122
+ >
123
+ {label}
124
+ </span>
125
+ </div>
126
+
50
127
  <div
51
128
  className="absolute bottom-0 left-0 right-0 z-10 px-1.5 pb-0.5 pt-3"
52
129
  style={{
@@ -55,7 +132,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
55
132
  }}
56
133
  >
57
134
  <span
58
- className="text-[9px] font-semibold truncate block leading-tight"
135
+ className="block truncate text-[9px] font-semibold leading-tight"
59
136
  style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
60
137
  >
61
138
  {label}
@@ -2,6 +2,7 @@ import { useState, useCallback, useMemo, useRef } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { usePlayerStore } from "../store/playerStore";
4
4
  import { formatTime } from "../lib/time";
5
+ import { buildPromptCopyText, buildTimelineAgentPrompt } from "./timelineEditing";
5
6
 
6
7
  interface EditPopoverProps {
7
8
  rangeStart: number;
@@ -14,7 +15,8 @@ interface EditPopoverProps {
14
15
  export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }: EditPopoverProps) {
15
16
  const elements = usePlayerStore((s) => s.elements);
16
17
  const [prompt, setPrompt] = useState("");
17
- const [copied, setCopied] = useState(false);
18
+ const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
19
+ const [copiedPromptOnly, setCopiedPromptOnly] = useState(false);
18
20
  const popoverRef = useRef<HTMLDivElement>(null);
19
21
  const textareaRef = useRef<HTMLTextAreaElement>(null);
20
22
 
@@ -51,27 +53,12 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
51
53
  });
52
54
 
53
55
  const buildClipboardText = useCallback(() => {
54
- const elementLines = elementsInRange
55
- .map(
56
- (el) =>
57
- `- #${el.id} (${el.tag}) — ${formatTime(el.start)} to ${formatTime(el.start + el.duration)}, track ${el.track}`,
58
- )
59
- .join("\n");
60
-
61
- return `Edit the following HyperFrames composition:
62
-
63
- Time range: ${formatTime(start)} — ${formatTime(end)}
64
-
65
- Elements in range:
66
- ${elementLines || "(none)"}
67
-
68
- User request:
69
- ${prompt.trim() || "(no prompt provided)"}
70
-
71
- Instructions:
72
- Modify only the elements listed above within the specified time range.
73
- The composition uses HyperFrames data attributes (data-start, data-duration, data-track-index) and GSAP for animations.
74
- Preserve all other elements and timing outside this range.`;
56
+ return buildTimelineAgentPrompt({
57
+ rangeStart: start,
58
+ rangeEnd: end,
59
+ elements: elementsInRange,
60
+ prompt,
61
+ });
75
62
  }, [start, end, elementsInRange, prompt]);
76
63
 
77
64
  const handleCopy = useCallback(async () => {
@@ -85,13 +72,32 @@ Preserve all other elements and timing outside this range.`;
85
72
  document.execCommand("copy");
86
73
  document.body.removeChild(ta);
87
74
  }
88
- setCopied(true);
75
+ setCopiedAgentPrompt(true);
89
76
  setTimeout(() => {
90
- setCopied(false);
77
+ setCopiedAgentPrompt(false);
91
78
  onClose();
92
79
  }, 800);
93
80
  }, [buildClipboardText, onClose]);
94
81
 
82
+ const handleCopyPrompt = useCallback(async () => {
83
+ const promptText = buildPromptCopyText(prompt);
84
+ if (!promptText) return;
85
+ try {
86
+ await navigator.clipboard.writeText(promptText);
87
+ } catch {
88
+ const ta = document.createElement("textarea");
89
+ ta.value = promptText;
90
+ document.body.appendChild(ta);
91
+ ta.select();
92
+ document.execCommand("copy");
93
+ document.body.removeChild(ta);
94
+ }
95
+ setCopiedPromptOnly(true);
96
+ setTimeout(() => {
97
+ setCopiedPromptOnly(false);
98
+ }, 800);
99
+ }, [prompt]);
100
+
95
101
  const style: React.CSSProperties = {
96
102
  position: "fixed",
97
103
  left: Math.max(8, Math.min(anchorX - 160, window.innerWidth - 336)),
@@ -146,17 +152,30 @@ Preserve all other elements and timing outside this range.`;
146
152
  </div>
147
153
 
148
154
  {/* Action */}
149
- <div className="px-3 pb-3">
155
+ <div className="grid grid-cols-2 gap-2 px-3 pb-3">
156
+ <button
157
+ onClick={handleCopyPrompt}
158
+ disabled={!buildPromptCopyText(prompt)}
159
+ className={`py-1.5 text-[11px] font-medium rounded-lg transition-all border ${
160
+ copiedPromptOnly
161
+ ? "bg-green-500/20 text-green-400 border-green-500/30"
162
+ : "bg-neutral-800/70 text-neutral-200 border-neutral-700/50 hover:bg-neutral-800"
163
+ } disabled:opacity-50 disabled:cursor-not-allowed`}
164
+ >
165
+ {copiedPromptOnly ? "Prompt Copied!" : "Copy Prompt"}
166
+ </button>
150
167
  <button
151
168
  onClick={handleCopy}
152
- className={`w-full py-1.5 text-[11px] font-medium rounded-lg transition-all ${
153
- copied
169
+ className={`py-1.5 text-[11px] font-medium rounded-lg transition-all ${
170
+ copiedAgentPrompt
154
171
  ? "bg-green-500/20 text-green-400 border border-green-500/30"
155
172
  : "bg-studio-accent/15 text-studio-accent border border-studio-accent/25 hover:bg-studio-accent/25"
156
173
  }`}
157
174
  >
158
- {copied ? "Copied!" : "Copy to Agent"}
159
- {!copied && <span className="text-[9px] text-studio-accent/50 ml-1.5">Cmd+Enter</span>}
175
+ {copiedAgentPrompt ? "Copied!" : "Copy to Agent"}
176
+ {!copiedAgentPrompt && (
177
+ <span className="text-[9px] text-studio-accent/50 ml-1.5">Cmd+Enter</span>
178
+ )}
160
179
  </button>
161
180
  </div>
162
181
  </div>
@@ -1,6 +1,5 @@
1
1
  import { forwardRef, useRef, useState } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
- import type { HyperframesPlayer } from "@hyperframes/player";
4
3
  // NOTE: importing "@hyperframes/player" registers a class extending HTMLElement
5
4
  // at module load, which throws under SSR. Defer the import to the mount effect
6
5
  // so it only runs in the browser.
@@ -12,6 +11,10 @@ interface PlayerProps {
12
11
  portrait?: boolean;
13
12
  }
14
13
 
14
+ interface HyperframesPlayerElement extends HTMLElement {
15
+ iframeElement: HTMLIFrameElement;
16
+ }
17
+
15
18
  /**
16
19
  * Readiness check for a Lottie animation instance. Duck-types both supported
17
20
  * player shapes:
@@ -96,7 +99,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
96
99
  if (canceled) return;
97
100
 
98
101
  // Create the web component imperatively to avoid JSX custom-element typing.
99
- const player = document.createElement("hyperframes-player") as HyperframesPlayer;
102
+ const player = document.createElement("hyperframes-player") as HyperframesPlayerElement;
100
103
  const src = directUrl || `/api/projects/${projectId}/preview`;
101
104
  player.setAttribute("src", src);
102
105
  player.setAttribute("width", String(portrait ? 1080 : 1920));
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveSeekPercent } from "./PlayerControls";
3
+
4
+ describe("resolveSeekPercent", () => {
5
+ it("returns 0 when the track width is invalid", () => {
6
+ expect(resolveSeekPercent(100, 0, 0)).toBe(0);
7
+ });
8
+
9
+ it("snaps to the start within the edge threshold", () => {
10
+ expect(resolveSeekPercent(105, 100, 200)).toBe(0);
11
+ });
12
+
13
+ it("snaps to the end within the edge threshold", () => {
14
+ expect(resolveSeekPercent(298, 100, 200)).toBe(1);
15
+ });
16
+
17
+ it("preserves the true percent away from the edges", () => {
18
+ expect(resolveSeekPercent(150, 100, 200)).toBe(0.25);
19
+ });
20
+ });
@@ -4,6 +4,17 @@ import { formatTime } from "../lib/time";
4
4
  import { usePlayerStore, liveTime } from "../store/playerStore";
5
5
 
6
6
  const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
7
+ const SEEK_EDGE_SNAP_PX = 8;
8
+
9
+ export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number {
10
+ if (!Number.isFinite(rectWidth) || rectWidth <= 0) return 0;
11
+ const rawPercent = (clientX - rectLeft) / rectWidth;
12
+ const clamped = Math.max(0, Math.min(1, rawPercent));
13
+ const snapThreshold = Math.min(0.5, SEEK_EDGE_SNAP_PX / rectWidth);
14
+ if (clamped <= snapThreshold) return 0;
15
+ if (clamped >= 1 - snapThreshold) return 1;
16
+ return clamped;
17
+ }
7
18
 
8
19
  interface PlayerControlsProps {
9
20
  onTogglePlay: () => void;
@@ -88,7 +99,7 @@ export const PlayerControls = memo(function PlayerControls({
88
99
  const bar = seekBarRef.current;
89
100
  if (!bar || duration <= 0) return;
90
101
  const rect = bar.getBoundingClientRect();
91
- const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
102
+ const percent = resolveSeekPercent(clientX, rect.left, rect.width);
92
103
  // Immediately update progress bar visuals (don't wait for liveTime round-trip)
93
104
  const pct = percent * 100;
94
105
  if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
@@ -1,5 +1,10 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { generateTicks } from "./Timeline";
2
+ import {
3
+ generateTicks,
4
+ getTimelinePlayheadLeft,
5
+ getTimelineScrollLeftForZoomTransition,
6
+ shouldAutoScrollTimeline,
7
+ } from "./Timeline";
3
8
  import { formatTime } from "../lib/time";
4
9
 
5
10
  describe("generateTicks", () => {
@@ -108,3 +113,41 @@ describe("formatTime", () => {
108
113
  expect(formatTime(61)).toBe("1:01");
109
114
  });
110
115
  });
116
+
117
+ describe("shouldAutoScrollTimeline", () => {
118
+ it("never auto-scrolls in fit mode", () => {
119
+ expect(shouldAutoScrollTimeline("fit", 1200, 800)).toBe(false);
120
+ });
121
+
122
+ it("does not auto-scroll when there is no horizontal overflow", () => {
123
+ expect(shouldAutoScrollTimeline("manual", 800, 800)).toBe(false);
124
+ expect(shouldAutoScrollTimeline("manual", 800.5, 800)).toBe(false);
125
+ });
126
+
127
+ it("auto-scrolls in manual mode when horizontal overflow exists", () => {
128
+ expect(shouldAutoScrollTimeline("manual", 1200, 800)).toBe(true);
129
+ });
130
+ });
131
+
132
+ describe("getTimelineScrollLeftForZoomTransition", () => {
133
+ it("resets horizontal scroll when switching from manual zoom back to fit", () => {
134
+ expect(getTimelineScrollLeftForZoomTransition("manual", "fit", 480)).toBe(0);
135
+ });
136
+
137
+ it("preserves the current scroll offset for other zoom transitions", () => {
138
+ expect(getTimelineScrollLeftForZoomTransition("fit", "fit", 480)).toBe(480);
139
+ expect(getTimelineScrollLeftForZoomTransition("fit", "manual", 480)).toBe(480);
140
+ expect(getTimelineScrollLeftForZoomTransition("manual", "manual", 480)).toBe(480);
141
+ });
142
+ });
143
+
144
+ describe("getTimelinePlayheadLeft", () => {
145
+ it("converts time to a pixel offset from the gutter", () => {
146
+ expect(getTimelinePlayheadLeft(4, 20)).toBe(112);
147
+ });
148
+
149
+ it("guards invalid input", () => {
150
+ expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(32);
151
+ expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(32);
152
+ });
153
+ });