@hyperframes/studio 0.4.16 → 0.4.18

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.
@@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect, memo, type ReactNode } from "
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
4
4
  import type { TimelineElement } from "../../player";
5
+ import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
5
6
  import { NLEPreview } from "./NLEPreview";
6
7
  import { CompositionBreadcrumb, type CompositionLevel } from "./CompositionBreadcrumb";
7
8
 
@@ -27,6 +28,15 @@ interface NLELayoutProps {
27
28
  element: TimelineElement,
28
29
  style: { clip: string; label: string },
29
30
  ) => ReactNode;
31
+ onFileDrop?: (
32
+ files: File[],
33
+ placement?: Pick<TimelineElement, "start" | "track">,
34
+ ) => Promise<void> | void;
35
+ onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
36
+ onAssetDrop?: (
37
+ assetPath: string,
38
+ placement: Pick<TimelineElement, "start" | "track">,
39
+ ) => Promise<void> | void;
30
40
  /** Persist timeline move actions back into source HTML */
31
41
  onMoveElement?: (
32
42
  element: TimelineElement,
@@ -36,6 +46,7 @@ interface NLELayoutProps {
36
46
  element: TimelineElement,
37
47
  updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
38
48
  ) => Promise<void> | void;
49
+ onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
39
50
  /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
40
51
  onCompIdToSrcChange?: (map: Map<string, string>) => void;
41
52
  /** Whether the timeline panel is visible (default: true) */
@@ -59,8 +70,12 @@ export const NLELayout = memo(function NLELayout({
59
70
  onIframeRef,
60
71
  onCompositionChange,
61
72
  renderClipContent,
73
+ onFileDrop,
74
+ onDeleteElement,
75
+ onAssetDrop,
62
76
  onMoveElement,
63
77
  onResizeElement,
78
+ onBlockedEditAttempt,
64
79
  onCompIdToSrcChange,
65
80
  timelineVisible,
66
81
  onToggleTimeline,
@@ -390,8 +405,12 @@ export const NLELayout = memo(function NLELayout({
390
405
  onSeek={seek}
391
406
  onDrillDown={handleDrillDown}
392
407
  renderClipContent={renderClipContent}
408
+ onFileDrop={onFileDrop}
409
+ onDeleteElement={onDeleteElement}
410
+ onAssetDrop={onAssetDrop}
393
411
  onMoveElement={onMoveElement}
394
412
  onResizeElement={onResizeElement}
413
+ onBlockedEditAttempt={onBlockedEditAttempt}
395
414
  />
396
415
  </div>
397
416
  {timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
@@ -0,0 +1,156 @@
1
+ import { TIMELINE_TOGGLE_SHORTCUT_LABEL } from "../../utils/timelineDiscovery";
2
+
3
+ interface TimelineEditorNoticeProps {
4
+ onDismiss: () => void;
5
+ }
6
+
7
+ export function TimelineEditorNotice({ onDismiss }: TimelineEditorNoticeProps) {
8
+ return (
9
+ <aside
10
+ aria-live="polite"
11
+ className="pointer-events-none relative w-[320px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-white/10 bg-[#0f1115]/88 text-neutral-100 shadow-[0_18px_40px_rgba(0,0,0,0.3),0_4px_14px_rgba(0,0,0,0.18)] backdrop-blur-xl"
12
+ >
13
+ <style>{`
14
+ @keyframes hfTimelineNoticeClipNudge {
15
+ 0%, 100% { transform: translate3d(0, 0, 0); }
16
+ 20% { transform: translate3d(0, 0, 0); }
17
+ 52% { transform: translate3d(12px, 0, 0); }
18
+ 72% { transform: translate3d(12px, 0, 0); }
19
+ 100% { transform: translate3d(0, 0, 0); }
20
+ }
21
+
22
+ @keyframes hfTimelineNoticePlayheadSweep {
23
+ 0% { transform: translateX(0); opacity: 0; }
24
+ 10% { opacity: 1; }
25
+ 75% { opacity: 1; }
26
+ 100% { transform: translateX(218px); opacity: 0; }
27
+ }
28
+
29
+ @media (prefers-reduced-motion: reduce) {
30
+ .hf-timeline-notice-clip,
31
+ .hf-timeline-notice-playhead {
32
+ animation: none !important;
33
+ }
34
+ }
35
+ `}</style>
36
+
37
+ <button
38
+ type="button"
39
+ onClick={onDismiss}
40
+ aria-label="Dismiss timeline editor notice"
41
+ className="pointer-events-auto absolute right-3 top-3 z-10 flex h-7 w-7 items-center justify-center rounded-lg text-neutral-500 transition-colors duration-150 hover:bg-white/[0.06] hover:text-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-studio-accent/50"
42
+ >
43
+ <svg
44
+ width="11"
45
+ height="11"
46
+ viewBox="0 0 24 24"
47
+ fill="none"
48
+ stroke="currentColor"
49
+ strokeWidth="2.25"
50
+ strokeLinecap="round"
51
+ aria-hidden="true"
52
+ >
53
+ <line x1="18" y1="6" x2="6" y2="18" />
54
+ <line x1="6" y1="6" x2="18" y2="18" />
55
+ </svg>
56
+ </button>
57
+
58
+ <div className="flex items-start gap-3 px-4 py-3.5">
59
+ <div className="min-w-0 flex-1">
60
+ <div
61
+ aria-hidden="true"
62
+ className="mb-3 overflow-hidden rounded-[14px] bg-[#0d1117] p-2.5"
63
+ >
64
+ <div className="relative overflow-hidden rounded-[11px] bg-[#0f141c] px-2.5 pb-2 pt-1.5">
65
+ <div className="mb-1.5 flex items-center justify-between pl-6 pr-1 text-[8px] font-medium text-[#7f8796]">
66
+ <span>0:00</span>
67
+ <span>0:05</span>
68
+ <span>0:10</span>
69
+ </div>
70
+
71
+ <div className="pointer-events-none absolute inset-x-0 top-[18px] h-px bg-white/[0.04]" />
72
+ <div
73
+ className="hf-timeline-notice-playhead pointer-events-none absolute left-[31px] top-[18px] h-[70px] w-0"
74
+ style={{
75
+ animation:
76
+ "hfTimelineNoticePlayheadSweep 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite",
77
+ }}
78
+ >
79
+ <div
80
+ className="absolute top-0 bottom-0"
81
+ style={{
82
+ left: "50%",
83
+ width: 2,
84
+ marginLeft: -1,
85
+ background: "var(--hf-accent, #3CE6AC)",
86
+ boxShadow: "0 0 8px rgba(60,230,172,0.5)",
87
+ }}
88
+ />
89
+ <div
90
+ className="absolute"
91
+ style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}
92
+ >
93
+ <div
94
+ style={{
95
+ width: 0,
96
+ height: 0,
97
+ borderLeft: "6px solid transparent",
98
+ borderRight: "6px solid transparent",
99
+ borderTop: "8px solid var(--hf-accent, #3CE6AC)",
100
+ filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
101
+ }}
102
+ />
103
+ </div>
104
+ </div>
105
+
106
+ <div className="flex flex-col gap-1.5">
107
+ {[0, 1, 2].map((trackIndex) => (
108
+ <div
109
+ key={trackIndex}
110
+ className="relative h-6 overflow-hidden rounded-[10px] bg-white/[0.035]"
111
+ >
112
+ <div className="absolute inset-y-0 left-[24px] w-px bg-white/[0.035]" />
113
+ <div className="absolute inset-y-0 left-[100px] w-px bg-white/[0.035]" />
114
+ <div className="absolute inset-y-0 left-[176px] w-px bg-white/[0.035]" />
115
+ </div>
116
+ ))}
117
+ </div>
118
+
119
+ <div className="pointer-events-none absolute inset-x-0 top-[21px] h-[70px]">
120
+ <div className="absolute left-[34px] top-[3px] h-[18px] w-[56px] rounded-[9px] bg-white/[0.07]" />
121
+ <div
122
+ className="hf-timeline-notice-clip absolute left-[82px] top-[27px] h-[18px] w-[110px] rounded-[9px] bg-studio-accent/18 ring-1 ring-inset ring-studio-accent/28"
123
+ style={{
124
+ animation:
125
+ "hfTimelineNoticeClipNudge 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite",
126
+ }}
127
+ />
128
+ <div className="absolute left-[52px] top-[51px] h-[18px] w-[72px] rounded-[9px] bg-white/[0.07]" />
129
+ </div>
130
+ </div>
131
+ </div>
132
+
133
+ <div className="min-w-0 pr-9">
134
+ <p className="text-[12px] font-semibold leading-none tracking-tight text-neutral-100">
135
+ Timeline editing is on
136
+ </p>
137
+ <p className="mt-1.5 text-[12px] leading-5 text-neutral-300">
138
+ Drag clips to move timing, use{" "}
139
+ <span className="font-mono text-[11px] text-studio-accent">Shift</span> + click to
140
+ edit a full clip range, and watch for resize handles only on clips Studio can patch
141
+ safely. Toggle the timeline with{" "}
142
+ <span className="rounded-md border border-white/8 bg-white/[0.04] px-1.5 py-0.5 font-mono text-[11px] text-studio-accent">
143
+ {TIMELINE_TOGGLE_SHORTCUT_LABEL}
144
+ </span>
145
+ .
146
+ </p>
147
+ </div>
148
+
149
+ <div className="mt-2 text-[10px] leading-none text-neutral-500">
150
+ Dismiss once and it stays hidden.
151
+ </div>
152
+ </div>
153
+ </div>
154
+ </aside>
155
+ );
156
+ }
@@ -1,6 +1,7 @@
1
1
  import { memo, useState, useCallback, useRef } from "react";
2
2
  import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
3
3
  import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes";
4
+ import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
4
5
 
5
6
  interface AssetsTabProps {
6
7
  projectId: string;
@@ -106,7 +107,13 @@ function AssetCard({
106
107
  return (
107
108
  <>
108
109
  <div
110
+ draggable
109
111
  onClick={() => onCopy(asset)}
112
+ onDragStart={(e) => {
113
+ e.dataTransfer.effectAllowed = "copy";
114
+ e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset }));
115
+ e.dataTransfer.setData("text/plain", asset);
116
+ }}
110
117
  onContextMenu={(e) => {
111
118
  e.preventDefault();
112
119
  setContextMenu({ x: e.clientX, y: e.clientY });
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveCompositionPreviewScale } from "./CompositionsTab";
3
+
4
+ describe("resolveCompositionPreviewScale", () => {
5
+ it("scales a 16:9 stage to fit the composition card", () => {
6
+ expect(
7
+ resolveCompositionPreviewScale({
8
+ cardWidth: 80,
9
+ cardHeight: 45,
10
+ stageWidth: 1920,
11
+ stageHeight: 1080,
12
+ }),
13
+ ).toBeCloseTo(80 / 1920);
14
+ });
15
+
16
+ it("scales non-16:9 stages against their actual dimensions", () => {
17
+ expect(
18
+ resolveCompositionPreviewScale({
19
+ cardWidth: 80,
20
+ cardHeight: 45,
21
+ stageWidth: 1280,
22
+ stageHeight: 720,
23
+ }),
24
+ ).toBeCloseTo(80 / 1280);
25
+ });
26
+
27
+ it("falls back to the default stage when dimensions are invalid", () => {
28
+ expect(
29
+ resolveCompositionPreviewScale({
30
+ cardWidth: 80,
31
+ cardHeight: 45,
32
+ stageWidth: 0,
33
+ stageHeight: Number.NaN,
34
+ }),
35
+ ).toBeCloseTo(80 / 1920);
36
+ });
37
+ });
@@ -7,6 +7,27 @@ interface CompositionsTabProps {
7
7
  onSelect: (comp: string) => void;
8
8
  }
9
9
 
10
+ const DEFAULT_PREVIEW_STAGE = { width: 1920, height: 1080 };
11
+
12
+ export function resolveCompositionPreviewScale(input: {
13
+ cardWidth: number;
14
+ cardHeight: number;
15
+ stageWidth: number;
16
+ stageHeight: number;
17
+ }): number {
18
+ const safeStageWidth =
19
+ Number.isFinite(input.stageWidth) && input.stageWidth > 0
20
+ ? input.stageWidth
21
+ : DEFAULT_PREVIEW_STAGE.width;
22
+ const safeStageHeight =
23
+ Number.isFinite(input.stageHeight) && input.stageHeight > 0
24
+ ? input.stageHeight
25
+ : DEFAULT_PREVIEW_STAGE.height;
26
+ const scaleX = input.cardWidth / safeStageWidth;
27
+ const scaleY = input.cardHeight / safeStageHeight;
28
+ return Math.min(scaleX, scaleY);
29
+ }
30
+
10
31
  function CompCard({
11
32
  projectId,
12
33
  comp,
@@ -19,6 +40,7 @@ function CompCard({
19
40
  onSelect: () => void;
20
41
  }) {
21
42
  const [hovered, setHovered] = useState(false);
43
+ const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
22
44
  const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
23
45
  const handleEnter = () => {
24
46
  hoverTimer.current = setTimeout(() => setHovered(true), 300);
@@ -33,6 +55,12 @@ function CompCard({
33
55
  const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
34
56
  const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=2`;
35
57
  const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
58
+ const previewScale = resolveCompositionPreviewScale({
59
+ cardWidth: 80,
60
+ cardHeight: 45,
61
+ stageWidth: stageSize.width,
62
+ stageHeight: stageSize.height,
63
+ });
36
64
 
37
65
  return (
38
66
  <div
@@ -51,10 +79,25 @@ function CompCard({
51
79
  <iframe
52
80
  src={previewUrl}
53
81
  sandbox="allow-scripts allow-same-origin"
54
- className="absolute inset-0 w-[1920px] h-[1080px] border-none pointer-events-none"
82
+ className="absolute left-0 top-0 border-none pointer-events-none"
55
83
  style={{
56
84
  transformOrigin: "0 0",
57
- transform: `scale(${80 / 1920})`,
85
+ width: stageSize.width,
86
+ height: stageSize.height,
87
+ transform: `scale(${previewScale})`,
88
+ }}
89
+ onLoad={(e) => {
90
+ try {
91
+ const iframe = e.currentTarget;
92
+ const root = iframe.contentDocument?.querySelector("[data-composition-id]");
93
+ const width =
94
+ Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
95
+ const height =
96
+ Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
97
+ setStageSize({ width, height });
98
+ } catch {
99
+ setStageSize(DEFAULT_PREVIEW_STAGE);
100
+ }
58
101
  }}
59
102
  tabIndex={-1}
60
103
  />
@@ -1,8 +1,12 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import {
3
3
  generateTicks,
4
+ getDefaultDroppedTrack,
5
+ getTimelineCanvasHeight,
6
+ resolveTimelineAssetDrop,
4
7
  getTimelinePlayheadLeft,
5
8
  getTimelineScrollLeftForZoomTransition,
9
+ shouldHandleTimelineDeleteKey,
6
10
  shouldAutoScrollTimeline,
7
11
  } from "./Timeline";
8
12
  import { formatTime } from "../lib/time";
@@ -151,3 +155,83 @@ describe("getTimelinePlayheadLeft", () => {
151
155
  expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(32);
152
156
  });
153
157
  });
158
+
159
+ describe("getTimelineCanvasHeight", () => {
160
+ it("includes bottom scroll buffer below the last track", () => {
161
+ expect(getTimelineCanvasHeight(3)).toBeGreaterThan(24 + 3 * 72);
162
+ });
163
+
164
+ it("still keeps ruler space when there are no tracks", () => {
165
+ expect(getTimelineCanvasHeight(0)).toBeGreaterThan(24);
166
+ });
167
+ });
168
+
169
+ describe("shouldHandleTimelineDeleteKey", () => {
170
+ it("handles Delete and Backspace when focus is not in an editor", () => {
171
+ expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true);
172
+ expect(shouldHandleTimelineDeleteKey({ key: "Backspace" })).toBe(true);
173
+ });
174
+
175
+ it("ignores modifier shortcuts", () => {
176
+ expect(shouldHandleTimelineDeleteKey({ key: "Delete", metaKey: true })).toBe(false);
177
+ expect(shouldHandleTimelineDeleteKey({ key: "Backspace", ctrlKey: true })).toBe(false);
178
+ });
179
+
180
+ it("ignores input and editable targets", () => {
181
+ const input = { tagName: "INPUT", isContentEditable: false };
182
+ const editable = { tagName: "DIV", isContentEditable: true };
183
+
184
+ expect(shouldHandleTimelineDeleteKey({ key: "Delete", target: input })).toBe(false);
185
+ expect(shouldHandleTimelineDeleteKey({ key: "Delete", target: editable })).toBe(false);
186
+ });
187
+ });
188
+
189
+ describe("getDefaultDroppedTrack", () => {
190
+ it("defaults to track 0 when there are no rows yet", () => {
191
+ expect(getDefaultDroppedTrack([])).toBe(0);
192
+ });
193
+
194
+ it("creates a new bottom track when dropped below existing rows", () => {
195
+ expect(getDefaultDroppedTrack([0, 1, 5], 10)).toBe(6);
196
+ });
197
+ });
198
+
199
+ describe("resolveTimelineAssetDrop", () => {
200
+ it("maps drop coordinates to a start time and visible track", () => {
201
+ expect(
202
+ resolveTimelineAssetDrop(
203
+ {
204
+ rectLeft: 100,
205
+ rectTop: 200,
206
+ scrollLeft: 0,
207
+ scrollTop: 0,
208
+ pixelsPerSecond: 100,
209
+ duration: 10,
210
+ trackHeight: 72,
211
+ trackOrder: [0, 3, 7],
212
+ },
213
+ 432,
214
+ 310,
215
+ ),
216
+ ).toEqual({ start: 3, track: 3 });
217
+ });
218
+
219
+ it("can create a new bottom track when dropped below the last visible row", () => {
220
+ expect(
221
+ resolveTimelineAssetDrop(
222
+ {
223
+ rectLeft: 100,
224
+ rectTop: 200,
225
+ scrollLeft: 0,
226
+ scrollTop: 0,
227
+ pixelsPerSecond: 100,
228
+ duration: 10,
229
+ trackHeight: 72,
230
+ trackOrder: [0, 3, 7],
231
+ },
232
+ 250,
233
+ 600,
234
+ ),
235
+ ).toEqual({ start: 1.18, track: 8 });
236
+ });
237
+ });