@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
package/dist/index.html CHANGED
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <title>HyperFrames Studio</title>
7
- <script type="module" crossorigin src="/assets/index-CVDXfFQ6.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-jmDaI2F7.css">
7
+ <script type="module" crossorigin src="/assets/index-rN5doSq1.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-BKkR67xb.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.4.12",
3
+ "version": "0.4.13-alpha.1",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,8 +32,8 @@
32
32
  "@phosphor-icons/react": "^2.1.10",
33
33
  "codemirror": "^6.0.1",
34
34
  "motion": "^12.38.0",
35
- "@hyperframes/core": "0.4.12",
36
- "@hyperframes/player": "0.4.12"
35
+ "@hyperframes/core": "0.4.13-alpha.1",
36
+ "@hyperframes/player": "0.4.13-alpha.1"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/react": "^19.0.0",
@@ -47,7 +47,7 @@
47
47
  "vite": "^6.4.2",
48
48
  "vitest": "^3.2.4",
49
49
  "zustand": "^5.0.0",
50
- "@hyperframes/producer": "0.4.12"
50
+ "@hyperframes/producer": "0.4.13-alpha.1"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "react": "^18.0.0 || ^19.0.0",
package/src/App.tsx CHANGED
@@ -5,7 +5,7 @@ import { SourceEditor } from "./components/editor/SourceEditor";
5
5
  import { LeftSidebar } from "./components/sidebar/LeftSidebar";
6
6
  import { RenderQueue } from "./components/renders/RenderQueue";
7
7
  import { useRenderQueue } from "./components/renders/useRenderQueue";
8
- import { CompositionThumbnail, VideoThumbnail } from "./player";
8
+ import { CompositionThumbnail, VideoThumbnail, usePlayerStore } from "./player";
9
9
  import { AudioWaveform } from "./player/components/AudioWaveform";
10
10
  import type { TimelineElement } from "./player";
11
11
  import { LintModal } from "./components/LintModal";
@@ -18,6 +18,15 @@ import { CaptionTimeline } from "./captions/components/CaptionTimeline";
18
18
  import { useCaptionStore } from "./captions/store";
19
19
  import { useCaptionSync } from "./captions/hooks/useCaptionSync";
20
20
  import { parseCaptionComposition } from "./captions/parser";
21
+ import { applyPatchByTarget, readAttributeByTarget } from "./utils/sourcePatcher";
22
+ import {
23
+ buildTrackZIndexMap,
24
+ formatTimelineAttributeNumber,
25
+ } from "./player/components/timelineEditing";
26
+ import {
27
+ getNextTimelineZoomPercent,
28
+ getTimelineZoomPercent,
29
+ } from "./player/components/timelineZoom";
21
30
 
22
31
  interface EditingFile {
23
32
  path: string;
@@ -186,7 +195,7 @@ export function StudioApp() {
186
195
  }, [captionHasSelection, captionEditMode]);
187
196
  const [globalDragOver, setGlobalDragOver] = useState(false);
188
197
  const [uploadToast, setUploadToast] = useState<string | null>(null);
189
- const [timelineVisible, setTimelineVisible] = useState(false);
198
+ const [timelineVisible, setTimelineVisible] = useState(true);
190
199
  const dragCounterRef = useRef(0);
191
200
  const panelDragRef = useRef<{
192
201
  side: "left" | "right";
@@ -198,6 +207,23 @@ export function StudioApp() {
198
207
  const activePreviewUrl = activeCompPath
199
208
  ? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
200
209
  : null;
210
+ const zoomMode = usePlayerStore((s) => s.zoomMode);
211
+ const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
212
+ const setZoomMode = usePlayerStore((s) => s.setZoomMode);
213
+ const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
214
+ const timelineElements = usePlayerStore((s) => s.elements);
215
+ const timelineDuration = usePlayerStore((s) => s.duration);
216
+ const effectiveTimelineDuration = useMemo(() => {
217
+ const maxEnd =
218
+ timelineElements.length > 0
219
+ ? Math.max(...timelineElements.map((element) => element.start + element.duration))
220
+ : 0;
221
+ return Math.max(timelineDuration, maxEnd);
222
+ }, [timelineDuration, timelineElements]);
223
+ const displayedTimelineZoomPercent = useMemo(
224
+ () => getTimelineZoomPercent(zoomMode, manualZoomPercent),
225
+ [zoomMode, manualZoomPercent],
226
+ );
201
227
 
202
228
  const renderClipContent = useCallback(
203
229
  (el: TimelineElement, style: { clip: string; label: string }): ReactNode => {
@@ -222,6 +248,8 @@ export function StudioApp() {
222
248
  previewUrl={`/api/projects/${pid}/preview/comp/${compSrc}`}
223
249
  label={el.id || el.tag}
224
250
  labelColor={style.label}
251
+ accentColor={style.clip}
252
+ selector={el.selector}
225
253
  seekTime={0}
226
254
  duration={el.duration}
227
255
  />
@@ -236,12 +264,20 @@ export function StudioApp() {
236
264
  previewUrl={activePreviewUrl}
237
265
  label={el.id || el.tag}
238
266
  labelColor={style.label}
267
+ accentColor={style.clip}
268
+ selector={el.selector}
239
269
  seekTime={el.start}
240
270
  duration={el.duration}
241
271
  />
242
272
  );
243
273
  }
244
274
 
275
+ const htmlPreviewEligible =
276
+ el.duration > 0 &&
277
+ effectiveTimelineDuration > 0 &&
278
+ el.duration < effectiveTimelineDuration * 0.92 &&
279
+ !/(backdrop|background|overlay|scrim|mask)/i.test(el.id);
280
+
245
281
  // Audio clips — waveform visualization
246
282
  if (el.tag === "audio") {
247
283
  const audioUrl = el.src
@@ -268,14 +304,14 @@ export function StudioApp() {
268
304
  );
269
305
  }
270
306
 
271
- // HTML scene elements — render from the master preview at the scene's time
272
- if (el.tag === "div" && el.duration > 0) {
273
- const previewUrl = `/api/projects/${pid}/preview`;
307
+ if (htmlPreviewEligible) {
274
308
  return (
275
309
  <CompositionThumbnail
276
- previewUrl={previewUrl}
310
+ previewUrl={`/api/projects/${pid}/preview`}
277
311
  label={el.id || el.tag}
278
312
  labelColor={style.label}
313
+ accentColor={style.clip}
314
+ selector={el.selector}
279
315
  seekTime={el.start}
280
316
  duration={el.duration}
281
317
  />
@@ -284,7 +320,53 @@ export function StudioApp() {
284
320
 
285
321
  return null;
286
322
  },
287
- [compIdToSrc, activePreviewUrl],
323
+ [compIdToSrc, activePreviewUrl, effectiveTimelineDuration],
324
+ );
325
+ const timelineToolbar = (
326
+ <div className="flex items-center justify-between px-3 py-2 border-b border-neutral-800/40 bg-neutral-950/96">
327
+ <div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
328
+ Timeline
329
+ </div>
330
+ <div className="flex items-center gap-1">
331
+ <button
332
+ type="button"
333
+ onClick={() => setZoomMode("fit")}
334
+ className={`h-7 px-2.5 rounded-md border text-[11px] font-medium transition-colors ${
335
+ zoomMode === "fit"
336
+ ? "border-studio-accent/30 bg-studio-accent/10 text-studio-accent"
337
+ : "border-neutral-800 text-neutral-400 hover:border-neutral-700 hover:text-neutral-200"
338
+ }`}
339
+ title="Fit timeline to width"
340
+ >
341
+ Fit
342
+ </button>
343
+ <button
344
+ type="button"
345
+ onClick={() => {
346
+ setZoomMode("manual");
347
+ setManualZoomPercent(getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent));
348
+ }}
349
+ className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
350
+ title="Zoom out"
351
+ >
352
+ -
353
+ </button>
354
+ <div className="min-w-[58px] text-center text-[10px] font-medium tabular-nums text-neutral-500">
355
+ {`${displayedTimelineZoomPercent}%`}
356
+ </div>
357
+ <button
358
+ type="button"
359
+ onClick={() => {
360
+ setZoomMode("manual");
361
+ setManualZoomPercent(getNextTimelineZoomPercent("in", zoomMode, manualZoomPercent));
362
+ }}
363
+ className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
364
+ title="Zoom in"
365
+ >
366
+ +
367
+ </button>
368
+ </div>
369
+ </div>
288
370
  );
289
371
  const [lintModal, setLintModal] = useState<LintFinding[] | null>(null);
290
372
  const [consoleErrors, setConsoleErrors] = useState<LintFinding[] | null>(null);
@@ -378,6 +460,195 @@ export function StudioApp() {
378
460
  }, 600);
379
461
  }, []);
380
462
 
463
+ const handleTimelineElementMove = useCallback(
464
+ async (element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
465
+ const pid = projectIdRef.current;
466
+ if (!pid) throw new Error("No active project");
467
+
468
+ const targetPath = element.sourceFile || activeCompPath || "index.html";
469
+ const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
470
+ if (!response.ok) {
471
+ throw new Error(`Failed to read ${targetPath}`);
472
+ }
473
+
474
+ const data = (await response.json()) as { content?: string };
475
+ const originalContent = data.content;
476
+ if (typeof originalContent !== "string") {
477
+ throw new Error(`Missing file contents for ${targetPath}`);
478
+ }
479
+
480
+ const patchTarget = element.domId
481
+ ? { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex }
482
+ : element.selector
483
+ ? { selector: element.selector, selectorIndex: element.selectorIndex }
484
+ : null;
485
+ if (!patchTarget) {
486
+ throw new Error(`Timeline element ${element.id} is missing a patchable target`);
487
+ }
488
+
489
+ const resolvedTargetPath = targetPath || "index.html";
490
+ const relevantElements = timelineElements
491
+ .map((timelineElement) =>
492
+ (timelineElement.key ?? timelineElement.id) === (element.key ?? element.id)
493
+ ? { ...timelineElement, start: updates.start, track: updates.track }
494
+ : timelineElement,
495
+ )
496
+ .filter(
497
+ (timelineElement) =>
498
+ (timelineElement.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
499
+ );
500
+ const trackZIndices = buildTrackZIndexMap(
501
+ relevantElements.map((timelineElement) => timelineElement.track),
502
+ );
503
+
504
+ let patchedContent = applyPatchByTarget(originalContent, patchTarget, {
505
+ type: "attribute",
506
+ property: "start",
507
+ value: formatTimelineAttributeNumber(updates.start),
508
+ });
509
+ patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
510
+ type: "attribute",
511
+ property: "track-index",
512
+ value: String(updates.track),
513
+ });
514
+ for (const timelineElement of relevantElements) {
515
+ const elementTarget = timelineElement.domId
516
+ ? {
517
+ id: timelineElement.domId,
518
+ selector: timelineElement.selector,
519
+ selectorIndex: timelineElement.selectorIndex,
520
+ }
521
+ : timelineElement.selector
522
+ ? {
523
+ selector: timelineElement.selector,
524
+ selectorIndex: timelineElement.selectorIndex,
525
+ }
526
+ : null;
527
+ if (!elementTarget) continue;
528
+ const nextZIndex = trackZIndices.get(timelineElement.track);
529
+ if (nextZIndex == null) continue;
530
+ patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
531
+ type: "inline-style",
532
+ property: "z-index",
533
+ value: String(nextZIndex),
534
+ });
535
+ }
536
+
537
+ if (patchedContent === originalContent) {
538
+ throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
539
+ }
540
+
541
+ const saveResponse = await fetch(
542
+ `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
543
+ {
544
+ method: "PUT",
545
+ headers: { "Content-Type": "text/plain" },
546
+ body: patchedContent,
547
+ },
548
+ );
549
+ if (!saveResponse.ok) {
550
+ throw new Error(`Failed to save ${targetPath}`);
551
+ }
552
+
553
+ if (editingPathRef.current === targetPath) {
554
+ setEditingFile({ path: targetPath, content: patchedContent });
555
+ }
556
+
557
+ setRefreshKey((k) => k + 1);
558
+ },
559
+ [activeCompPath, timelineElements],
560
+ );
561
+
562
+ const handleTimelineElementResize = useCallback(
563
+ async (
564
+ element: TimelineElement,
565
+ updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
566
+ ) => {
567
+ const pid = projectIdRef.current;
568
+ if (!pid) throw new Error("No active project");
569
+
570
+ const targetPath = element.sourceFile || activeCompPath || "index.html";
571
+ const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
572
+ if (!response.ok) {
573
+ throw new Error(`Failed to read ${targetPath}`);
574
+ }
575
+
576
+ const data = (await response.json()) as { content?: string };
577
+ const originalContent = data.content;
578
+ if (typeof originalContent !== "string") {
579
+ throw new Error(`Missing file contents for ${targetPath}`);
580
+ }
581
+
582
+ const patchTarget = element.domId
583
+ ? { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex }
584
+ : element.selector
585
+ ? { selector: element.selector, selectorIndex: element.selectorIndex }
586
+ : null;
587
+ if (!patchTarget) {
588
+ throw new Error(`Timeline element ${element.id} is missing a patchable target`);
589
+ }
590
+
591
+ const playbackStartAttrName =
592
+ element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
593
+ const currentPlaybackStartValue =
594
+ readAttributeByTarget(originalContent, patchTarget, "playback-start") ??
595
+ readAttributeByTarget(originalContent, patchTarget, "media-start");
596
+ const currentPlaybackStart =
597
+ currentPlaybackStartValue != null ? parseFloat(currentPlaybackStartValue) : undefined;
598
+ const trimDelta = updates.start - element.start;
599
+ const fallbackPlaybackStart =
600
+ updates.playbackStart == null &&
601
+ trimDelta !== 0 &&
602
+ Number.isFinite(currentPlaybackStart) &&
603
+ currentPlaybackStart != null
604
+ ? Math.max(0, currentPlaybackStart + trimDelta * Math.max(element.playbackRate ?? 1, 0.1))
605
+ : undefined;
606
+ const nextPlaybackStart = updates.playbackStart ?? fallbackPlaybackStart;
607
+
608
+ let patchedContent = originalContent;
609
+ patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
610
+ type: "attribute",
611
+ property: "start",
612
+ value: formatTimelineAttributeNumber(updates.start),
613
+ });
614
+ patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
615
+ type: "attribute",
616
+ property: "duration",
617
+ value: formatTimelineAttributeNumber(updates.duration),
618
+ });
619
+ if (nextPlaybackStart != null) {
620
+ patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
621
+ type: "attribute",
622
+ property: playbackStartAttrName,
623
+ value: formatTimelineAttributeNumber(nextPlaybackStart),
624
+ });
625
+ }
626
+
627
+ if (patchedContent === originalContent) {
628
+ throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
629
+ }
630
+
631
+ const saveResponse = await fetch(
632
+ `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
633
+ {
634
+ method: "PUT",
635
+ headers: { "Content-Type": "text/plain" },
636
+ body: patchedContent,
637
+ },
638
+ );
639
+ if (!saveResponse.ok) {
640
+ throw new Error(`Failed to save ${targetPath}`);
641
+ }
642
+
643
+ if (editingPathRef.current === targetPath) {
644
+ setEditingFile({ path: targetPath, content: patchedContent });
645
+ }
646
+
647
+ setRefreshKey((k) => k + 1);
648
+ },
649
+ [activeCompPath],
650
+ );
651
+
381
652
  // ── File Management Handlers ──
382
653
 
383
654
  const refreshFileTree = useCallback(async () => {
@@ -780,12 +1051,14 @@ export function StudioApp() {
780
1051
  {/* Left resize handle */}
781
1052
  {!leftCollapsed && (
782
1053
  <div
783
- className="w-1 flex-shrink-0 bg-neutral-800 hover:bg-studio-accent cursor-col-resize transition-colors active:bg-studio-accent/80"
1054
+ className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center"
784
1055
  style={{ touchAction: "none" }}
785
1056
  onPointerDown={(e) => handlePanelResizeStart("left", e)}
786
1057
  onPointerMove={handlePanelResizeMove}
787
1058
  onPointerUp={handlePanelResizeEnd}
788
- />
1059
+ >
1060
+ <div className="h-[52px] w-px bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
1061
+ </div>
789
1062
  )}
790
1063
 
791
1064
  {/* Center: Preview */}
@@ -794,7 +1067,10 @@ export function StudioApp() {
794
1067
  projectId={projectId}
795
1068
  refreshKey={refreshKey}
796
1069
  activeCompositionPath={activeCompPath}
1070
+ timelineToolbar={timelineToolbar}
797
1071
  renderClipContent={renderClipContent}
1072
+ onMoveElement={handleTimelineElementMove}
1073
+ onResizeElement={handleTimelineElementResize}
798
1074
  onCompIdToSrcChange={setCompIdToSrc}
799
1075
  onCompositionChange={(compPath) => {
800
1076
  // Sync activeCompPath when user drills down via timeline double-click
@@ -875,12 +1151,14 @@ export function StudioApp() {
875
1151
  {!rightCollapsed && (
876
1152
  <>
877
1153
  <div
878
- className="w-1 flex-shrink-0 bg-neutral-800 hover:bg-studio-accent cursor-col-resize transition-colors active:bg-studio-accent/80"
1154
+ className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center"
879
1155
  style={{ touchAction: "none" }}
880
1156
  onPointerDown={(e) => handlePanelResizeStart("right", e)}
881
1157
  onPointerMove={handlePanelResizeMove}
882
1158
  onPointerUp={handlePanelResizeEnd}
883
- />
1159
+ >
1160
+ <div className="h-[52px] w-px bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
1161
+ </div>
884
1162
  <div
885
1163
  className="flex flex-col border-l border-neutral-800 bg-neutral-900 flex-shrink-0"
886
1164
  style={{ width: rightWidth }}
@@ -27,6 +27,15 @@ interface NLELayoutProps {
27
27
  element: TimelineElement,
28
28
  style: { clip: string; label: string },
29
29
  ) => ReactNode;
30
+ /** Persist timeline move actions back into source HTML */
31
+ onMoveElement?: (
32
+ element: TimelineElement,
33
+ updates: Pick<TimelineElement, "start" | "track">,
34
+ ) => Promise<void> | void;
35
+ onResizeElement?: (
36
+ element: TimelineElement,
37
+ updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
38
+ ) => Promise<void> | void;
30
39
  /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
31
40
  onCompIdToSrcChange?: (map: Map<string, string>) => void;
32
41
  /** Whether the timeline panel is visible (default: true) */
@@ -50,6 +59,8 @@ export const NLELayout = memo(function NLELayout({
50
59
  onIframeRef,
51
60
  onCompositionChange,
52
61
  renderClipContent,
62
+ onMoveElement,
63
+ onResizeElement,
53
64
  onCompIdToSrcChange,
54
65
  timelineVisible,
55
66
  onToggleTimeline,
@@ -59,6 +70,7 @@ export const NLELayout = memo(function NLELayout({
59
70
  togglePlay,
60
71
  seek,
61
72
  onIframeLoad: baseOnIframeLoad,
73
+ refreshPlayer,
62
74
  saveSeekPosition,
63
75
  } = useTimelinePlayer();
64
76
 
@@ -72,12 +84,13 @@ export const NLELayout = memo(function NLELayout({
72
84
  usePlayerStore.getState().reset();
73
85
  }
74
86
 
75
- // Preserve seek position when refreshKey changes (iframe will remount via key prop).
87
+ // Refresh the existing iframe in place when source files change.
76
88
  const prevRefreshKeyRef = useRef(refreshKey);
77
- if (refreshKey !== prevRefreshKeyRef.current) {
89
+ useEffect(() => {
90
+ if (refreshKey === prevRefreshKeyRef.current) return;
78
91
  prevRefreshKeyRef.current = refreshKey;
79
- saveSeekPosition();
80
- }
92
+ refreshPlayer();
93
+ }, [refreshKey, refreshPlayer]);
81
94
 
82
95
  // Wrap onIframeLoad to also notify parent of iframe ref
83
96
  const onIframeLoad = useCallback(() => {
@@ -351,18 +364,20 @@ export const NLELayout = memo(function NLELayout({
351
364
  <>
352
365
  {/* Resize divider */}
353
366
  <div
354
- className="h-1 flex-shrink-0 bg-neutral-800 hover:bg-studio-accent cursor-row-resize transition-colors active:bg-studio-accent/80 z-10"
367
+ className="group h-2 flex-shrink-0 cursor-row-resize flex items-center justify-center z-10"
355
368
  style={{ touchAction: "none" }}
356
369
  onPointerDown={handleDividerPointerDown}
357
370
  onPointerMove={handleDividerPointerMove}
358
371
  onPointerUp={handleDividerPointerUp}
359
- />
372
+ >
373
+ <div className="h-px w-full bg-white/10 transition-colors group-hover:bg-white/16 group-active:bg-white/22" />
374
+ </div>
360
375
 
361
376
  {/* Timeline section — fixed height, resizable */}
362
377
  <div className="flex flex-col flex-shrink-0" style={{ height: timelineH }}>
363
378
  {/* Timeline tracks */}
364
379
  <div
365
- className="flex-1 min-h-0 overflow-y-auto bg-neutral-950"
380
+ className="flex-1 min-h-0 overflow-hidden bg-neutral-950"
366
381
  onDoubleClick={(e) => {
367
382
  if ((e.target as HTMLElement).closest("[data-clip]")) return;
368
383
  if (compositionStack.length > 1) {
@@ -375,6 +390,8 @@ export const NLELayout = memo(function NLELayout({
375
390
  onSeek={seek}
376
391
  onDrillDown={handleDrillDown}
377
392
  renderClipContent={renderClipContent}
393
+ onMoveElement={onMoveElement}
394
+ onResizeElement={onResizeElement}
378
395
  />
379
396
  </div>
380
397
  {timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getPreviewPlayerKey } from "./NLEPreview";
3
+
4
+ describe("getPreviewPlayerKey", () => {
5
+ it("keeps the same player identity when only refreshKey changes", () => {
6
+ expect(
7
+ getPreviewPlayerKey({
8
+ projectId: "timeline-edit-playground",
9
+ refreshKey: 1,
10
+ }),
11
+ ).toBe(
12
+ getPreviewPlayerKey({
13
+ projectId: "timeline-edit-playground",
14
+ refreshKey: 2,
15
+ }),
16
+ );
17
+ });
18
+
19
+ it("switches identity when drilling into a different directUrl", () => {
20
+ expect(
21
+ getPreviewPlayerKey({
22
+ projectId: "timeline-edit-playground",
23
+ directUrl: "/api/projects/timeline-edit-playground/preview",
24
+ }),
25
+ ).not.toBe(
26
+ getPreviewPlayerKey({
27
+ projectId: "timeline-edit-playground",
28
+ directUrl: "/api/projects/timeline-edit-playground/preview/comp/compositions/intro.html",
29
+ }),
30
+ );
31
+ });
32
+ });
@@ -10,6 +10,17 @@ interface NLEPreviewProps {
10
10
  refreshKey?: number;
11
11
  }
12
12
 
13
+ export function getPreviewPlayerKey({
14
+ projectId,
15
+ directUrl,
16
+ }: {
17
+ projectId: string;
18
+ directUrl?: string;
19
+ refreshKey?: number;
20
+ }): string {
21
+ return directUrl ?? projectId;
22
+ }
23
+
13
24
  export const NLEPreview = memo(function NLEPreview({
14
25
  projectId,
15
26
  iframeRef,
@@ -18,7 +29,7 @@ export const NLEPreview = memo(function NLEPreview({
18
29
  directUrl,
19
30
  refreshKey,
20
31
  }: NLEPreviewProps) {
21
- const playerKey = `${directUrl ?? projectId}_${refreshKey ?? 0}`;
32
+ const playerKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
22
33
 
23
34
  return (
24
35
  <div className="flex flex-col h-full min-h-0">