@hyperframes/studio 0.4.37 → 0.4.39

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/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-Bj3m6A02.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-_h8opaGY.css">
7
+ <script type="module" crossorigin src="/assets/index-D4-n3yWG.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-BLrgRQSu.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.37",
3
+ "version": "0.4.39",
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.37",
36
- "@hyperframes/player": "0.4.37"
35
+ "@hyperframes/core": "0.4.39",
36
+ "@hyperframes/player": "0.4.39"
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.37"
50
+ "@hyperframes/producer": "0.4.39"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "react": "^18.0.0 || ^19.0.0",
package/src/App.tsx CHANGED
@@ -1,11 +1,19 @@
1
- import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from "react";
1
+ import {
2
+ useState,
3
+ useCallback,
4
+ useRef,
5
+ useEffect,
6
+ useMemo,
7
+ type MouseEvent,
8
+ type ReactNode,
9
+ } from "react";
2
10
  import { useMountEffect } from "./hooks/useMountEffect";
3
11
  import { NLELayout } from "./components/nle/NLELayout";
4
12
  import { SourceEditor } from "./components/editor/SourceEditor";
5
13
  import { LeftSidebar } from "./components/sidebar/LeftSidebar";
6
14
  import { RenderQueue } from "./components/renders/RenderQueue";
7
15
  import { useRenderQueue } from "./components/renders/useRenderQueue";
8
- import { CompositionThumbnail, VideoThumbnail, usePlayerStore } from "./player";
16
+ import { CompositionThumbnail, VideoThumbnail, liveTime, usePlayerStore } from "./player";
9
17
  import { AudioWaveform } from "./player/components/AudioWaveform";
10
18
  import type { TimelineElement } from "./player";
11
19
  import { LintModal } from "./components/LintModal";
@@ -40,6 +48,8 @@ import {
40
48
  getTimelineToggleTitle,
41
49
  shouldHandleTimelineToggleHotkey,
42
50
  } from "./utils/timelineDiscovery";
51
+ import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./utils/frameCapture";
52
+ import { Camera } from "./icons/SystemIcons";
43
53
 
44
54
  interface EditingFile {
45
55
  path: string;
@@ -51,6 +61,10 @@ interface AppToast {
51
61
  tone: "error" | "info";
52
62
  }
53
63
 
64
+ function getTimelineElementLabel(element: TimelineElement): string {
65
+ return element.label || element.id || element.tag;
66
+ }
67
+
54
68
  const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
55
69
  image: 3,
56
70
  video: 5,
@@ -264,6 +278,7 @@ export function StudioApp() {
264
278
  const [globalDragOver, setGlobalDragOver] = useState(false);
265
279
  const [appToast, setAppToast] = useState<AppToast | null>(null);
266
280
  const [timelineVisible, setTimelineVisible] = useState(true);
281
+ const [captureFrameTime, setCaptureFrameTime] = useState(0);
267
282
  const dragCounterRef = useRef(0);
268
283
  const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
269
284
  const lastBlockedTimelineToastAtRef = useRef(0);
@@ -298,6 +313,26 @@ export function StudioApp() {
298
313
  const toggleTimelineVisibility = useCallback(() => {
299
314
  setTimelineVisible((visible) => !visible);
300
315
  }, []);
316
+ const toggleLeftSidebar = useCallback(() => {
317
+ setLeftCollapsed((collapsed) => !collapsed);
318
+ }, []);
319
+ const refreshCaptureFrameTime = useCallback(() => {
320
+ setCaptureFrameTime(usePlayerStore.getState().currentTime);
321
+ }, []);
322
+
323
+ useMountEffect(() => {
324
+ setCaptureFrameTime(usePlayerStore.getState().currentTime);
325
+ return liveTime.subscribe(setCaptureFrameTime);
326
+ });
327
+
328
+ const captureFrameHref = projectId
329
+ ? buildFrameCaptureUrl({
330
+ projectId,
331
+ compositionPath: activeCompPath,
332
+ currentTime: captureFrameTime,
333
+ })
334
+ : "#";
335
+ const captureFrameFilename = buildFrameCaptureFilename(activeCompPath, captureFrameTime);
301
336
  useMountEffect(() => () => {
302
337
  if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
303
338
  });
@@ -361,7 +396,7 @@ export function StudioApp() {
361
396
  return (
362
397
  <CompositionThumbnail
363
398
  previewUrl={`/api/projects/${pid}/preview/comp/${compSrc}`}
364
- label={el.id || el.tag}
399
+ label={getTimelineElementLabel(el)}
365
400
  labelColor={style.label}
366
401
  accentColor={style.clip}
367
402
  selector={el.selector}
@@ -377,7 +412,7 @@ export function StudioApp() {
377
412
  return (
378
413
  <CompositionThumbnail
379
414
  previewUrl={activePreviewUrl}
380
- label={el.id || el.tag}
415
+ label={getTimelineElementLabel(el)}
381
416
  labelColor={style.label}
382
417
  accentColor={style.clip}
383
418
  selector={el.selector}
@@ -414,7 +449,7 @@ export function StudioApp() {
414
449
  <AudioWaveform
415
450
  audioUrl={audioUrl}
416
451
  waveformUrl={waveformUrl}
417
- label={el.id || el.tag}
452
+ label={getTimelineElementLabel(el)}
418
453
  labelColor={style.label}
419
454
  />
420
455
  );
@@ -427,7 +462,7 @@ export function StudioApp() {
427
462
  return (
428
463
  <VideoThumbnail
429
464
  videoSrc={mediaSrc}
430
- label={el.id || el.tag}
465
+ label={getTimelineElementLabel(el)}
431
466
  labelColor={style.label}
432
467
  duration={el.duration}
433
468
  />
@@ -438,7 +473,7 @@ export function StudioApp() {
438
473
  return (
439
474
  <CompositionThumbnail
440
475
  previewUrl={`/api/projects/${pid}/preview`}
441
- label={el.id || el.tag}
476
+ label={getTimelineElementLabel(el)}
442
477
  labelColor={style.label}
443
478
  accentColor={style.clip}
444
479
  selector={el.selector}
@@ -496,6 +531,28 @@ export function StudioApp() {
496
531
  >
497
532
  +
498
533
  </button>
534
+ <button
535
+ type="button"
536
+ onClick={toggleTimelineVisibility}
537
+ className="ml-1 flex h-7 w-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-900 hover:text-neutral-200"
538
+ title={getTimelineToggleTitle(true)}
539
+ aria-label="Hide timeline editor"
540
+ >
541
+ <svg
542
+ width="14"
543
+ height="14"
544
+ viewBox="0 0 24 24"
545
+ fill="none"
546
+ stroke="currentColor"
547
+ strokeWidth="1.8"
548
+ strokeLinecap="round"
549
+ strokeLinejoin="round"
550
+ aria-hidden="true"
551
+ >
552
+ <path d="M5 7h14" />
553
+ <path d="m8 11 4 4 4-4" />
554
+ </svg>
555
+ </button>
499
556
  </div>
500
557
  </div>
501
558
  </div>
@@ -787,6 +844,42 @@ export function StudioApp() {
787
844
  toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
788
845
  }, []);
789
846
 
847
+ const handleCaptureFrameClick = useCallback(
848
+ async (event: MouseEvent<HTMLAnchorElement>) => {
849
+ if (!projectId) return;
850
+ event.preventDefault();
851
+
852
+ const currentTime = usePlayerStore.getState().currentTime;
853
+ setCaptureFrameTime(currentTime);
854
+ const href = buildFrameCaptureUrl({
855
+ projectId,
856
+ compositionPath: activeCompPath,
857
+ currentTime,
858
+ });
859
+ const filename = buildFrameCaptureFilename(activeCompPath, currentTime);
860
+
861
+ try {
862
+ const response = await fetch(href, { cache: "no-store" });
863
+ if (!response.ok) {
864
+ throw new Error(`Capture failed (${response.status})`);
865
+ }
866
+ const blob = await response.blob();
867
+ const blobUrl = URL.createObjectURL(blob);
868
+ const link = document.createElement("a");
869
+ link.href = blobUrl;
870
+ link.download = filename;
871
+ document.body.appendChild(link);
872
+ link.click();
873
+ link.remove();
874
+ setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
875
+ } catch (err) {
876
+ const message = err instanceof Error ? err.message : "Capture failed";
877
+ showToast(message);
878
+ }
879
+ },
880
+ [activeCompPath, projectId, showToast],
881
+ );
882
+
790
883
  const handleTimelineElementDelete = useCallback(
791
884
  async (element: TimelineElement) => {
792
885
  const pid = projectIdRef.current;
@@ -1345,55 +1438,19 @@ export function StudioApp() {
1345
1438
  </div>
1346
1439
  {/* Right: toolbar buttons */}
1347
1440
  <div className="flex items-center gap-1.5">
1348
- <button
1349
- onClick={() => setLeftCollapsed((v) => !v)}
1350
- className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
1351
- !leftCollapsed
1352
- ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
1353
- : "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
1354
- }`}
1355
- title={leftCollapsed ? "Show sidebar" : "Hide sidebar"}
1356
- >
1357
- <svg
1358
- width="14"
1359
- height="14"
1360
- viewBox="0 0 24 24"
1361
- fill="none"
1362
- stroke="currentColor"
1363
- strokeWidth="1.5"
1364
- strokeLinecap="round"
1365
- strokeLinejoin="round"
1366
- >
1367
- <rect x="3" y="3" width="18" height="18" rx="2" />
1368
- <path d="M9 3v18" />
1369
- </svg>
1370
- </button>
1371
- <button
1372
- type="button"
1373
- onClick={toggleTimelineVisibility}
1374
- className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
1375
- timelineVisible
1376
- ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
1377
- : "text-neutral-300 border-neutral-700 hover:border-neutral-500 hover:bg-neutral-800"
1378
- }`}
1379
- title={getTimelineToggleTitle(timelineVisible)}
1380
- aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
1441
+ <a
1442
+ href={captureFrameHref}
1443
+ download={captureFrameFilename}
1444
+ onClick={handleCaptureFrameClick}
1445
+ onFocus={refreshCaptureFrameTime}
1446
+ onPointerDown={refreshCaptureFrameTime}
1447
+ className="h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border border-neutral-700 text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
1448
+ title="Capture current frame"
1449
+ aria-label="Capture current frame"
1381
1450
  >
1382
- <svg
1383
- width="14"
1384
- height="14"
1385
- viewBox="0 0 24 24"
1386
- fill="none"
1387
- stroke="currentColor"
1388
- strokeWidth="1.5"
1389
- strokeLinecap="round"
1390
- >
1391
- <rect x="3" y="13" width="18" height="8" rx="1" />
1392
- <line x1="3" y1="9" x2="21" y2="9" />
1393
- <line x1="3" y1="5" x2="21" y2="5" />
1394
- </svg>
1395
- <span>Timeline</span>
1396
- </button>
1451
+ <Camera size={14} />
1452
+ <span>Capture</span>
1453
+ </a>
1397
1454
  <button
1398
1455
  onClick={() => setRightCollapsed((v) => !v)}
1399
1456
  className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
@@ -1422,7 +1479,32 @@ export function StudioApp() {
1422
1479
  {/* Main content: sidebar + preview + right panel */}
1423
1480
  <div className="flex flex-1 min-h-0">
1424
1481
  {/* Left sidebar: Compositions + Assets (resizable, collapsible) */}
1425
- {!leftCollapsed && (
1482
+ {leftCollapsed ? (
1483
+ <div className="flex w-10 flex-shrink-0 flex-col items-center border-r border-neutral-800/50 bg-neutral-950 pt-1">
1484
+ <button
1485
+ type="button"
1486
+ onClick={toggleLeftSidebar}
1487
+ className="flex h-8 w-8 items-center justify-center rounded-md border border-transparent text-neutral-500 transition-colors hover:border-neutral-800 hover:bg-neutral-900 hover:text-neutral-300"
1488
+ title="Show sidebar"
1489
+ aria-label="Show sidebar"
1490
+ >
1491
+ <svg
1492
+ width="14"
1493
+ height="14"
1494
+ viewBox="0 0 24 24"
1495
+ fill="none"
1496
+ stroke="currentColor"
1497
+ strokeWidth="1.5"
1498
+ strokeLinecap="round"
1499
+ strokeLinejoin="round"
1500
+ aria-hidden="true"
1501
+ >
1502
+ <path d="M5 4v16" />
1503
+ <path d="m10 7 5 5-5 5" />
1504
+ </svg>
1505
+ </button>
1506
+ </div>
1507
+ ) : (
1426
1508
  <LeftSidebar
1427
1509
  width={leftWidth}
1428
1510
  projectId={projectId}
@@ -1469,6 +1551,7 @@ export function StudioApp() {
1469
1551
  }
1470
1552
  onLint={handleLint}
1471
1553
  linting={linting}
1554
+ onToggleCollapse={toggleLeftSidebar}
1472
1555
  />
1473
1556
  )}
1474
1557
 
@@ -5,6 +5,10 @@ import type { TimelineElement } from "../../player";
5
5
  import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
6
6
  import { NLEPreview } from "./NLEPreview";
7
7
  import { CompositionBreadcrumb, type CompositionLevel } from "./CompositionBreadcrumb";
8
+ import {
9
+ TIMELINE_TOGGLE_SHORTCUT_LABEL,
10
+ getTimelineToggleTitle,
11
+ } from "../../utils/timelineDiscovery";
8
12
 
9
13
  interface NLELayoutProps {
10
14
  projectId: string;
@@ -197,6 +201,7 @@ export const NLELayout = memo(function NLELayout({
197
201
 
198
202
  // Resizable timeline height
199
203
  const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
204
+ const isTimelineVisible = timelineVisible ?? true;
200
205
  const isDragging = useRef(false);
201
206
  const containerRef = useRef<HTMLDivElement>(null);
202
207
 
@@ -366,16 +371,11 @@ export const NLELayout = memo(function NLELayout({
366
371
  onNavigate={handleNavigateComposition}
367
372
  />
368
373
  )}
369
- <PlayerControls
370
- onTogglePlay={togglePlay}
371
- onSeek={seek}
372
- timelineVisible={timelineVisible ?? true}
373
- onToggleTimeline={onToggleTimeline}
374
- />
374
+ <PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
375
375
  </div>
376
376
  </div>
377
377
 
378
- {(timelineVisible ?? true) && (
378
+ {isTimelineVisible ? (
379
379
  <>
380
380
  {/* Resize divider */}
381
381
  <div
@@ -417,7 +417,42 @@ export const NLELayout = memo(function NLELayout({
417
417
  {timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
418
418
  </div>
419
419
  </>
420
- )}
420
+ ) : onToggleTimeline ? (
421
+ <div className="flex-shrink-0 border-t border-neutral-800/50 bg-neutral-950/96">
422
+ <div className="flex h-10 items-center justify-between px-3">
423
+ <div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
424
+ Timeline
425
+ </div>
426
+ <button
427
+ type="button"
428
+ onClick={onToggleTimeline}
429
+ className="flex h-7 items-center gap-1.5 rounded-md border border-neutral-800 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-700 hover:bg-neutral-900 hover:text-neutral-100"
430
+ title={getTimelineToggleTitle(false)}
431
+ aria-label="Show timeline editor"
432
+ >
433
+ <svg
434
+ width="13"
435
+ height="13"
436
+ viewBox="0 0 24 24"
437
+ fill="none"
438
+ stroke="currentColor"
439
+ strokeWidth="1.7"
440
+ strokeLinecap="round"
441
+ strokeLinejoin="round"
442
+ aria-hidden="true"
443
+ >
444
+ <rect x="3" y="13" width="18" height="8" rx="1" />
445
+ <path d="M7 9h10" />
446
+ <path d="M8 5h8" />
447
+ </svg>
448
+ <span>Show</span>
449
+ <span className="hidden rounded bg-white/5 px-1 py-0.5 font-mono text-[9px] text-neutral-500 sm:inline">
450
+ {TIMELINE_TOGGLE_SHORTCUT_LABEL}
451
+ </span>
452
+ </button>
453
+ </div>
454
+ </div>
455
+ ) : null}
421
456
  </div>
422
457
  );
423
458
  });
@@ -35,6 +35,7 @@ interface LeftSidebarProps {
35
35
  codeChildren?: ReactNode;
36
36
  onLint?: () => void;
37
37
  linting?: boolean;
38
+ onToggleCollapse?: () => void;
38
39
  }
39
40
 
40
41
  export const LeftSidebar = memo(function LeftSidebar({
@@ -57,6 +58,7 @@ export const LeftSidebar = memo(function LeftSidebar({
57
58
  codeChildren,
58
59
  onLint,
59
60
  linting,
61
+ onToggleCollapse,
60
62
  }: LeftSidebarProps) {
61
63
  const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
62
64
 
@@ -122,6 +124,30 @@ export const LeftSidebar = memo(function LeftSidebar({
122
124
  >
123
125
  Assets
124
126
  </button>
127
+ {onToggleCollapse && (
128
+ <button
129
+ type="button"
130
+ onClick={onToggleCollapse}
131
+ className="mx-1 my-1 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md border border-transparent text-neutral-500 transition-colors hover:border-neutral-800 hover:bg-neutral-900 hover:text-neutral-300"
132
+ title="Hide sidebar"
133
+ aria-label="Hide sidebar"
134
+ >
135
+ <svg
136
+ width="14"
137
+ height="14"
138
+ viewBox="0 0 24 24"
139
+ fill="none"
140
+ stroke="currentColor"
141
+ strokeWidth="1.5"
142
+ strokeLinecap="round"
143
+ strokeLinejoin="round"
144
+ aria-hidden="true"
145
+ >
146
+ <path d="m14 7-5 5 5 5" />
147
+ <path d="M19 4v16" />
148
+ </svg>
149
+ </button>
150
+ )}
125
151
  </div>
126
152
 
127
153
  {/* Tab content */}
@@ -53,6 +53,7 @@ import {
53
53
  CaretRight,
54
54
  ClipboardText,
55
55
  ArrowCounterClockwise,
56
+ Camera as PhCamera,
56
57
  Gear,
57
58
  } from "@phosphor-icons/react";
58
59
  import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
@@ -127,4 +128,5 @@ export const ChevronDown = makeIcon(CaretDown);
127
128
  export const ChevronRight = makeIcon(CaretRight);
128
129
  export const ClipboardList = makeIcon(ClipboardText);
129
130
  export const RotateCcw = makeIcon(ArrowCounterClockwise);
131
+ export const Camera = makeIcon(PhCamera);
130
132
  export const Settings = makeIcon(Gear);
@@ -1,10 +1,6 @@
1
1
  import { useRef, useState, useCallback, useEffect, memo } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
- import {
4
- TIMELINE_TOGGLE_SHORTCUT_LABEL,
5
- getTimelineToggleTitle,
6
- } from "../../utils/timelineDiscovery";
7
- import { formatFrameTime, frameToSeconds, formatTime } from "../lib/time";
3
+ import { formatFrameTime, frameToSeconds, stepFrameTime, formatTime } from "../lib/time";
8
4
  import { usePlayerStore, liveTime } from "../store/playerStore";
9
5
 
10
6
  const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
@@ -30,15 +26,11 @@ export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth:
30
26
  interface PlayerControlsProps {
31
27
  onTogglePlay: () => void;
32
28
  onSeek: (time: number) => void;
33
- timelineVisible?: boolean;
34
- onToggleTimeline?: () => void;
35
29
  }
36
30
 
37
31
  export const PlayerControls = memo(function PlayerControls({
38
32
  onTogglePlay,
39
33
  onSeek,
40
- timelineVisible,
41
- onToggleTimeline,
42
34
  }: PlayerControlsProps) {
43
35
  // Subscribe to only the fields we render — each selector prevents cascading re-renders
44
36
  const isPlaying = usePlayerStore((s) => s.isPlaying);
@@ -216,10 +208,10 @@ export const PlayerControls = memo(function PlayerControls({
216
208
  const step = e.shiftKey ? 10 : 1;
217
209
  if (e.key === "ArrowLeft") {
218
210
  e.preventDefault();
219
- onSeek(Math.max(0, currentTimeRef.current - frameToSeconds(step)));
211
+ onSeek(stepFrameTime(currentTimeRef.current, -step));
220
212
  } else if (e.key === "ArrowRight") {
221
213
  e.preventDefault();
222
- onSeek(Math.min(duration, currentTimeRef.current + frameToSeconds(step)));
214
+ onSeek(Math.min(duration, stepFrameTime(currentTimeRef.current, step)));
223
215
  }
224
216
  },
225
217
  [timelineReady, duration, onSeek],
@@ -437,39 +429,6 @@ export const PlayerControls = memo(function PlayerControls({
437
429
  </span>
438
430
  ))}
439
431
  </div>
440
-
441
- {/* Timeline toggle */}
442
- {onToggleTimeline !== undefined && (
443
- <button
444
- type="button"
445
- onClick={onToggleTimeline}
446
- className={`h-7 flex items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-medium transition-colors ${
447
- timelineVisible
448
- ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
449
- : "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
450
- }`}
451
- title={getTimelineToggleTitle(Boolean(timelineVisible))}
452
- aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
453
- >
454
- <svg
455
- width="13"
456
- height="13"
457
- viewBox="0 0 24 24"
458
- fill="none"
459
- stroke="currentColor"
460
- strokeWidth="2"
461
- strokeLinecap="round"
462
- >
463
- <rect x="3" y="13" width="18" height="8" rx="1" />
464
- <line x1="3" y1="9" x2="21" y2="9" />
465
- <line x1="3" y1="5" x2="21" y2="5" />
466
- </svg>
467
- <span>Timeline</span>
468
- <span className="hidden md:inline rounded bg-black/20 px-1 py-0.5 text-[9px] font-mono opacity-70">
469
- {TIMELINE_TOGGLE_SHORTCUT_LABEL}
470
- </span>
471
- </button>
472
- )}
473
432
  </div>
474
433
  );
475
434
  });
@@ -1014,7 +1014,10 @@ export const Timeline = memo(function Timeline({
1014
1014
  major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
1015
1015
  const getPreviewElement = useCallback(
1016
1016
  (element: TimelineElement): TimelineElement => {
1017
- if (resizingClip?.element.id === element.id) {
1017
+ if (
1018
+ resizingClip &&
1019
+ (resizingClip.element.key ?? resizingClip.element.id) === (element.key ?? element.id)
1020
+ ) {
1018
1021
  return {
1019
1022
  ...element,
1020
1023
  start: resizingClip.previewStart,
@@ -1242,7 +1245,7 @@ export const Timeline = memo(function Timeline({
1242
1245
  draggedClip?.started === true && draggedElement
1243
1246
  ? getRenderedTimelineElement({
1244
1247
  element: draggedElement,
1245
- draggedElementId: draggedElement.id,
1248
+ draggedElementId: draggedElement.key ?? draggedElement.id,
1246
1249
  previewStart: draggedClip.previewStart,
1247
1250
  previewTrack: draggedClip.previewTrack,
1248
1251
  })
@@ -61,6 +61,7 @@ export const TimelineClip = memo(function TimelineClip({
61
61
  ? theme.clipShadowHover
62
62
  : theme.clipShadow;
63
63
  const capabilities = getTimelineEditCapabilities(el);
64
+ const displayLabel = el.label || el.id || el.tag;
64
65
  const showHandles = handleOpacity > 0.01;
65
66
 
66
67
  return (
@@ -93,7 +94,7 @@ export const TimelineClip = memo(function TimelineClip({
93
94
  title={
94
95
  isComposition
95
96
  ? `${el.compositionSrc} \u2022 Double-click to open`
96
- : `${el.id || el.tag} \u2022 ${el.start.toFixed(1)}s \u2013 ${(el.start + el.duration).toFixed(1)}s`
97
+ : `${displayLabel} \u2022 ${el.start.toFixed(1)}s \u2013 ${(el.start + el.duration).toFixed(1)}s`
97
98
  }
98
99
  onPointerEnter={onHoverStart}
99
100
  onPointerLeave={onHoverEnd}
@@ -53,4 +53,23 @@ describe("getRenderedTimelineElement", () => {
53
53
  }),
54
54
  ).toEqual({ ...element, start: 2.4, track: 3 });
55
55
  });
56
+
57
+ it("uses key before id when matching the dragged clip", () => {
58
+ const element = {
59
+ id: "Card",
60
+ key: "index.html:.card:1",
61
+ tag: "div",
62
+ start: 1,
63
+ duration: 2,
64
+ track: 0,
65
+ };
66
+ expect(
67
+ getRenderedTimelineElement({
68
+ element,
69
+ draggedElementId: "index.html:.card:1",
70
+ previewStart: 2.4,
71
+ previewTrack: 3,
72
+ }),
73
+ ).toEqual({ ...element, start: 2.4, track: 3 });
74
+ });
56
75
  });
@@ -63,12 +63,12 @@ const TRACK_STYLES: Record<string, TimelineTrackStyle> = {
63
63
  const DEFAULT_TRACK_STYLE: TimelineTrackStyle = createTrackStyle();
64
64
 
65
65
  export const defaultTimelineTheme: TimelineTheme = {
66
- shellBackground: "#0A0E15",
66
+ shellBackground: "#0A0A0B",
67
67
  shellBorder: "rgba(255,255,255,0.05)",
68
68
  rulerBorder: "rgba(255,255,255,0.045)",
69
- rowBackground: "#0A0E15",
69
+ rowBackground: "#0A0A0B",
70
70
  rowBorder: "rgba(255,255,255,0.05)",
71
- gutterBackground: "#0D121B",
71
+ gutterBackground: "#0A0A0B",
72
72
  gutterBorder: "rgba(255,255,255,0.05)",
73
73
  textPrimary: "#E8EDF5",
74
74
  textSecondary: "#8391A8",
@@ -130,7 +130,11 @@ export function getRenderedTimelineElement({
130
130
  previewStart: number | null;
131
131
  previewTrack: number | null;
132
132
  }): TimelineElement {
133
- if (element.id !== draggedElementId || previewStart === null || previewTrack === null) {
133
+ if (
134
+ (element.key ?? element.id) !== draggedElementId ||
135
+ previewStart === null ||
136
+ previewTrack === null
137
+ ) {
134
138
  return element;
135
139
  }
136
140
  return {