@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/assets/index-BLrgRQSu.css +1 -0
- package/dist/assets/index-D4-n3yWG.js +93 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +139 -56
- package/src/components/nle/NLELayout.tsx +43 -8
- package/src/components/sidebar/LeftSidebar.tsx +26 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/PlayerControls.tsx +3 -44
- package/src/player/components/Timeline.tsx +5 -2
- package/src/player/components/TimelineClip.tsx +2 -1
- package/src/player/components/timelineTheme.test.ts +19 -0
- package/src/player/components/timelineTheme.ts +8 -4
- package/src/player/hooks/useTimelinePlayer.test.ts +198 -0
- package/src/player/hooks/useTimelinePlayer.ts +263 -108
- package/src/player/lib/time.test.ts +11 -1
- package/src/player/lib/time.ts +6 -0
- package/src/player/store/playerStore.ts +1 -0
- package/src/utils/frameCapture.test.ts +26 -0
- package/src/utils/frameCapture.ts +38 -0
- package/dist/assets/index-Bj3m6A02.js +0 -93
- package/dist/assets/index-_h8opaGY.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-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
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.
|
|
36
|
-
"@hyperframes/player": "0.4.
|
|
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.
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
<
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
title=
|
|
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
|
-
<
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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(
|
|
211
|
+
onSeek(stepFrameTime(currentTimeRef.current, -step));
|
|
220
212
|
} else if (e.key === "ArrowRight") {
|
|
221
213
|
e.preventDefault();
|
|
222
|
-
onSeek(Math.min(duration, currentTimeRef.current
|
|
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 (
|
|
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
|
-
: `${
|
|
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: "#
|
|
66
|
+
shellBackground: "#0A0A0B",
|
|
67
67
|
shellBorder: "rgba(255,255,255,0.05)",
|
|
68
68
|
rulerBorder: "rgba(255,255,255,0.045)",
|
|
69
|
-
rowBackground: "#
|
|
69
|
+
rowBackground: "#0A0A0B",
|
|
70
70
|
rowBorder: "rgba(255,255,255,0.05)",
|
|
71
|
-
gutterBackground: "#
|
|
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 (
|
|
133
|
+
if (
|
|
134
|
+
(element.key ?? element.id) !== draggedElementId ||
|
|
135
|
+
previewStart === null ||
|
|
136
|
+
previewTrack === null
|
|
137
|
+
) {
|
|
134
138
|
return element;
|
|
135
139
|
}
|
|
136
140
|
return {
|