@hyperframes/studio 0.5.0-alpha.8 → 0.5.0-alpha.9
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-DKaNgV2Z.css +1 -0
- package/dist/assets/index-peNJzL-4.js +105 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +2 -0
- package/src/captions/components/CaptionOverlay.tsx +2 -1
- package/src/captions/keyboard.test.ts +38 -0
- package/src/captions/keyboard.ts +8 -0
- package/src/components/editor/DomEditOverlay.tsx +5 -2
- package/src/components/editor/PropertyPanel.tsx +7 -3
- package/src/components/nle/NLEPreview.tsx +5 -1
- package/src/player/components/PlayerControls.tsx +120 -11
- package/src/player/hooks/useTimelinePlayer.test.ts +79 -0
- package/src/player/hooks/useTimelinePlayer.ts +284 -16
- package/src/player/lib/time.test.ts +19 -1
- package/src/player/lib/time.ts +20 -0
- package/src/player/store/playerStore.test.ts +11 -1
- package/src/player/store/playerStore.ts +5 -1
- package/dist/assets/index-0Zt0t13W.css +0 -1
- package/dist/assets/index-C9f5eif8.js +0 -105
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useRef, useCallback } from "react";
|
|
2
2
|
import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore";
|
|
3
3
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
|
+
import { frameToSeconds, STUDIO_PREVIEW_FPS } from "../lib/time";
|
|
5
|
+
import { useCaptionStore } from "../../captions/store";
|
|
4
6
|
|
|
5
7
|
interface PlaybackAdapter {
|
|
6
8
|
play: () => void;
|
|
@@ -105,6 +107,64 @@ function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): voi
|
|
|
105
107
|
}
|
|
106
108
|
}
|
|
107
109
|
|
|
110
|
+
const SHUTTLE_SPEEDS = [1, 2, 4] as const;
|
|
111
|
+
const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
|
|
112
|
+
const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
|
|
113
|
+
"input",
|
|
114
|
+
"textarea",
|
|
115
|
+
"select",
|
|
116
|
+
"button",
|
|
117
|
+
"a[href]",
|
|
118
|
+
"[contenteditable='true']",
|
|
119
|
+
"[role='button']",
|
|
120
|
+
"[role='checkbox']",
|
|
121
|
+
"[role='combobox']",
|
|
122
|
+
"[role='menuitem']",
|
|
123
|
+
"[role='radio']",
|
|
124
|
+
"[role='slider']",
|
|
125
|
+
"[role='spinbutton']",
|
|
126
|
+
"[role='switch']",
|
|
127
|
+
"[role='textbox']",
|
|
128
|
+
].join(",");
|
|
129
|
+
|
|
130
|
+
export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null): boolean {
|
|
131
|
+
if (!target || typeof target !== "object") return false;
|
|
132
|
+
const candidate = target as { closest?: unknown };
|
|
133
|
+
if (typeof candidate.closest !== "function") return false;
|
|
134
|
+
return (
|
|
135
|
+
(candidate.closest as (selector: string) => Element | null).call(
|
|
136
|
+
target,
|
|
137
|
+
PLAYBACK_SHORTCUT_IGNORED_SELECTOR,
|
|
138
|
+
) !== null
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface PlaybackShortcutCaptionState {
|
|
143
|
+
isCaptionEditMode: boolean;
|
|
144
|
+
selectedCaptionSegmentCount: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
type PlaybackShortcutEvent = Pick<
|
|
148
|
+
KeyboardEvent,
|
|
149
|
+
"altKey" | "ctrlKey" | "metaKey" | "code" | "target"
|
|
150
|
+
>;
|
|
151
|
+
|
|
152
|
+
export function shouldIgnorePlaybackShortcutEvent(
|
|
153
|
+
event: PlaybackShortcutEvent,
|
|
154
|
+
captionState: PlaybackShortcutCaptionState = {
|
|
155
|
+
isCaptionEditMode: false,
|
|
156
|
+
selectedCaptionSegmentCount: 0,
|
|
157
|
+
},
|
|
158
|
+
): boolean {
|
|
159
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return true;
|
|
160
|
+
if (shouldIgnorePlaybackShortcutTarget(event.target)) return true;
|
|
161
|
+
return (
|
|
162
|
+
PLAYBACK_FRAME_STEP_CODES.has(event.code) &&
|
|
163
|
+
captionState.isCaptionEditMode &&
|
|
164
|
+
captionState.selectedCaptionSegmentCount > 0
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
108
168
|
/**
|
|
109
169
|
* Parse [data-start] elements from a Document into TimelineElement[].
|
|
110
170
|
* Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
|
|
@@ -452,6 +512,13 @@ export function useTimelinePlayer() {
|
|
|
452
512
|
const probeIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
|
453
513
|
const pendingSeekRef = useRef<number | null>(null);
|
|
454
514
|
const isRefreshingRef = useRef(false);
|
|
515
|
+
const reverseRafRef = useRef<number>(0);
|
|
516
|
+
const shuttleDirectionRef = useRef<"forward" | "backward" | null>(null);
|
|
517
|
+
const shuttleSpeedIndexRef = useRef(0);
|
|
518
|
+
const pressedCodesRef = useRef(new Set<string>());
|
|
519
|
+
const iframeShortcutCleanupRef = useRef<(() => void) | null>(null);
|
|
520
|
+
const playbackKeyDownRef = useRef<(e: KeyboardEvent) => void>(() => {});
|
|
521
|
+
const playbackKeyUpRef = useRef<(e: KeyboardEvent) => void>(() => {});
|
|
455
522
|
|
|
456
523
|
// ZERO store subscriptions — this hook never causes re-renders.
|
|
457
524
|
// All reads use getState() (point-in-time), all writes use the stable setters.
|
|
@@ -510,6 +577,10 @@ export function useTimelinePlayer() {
|
|
|
510
577
|
}
|
|
511
578
|
}, []);
|
|
512
579
|
|
|
580
|
+
const stopReverseLoop = useCallback(() => {
|
|
581
|
+
cancelAnimationFrame(reverseRafRef.current);
|
|
582
|
+
}, []);
|
|
583
|
+
|
|
513
584
|
const startRAFLoop = useCallback(() => {
|
|
514
585
|
const tick = () => {
|
|
515
586
|
const adapter = getAdapter();
|
|
@@ -518,6 +589,14 @@ export function useTimelinePlayer() {
|
|
|
518
589
|
const dur = adapter.getDuration();
|
|
519
590
|
liveTime.notify(time); // direct DOM updates, no React re-render
|
|
520
591
|
if (time >= dur && !adapter.isPlaying()) {
|
|
592
|
+
if (usePlayerStore.getState().loopEnabled && dur > 0) {
|
|
593
|
+
adapter.seek(0);
|
|
594
|
+
liveTime.notify(0);
|
|
595
|
+
adapter.play();
|
|
596
|
+
setIsPlaying(true);
|
|
597
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
521
600
|
setCurrentTime(time); // sync Zustand once at end
|
|
522
601
|
setIsPlaying(false);
|
|
523
602
|
cancelAnimationFrame(rafRef.current);
|
|
@@ -560,6 +639,8 @@ export function useTimelinePlayer() {
|
|
|
560
639
|
}, []);
|
|
561
640
|
|
|
562
641
|
const play = useCallback(() => {
|
|
642
|
+
stopRAFLoop();
|
|
643
|
+
stopReverseLoop();
|
|
563
644
|
const adapter = getAdapter();
|
|
564
645
|
if (!adapter) return;
|
|
565
646
|
if (adapter.getTime() >= adapter.getDuration()) {
|
|
@@ -568,18 +649,68 @@ export function useTimelinePlayer() {
|
|
|
568
649
|
unmutePreviewMedia(iframeRef.current);
|
|
569
650
|
applyPlaybackRate(usePlayerStore.getState().playbackRate);
|
|
570
651
|
adapter.play();
|
|
652
|
+
shuttleDirectionRef.current = "forward";
|
|
571
653
|
setIsPlaying(true);
|
|
572
654
|
startRAFLoop();
|
|
573
|
-
}, [getAdapter, setIsPlaying, startRAFLoop, applyPlaybackRate]);
|
|
655
|
+
}, [getAdapter, setIsPlaying, startRAFLoop, applyPlaybackRate, stopRAFLoop, stopReverseLoop]);
|
|
656
|
+
|
|
657
|
+
const playBackward = useCallback(
|
|
658
|
+
(rate: number) => {
|
|
659
|
+
stopRAFLoop();
|
|
660
|
+
stopReverseLoop();
|
|
661
|
+
const adapter = getAdapter();
|
|
662
|
+
if (!adapter) return;
|
|
663
|
+
const duration = Math.max(0, adapter.getDuration());
|
|
664
|
+
const initialTime = adapter.getTime() <= 0 && duration > 0 ? duration : adapter.getTime();
|
|
665
|
+
adapter.pause();
|
|
666
|
+
if (initialTime !== adapter.getTime()) adapter.seek(initialTime);
|
|
667
|
+
unmutePreviewMedia(iframeRef.current);
|
|
668
|
+
const speed = Math.max(0.1, Math.min(4, rate));
|
|
669
|
+
let startTime = initialTime;
|
|
670
|
+
let startedAt = performance.now();
|
|
671
|
+
|
|
672
|
+
const tick = (now: number) => {
|
|
673
|
+
const elapsed = ((now - startedAt) / 1000) * speed;
|
|
674
|
+
let nextTime = startTime - elapsed;
|
|
675
|
+
if (nextTime <= 0) {
|
|
676
|
+
if (usePlayerStore.getState().loopEnabled && duration > 0) {
|
|
677
|
+
startTime = duration;
|
|
678
|
+
startedAt = now;
|
|
679
|
+
nextTime = duration;
|
|
680
|
+
} else {
|
|
681
|
+
adapter.seek(0);
|
|
682
|
+
liveTime.notify(0);
|
|
683
|
+
setCurrentTime(0);
|
|
684
|
+
setIsPlaying(false);
|
|
685
|
+
shuttleDirectionRef.current = null;
|
|
686
|
+
reverseRafRef.current = 0;
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
adapter.seek(Math.max(0, nextTime));
|
|
691
|
+
liveTime.notify(Math.max(0, nextTime));
|
|
692
|
+
setIsPlaying(true);
|
|
693
|
+
reverseRafRef.current = requestAnimationFrame(tick);
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
setIsPlaying(true);
|
|
697
|
+
shuttleDirectionRef.current = "backward";
|
|
698
|
+
reverseRafRef.current = requestAnimationFrame(tick);
|
|
699
|
+
},
|
|
700
|
+
[getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
|
|
701
|
+
);
|
|
574
702
|
|
|
575
703
|
const pause = useCallback(() => {
|
|
704
|
+
stopReverseLoop();
|
|
576
705
|
const adapter = getAdapter();
|
|
577
706
|
if (!adapter) return;
|
|
578
707
|
adapter.pause();
|
|
579
708
|
setCurrentTime(adapter.getTime()); // sync store so Split/Delete have accurate time
|
|
580
709
|
setIsPlaying(false);
|
|
710
|
+
shuttleDirectionRef.current = null;
|
|
711
|
+
shuttleSpeedIndexRef.current = 0;
|
|
581
712
|
stopRAFLoop();
|
|
582
|
-
}, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop]);
|
|
713
|
+
}, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]);
|
|
583
714
|
|
|
584
715
|
const togglePlay = useCallback(() => {
|
|
585
716
|
if (usePlayerStore.getState().isPlaying) {
|
|
@@ -591,18 +722,136 @@ export function useTimelinePlayer() {
|
|
|
591
722
|
|
|
592
723
|
const seek = useCallback(
|
|
593
724
|
(time: number) => {
|
|
725
|
+
stopReverseLoop();
|
|
594
726
|
const adapter = getAdapter();
|
|
595
727
|
if (!adapter) return;
|
|
596
|
-
adapter.
|
|
597
|
-
|
|
598
|
-
|
|
728
|
+
const duration = Math.max(0, adapter.getDuration());
|
|
729
|
+
const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time);
|
|
730
|
+
adapter.seek(nextTime);
|
|
731
|
+
liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render
|
|
732
|
+
setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
|
|
599
733
|
stopRAFLoop();
|
|
600
734
|
// Only update store if state actually changes (avoids unnecessary re-renders)
|
|
601
735
|
if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
|
|
736
|
+
shuttleDirectionRef.current = null;
|
|
737
|
+
shuttleSpeedIndexRef.current = 0;
|
|
738
|
+
},
|
|
739
|
+
[getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
const stepFrames = useCallback(
|
|
743
|
+
(deltaFrames: number) => {
|
|
744
|
+
const adapter = getAdapter();
|
|
745
|
+
const currentTime = adapter?.getTime() ?? usePlayerStore.getState().currentTime;
|
|
746
|
+
seek(currentTime + frameToSeconds(deltaFrames, STUDIO_PREVIEW_FPS));
|
|
602
747
|
},
|
|
603
|
-
[getAdapter,
|
|
748
|
+
[getAdapter, seek],
|
|
604
749
|
);
|
|
605
750
|
|
|
751
|
+
const shuttle = useCallback(
|
|
752
|
+
(direction: "forward" | "backward") => {
|
|
753
|
+
if (shuttleDirectionRef.current === direction) {
|
|
754
|
+
shuttleSpeedIndexRef.current = Math.min(
|
|
755
|
+
shuttleSpeedIndexRef.current + 1,
|
|
756
|
+
SHUTTLE_SPEEDS.length - 1,
|
|
757
|
+
);
|
|
758
|
+
} else {
|
|
759
|
+
shuttleSpeedIndexRef.current = 0;
|
|
760
|
+
}
|
|
761
|
+
const speed = SHUTTLE_SPEEDS[shuttleSpeedIndexRef.current];
|
|
762
|
+
usePlayerStore.getState().setPlaybackRate(speed);
|
|
763
|
+
if (direction === "forward") {
|
|
764
|
+
play();
|
|
765
|
+
} else {
|
|
766
|
+
playBackward(speed);
|
|
767
|
+
}
|
|
768
|
+
},
|
|
769
|
+
[play, playBackward],
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
const handlePlaybackKeyDown = useCallback(
|
|
773
|
+
(e: KeyboardEvent) => {
|
|
774
|
+
if (e.defaultPrevented) return;
|
|
775
|
+
const captionState = useCaptionStore.getState();
|
|
776
|
+
if (
|
|
777
|
+
shouldIgnorePlaybackShortcutEvent(e, {
|
|
778
|
+
isCaptionEditMode: captionState.isEditMode,
|
|
779
|
+
selectedCaptionSegmentCount: captionState.selectedSegmentIds.size,
|
|
780
|
+
})
|
|
781
|
+
) {
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
pressedCodesRef.current.add(e.code);
|
|
785
|
+
if (e.code === "Space") {
|
|
786
|
+
e.preventDefault();
|
|
787
|
+
togglePlay();
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (e.code === "ArrowLeft") {
|
|
791
|
+
e.preventDefault();
|
|
792
|
+
stepFrames(e.shiftKey ? -10 : -1);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (e.code === "ArrowRight") {
|
|
796
|
+
e.preventDefault();
|
|
797
|
+
stepFrames(e.shiftKey ? 10 : 1);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
if (e.repeat) return;
|
|
801
|
+
if (e.code === "KeyK") {
|
|
802
|
+
e.preventDefault();
|
|
803
|
+
pause();
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (e.code === "KeyJ") {
|
|
807
|
+
e.preventDefault();
|
|
808
|
+
if (pressedCodesRef.current.has("KeyK")) {
|
|
809
|
+
stepFrames(-1);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
shuttle("backward");
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
if (e.code === "KeyL") {
|
|
816
|
+
e.preventDefault();
|
|
817
|
+
if (pressedCodesRef.current.has("KeyK")) {
|
|
818
|
+
stepFrames(1);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
shuttle("forward");
|
|
822
|
+
}
|
|
823
|
+
},
|
|
824
|
+
[pause, shuttle, stepFrames, togglePlay],
|
|
825
|
+
);
|
|
826
|
+
|
|
827
|
+
const handlePlaybackKeyUp = useCallback((e: KeyboardEvent) => {
|
|
828
|
+
pressedCodesRef.current.delete(e.code);
|
|
829
|
+
}, []);
|
|
830
|
+
playbackKeyDownRef.current = handlePlaybackKeyDown;
|
|
831
|
+
playbackKeyUpRef.current = handlePlaybackKeyUp;
|
|
832
|
+
|
|
833
|
+
const attachIframeShortcutListeners = useCallback(() => {
|
|
834
|
+
iframeShortcutCleanupRef.current?.();
|
|
835
|
+
iframeShortcutCleanupRef.current = null;
|
|
836
|
+
|
|
837
|
+
const iframeWin = iframeRef.current?.contentWindow;
|
|
838
|
+
const iframeDoc = iframeRef.current?.contentDocument;
|
|
839
|
+
if (!iframeWin && !iframeDoc) return;
|
|
840
|
+
|
|
841
|
+
const handleIframeKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
|
|
842
|
+
const handleIframeKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
|
|
843
|
+
iframeWin?.addEventListener("keydown", handleIframeKeyDown, true);
|
|
844
|
+
iframeWin?.addEventListener("keyup", handleIframeKeyUp, true);
|
|
845
|
+
iframeDoc?.addEventListener("keydown", handleIframeKeyDown, true);
|
|
846
|
+
iframeDoc?.addEventListener("keyup", handleIframeKeyUp, true);
|
|
847
|
+
iframeShortcutCleanupRef.current = () => {
|
|
848
|
+
iframeWin?.removeEventListener("keydown", handleIframeKeyDown, true);
|
|
849
|
+
iframeWin?.removeEventListener("keyup", handleIframeKeyUp, true);
|
|
850
|
+
iframeDoc?.removeEventListener("keydown", handleIframeKeyDown, true);
|
|
851
|
+
iframeDoc?.removeEventListener("keyup", handleIframeKeyUp, true);
|
|
852
|
+
};
|
|
853
|
+
}, []);
|
|
854
|
+
|
|
606
855
|
// Convert a runtime timeline message (from iframe postMessage) into TimelineElements
|
|
607
856
|
const processTimelineMessage = useCallback(
|
|
608
857
|
(data: {
|
|
@@ -906,6 +1155,7 @@ export function useTimelinePlayer() {
|
|
|
906
1155
|
if (doc && iframeWin) {
|
|
907
1156
|
normalizePreviewViewport(doc, iframeWin);
|
|
908
1157
|
autoHealMissingCompositionIds(doc);
|
|
1158
|
+
attachIframeShortcutListeners();
|
|
909
1159
|
}
|
|
910
1160
|
|
|
911
1161
|
// Try reading __clipManifest if already available (fast path)
|
|
@@ -972,6 +1222,7 @@ export function useTimelinePlayer() {
|
|
|
972
1222
|
processTimelineMessage,
|
|
973
1223
|
enrichMissingCompositions,
|
|
974
1224
|
syncTimelineElements,
|
|
1225
|
+
attachIframeShortcutListeners,
|
|
975
1226
|
]);
|
|
976
1227
|
|
|
977
1228
|
/** Save the current playback time so the next onIframeLoad restores it. */
|
|
@@ -982,11 +1233,25 @@ export function useTimelinePlayer() {
|
|
|
982
1233
|
: (usePlayerStore.getState().currentTime ?? 0);
|
|
983
1234
|
isRefreshingRef.current = true;
|
|
984
1235
|
stopRAFLoop();
|
|
1236
|
+
stopReverseLoop();
|
|
985
1237
|
setIsPlaying(false);
|
|
986
|
-
}, [getAdapter, stopRAFLoop, setIsPlaying]);
|
|
1238
|
+
}, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
|
|
987
1239
|
|
|
988
1240
|
const togglePlayRef = useRef(togglePlay);
|
|
989
1241
|
togglePlayRef.current = togglePlay;
|
|
1242
|
+
|
|
1243
|
+
const refreshPlayer = useCallback(() => {
|
|
1244
|
+
const iframe = iframeRef.current;
|
|
1245
|
+
if (!iframe) return;
|
|
1246
|
+
|
|
1247
|
+
saveSeekPosition();
|
|
1248
|
+
|
|
1249
|
+
const src = iframe.src;
|
|
1250
|
+
const url = new URL(src, window.location.origin);
|
|
1251
|
+
url.searchParams.set("_t", String(Date.now()));
|
|
1252
|
+
iframe.src = url.toString();
|
|
1253
|
+
}, [saveSeekPosition]);
|
|
1254
|
+
|
|
990
1255
|
const getAdapterRef = useRef(getAdapter);
|
|
991
1256
|
getAdapterRef.current = getAdapter;
|
|
992
1257
|
const processTimelineMessageRef = useRef(processTimelineMessage);
|
|
@@ -995,12 +1260,8 @@ export function useTimelinePlayer() {
|
|
|
995
1260
|
enrichMissingCompositionsRef.current = enrichMissingCompositions;
|
|
996
1261
|
|
|
997
1262
|
useMountEffect(() => {
|
|
998
|
-
const
|
|
999
|
-
|
|
1000
|
-
e.preventDefault();
|
|
1001
|
-
togglePlayRef.current();
|
|
1002
|
-
}
|
|
1003
|
-
};
|
|
1263
|
+
const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
|
|
1264
|
+
const handleWindowKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
|
|
1004
1265
|
|
|
1005
1266
|
// Listen for timeline messages from the iframe runtime.
|
|
1006
1267
|
// The runtime sends this AFTER all external compositions load,
|
|
@@ -1073,14 +1334,19 @@ export function useTimelinePlayer() {
|
|
|
1073
1334
|
}
|
|
1074
1335
|
};
|
|
1075
1336
|
|
|
1076
|
-
window.addEventListener("keydown",
|
|
1337
|
+
window.addEventListener("keydown", handleWindowKeyDown, true);
|
|
1338
|
+
window.addEventListener("keyup", handleWindowKeyUp, true);
|
|
1077
1339
|
window.addEventListener("message", handleMessage);
|
|
1078
1340
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
1079
1341
|
return () => {
|
|
1080
|
-
window.removeEventListener("keydown",
|
|
1342
|
+
window.removeEventListener("keydown", handleWindowKeyDown, true);
|
|
1343
|
+
window.removeEventListener("keyup", handleWindowKeyUp, true);
|
|
1344
|
+
iframeShortcutCleanupRef.current?.();
|
|
1345
|
+
iframeShortcutCleanupRef.current = null;
|
|
1081
1346
|
window.removeEventListener("message", handleMessage);
|
|
1082
1347
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
1083
1348
|
stopRAFLoop();
|
|
1349
|
+
stopReverseLoop();
|
|
1084
1350
|
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
1085
1351
|
};
|
|
1086
1352
|
});
|
|
@@ -1088,9 +1354,10 @@ export function useTimelinePlayer() {
|
|
|
1088
1354
|
/** Reset the player store (elements, duration, etc.) — call when switching sessions. */
|
|
1089
1355
|
const resetPlayer = useCallback(() => {
|
|
1090
1356
|
stopRAFLoop();
|
|
1357
|
+
stopReverseLoop();
|
|
1091
1358
|
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
1092
1359
|
usePlayerStore.getState().reset();
|
|
1093
|
-
}, [stopRAFLoop]);
|
|
1360
|
+
}, [stopRAFLoop, stopReverseLoop]);
|
|
1094
1361
|
|
|
1095
1362
|
return {
|
|
1096
1363
|
iframeRef,
|
|
@@ -1099,6 +1366,7 @@ export function useTimelinePlayer() {
|
|
|
1099
1366
|
togglePlay,
|
|
1100
1367
|
seek,
|
|
1101
1368
|
onIframeLoad,
|
|
1369
|
+
refreshPlayer,
|
|
1102
1370
|
saveSeekPosition,
|
|
1103
1371
|
resetPlayer,
|
|
1104
1372
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { formatTime } from "./time";
|
|
2
|
+
import { formatFrameTime, frameToSeconds, secondsToFrame, formatTime } from "./time";
|
|
3
3
|
|
|
4
4
|
describe("formatTime", () => {
|
|
5
5
|
it("formats zero seconds", () => {
|
|
@@ -55,3 +55,21 @@ describe("formatTime", () => {
|
|
|
55
55
|
expect(formatTime(Infinity)).toBe("0:00");
|
|
56
56
|
});
|
|
57
57
|
});
|
|
58
|
+
|
|
59
|
+
describe("frame helpers", () => {
|
|
60
|
+
it("converts seconds to frames at the Studio preview rate", () => {
|
|
61
|
+
expect(secondsToFrame(0)).toBe(0);
|
|
62
|
+
expect(secondsToFrame(1)).toBe(30);
|
|
63
|
+
expect(secondsToFrame(1.5)).toBe(45);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("converts frames to seconds at the Studio preview rate", () => {
|
|
67
|
+
expect(frameToSeconds(0)).toBe(0);
|
|
68
|
+
expect(frameToSeconds(30)).toBe(1);
|
|
69
|
+
expect(frameToSeconds(45)).toBe(1.5);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("formats current and total frame display", () => {
|
|
73
|
+
expect(formatFrameTime(1, 5)).toBe("30f / 150f");
|
|
74
|
+
});
|
|
75
|
+
});
|
package/src/player/lib/time.ts
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
|
+
export const STUDIO_PREVIEW_FPS = 30;
|
|
2
|
+
|
|
1
3
|
export function formatTime(time: number): string {
|
|
2
4
|
if (!Number.isFinite(time) || time < 0) return "0:00";
|
|
3
5
|
const mins = Math.floor(time / 60);
|
|
4
6
|
const secs = Math.floor(time % 60);
|
|
5
7
|
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
|
6
8
|
}
|
|
9
|
+
|
|
10
|
+
export function secondsToFrame(time: number, fps = STUDIO_PREVIEW_FPS): number {
|
|
11
|
+
if (!Number.isFinite(time) || time <= 0) return 0;
|
|
12
|
+
if (!Number.isFinite(fps) || fps <= 0) return 0;
|
|
13
|
+
return Math.round(time * fps);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function frameToSeconds(frame: number, fps = STUDIO_PREVIEW_FPS): number {
|
|
17
|
+
if (!Number.isFinite(frame) || frame <= 0) return 0;
|
|
18
|
+
if (!Number.isFinite(fps) || fps <= 0) return 0;
|
|
19
|
+
return frame / fps;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatFrameTime(time: number, duration: number, fps = STUDIO_PREVIEW_FPS): string {
|
|
23
|
+
const currentFrame = secondsToFrame(time, fps);
|
|
24
|
+
const totalFrames = secondsToFrame(duration, fps);
|
|
25
|
+
return `${currentFrame}f / ${totalFrames}f`;
|
|
26
|
+
}
|
|
@@ -16,6 +16,7 @@ describe("usePlayerStore", () => {
|
|
|
16
16
|
expect(state.elements).toEqual([]);
|
|
17
17
|
expect(state.selectedElementId).toBeNull();
|
|
18
18
|
expect(state.playbackRate).toBe(1);
|
|
19
|
+
expect(state.loopEnabled).toBe(false);
|
|
19
20
|
expect(state.zoomMode).toBe("fit");
|
|
20
21
|
expect(state.manualZoomPercent).toBe(100);
|
|
21
22
|
});
|
|
@@ -61,6 +62,13 @@ describe("usePlayerStore", () => {
|
|
|
61
62
|
});
|
|
62
63
|
});
|
|
63
64
|
|
|
65
|
+
describe("setLoopEnabled", () => {
|
|
66
|
+
it("updates loopEnabled", () => {
|
|
67
|
+
usePlayerStore.getState().setLoopEnabled(true);
|
|
68
|
+
expect(usePlayerStore.getState().loopEnabled).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
64
72
|
describe("setTimelineReady", () => {
|
|
65
73
|
it("updates timelineReady", () => {
|
|
66
74
|
usePlayerStore.getState().setTimelineReady(true);
|
|
@@ -205,9 +213,10 @@ describe("usePlayerStore", () => {
|
|
|
205
213
|
expect(state.selectedElementId).toBeNull();
|
|
206
214
|
});
|
|
207
215
|
|
|
208
|
-
it("does not reset playbackRate, zoomMode, or manualZoomPercent", () => {
|
|
216
|
+
it("does not reset playbackRate, loopEnabled, zoomMode, or manualZoomPercent", () => {
|
|
209
217
|
const store = usePlayerStore.getState();
|
|
210
218
|
store.setPlaybackRate(2);
|
|
219
|
+
store.setLoopEnabled(true);
|
|
211
220
|
store.setZoomMode("manual");
|
|
212
221
|
store.setManualZoomPercent(200);
|
|
213
222
|
|
|
@@ -216,6 +225,7 @@ describe("usePlayerStore", () => {
|
|
|
216
225
|
const state = usePlayerStore.getState();
|
|
217
226
|
// reset() only resets the fields explicitly listed in the reset function
|
|
218
227
|
expect(state.playbackRate).toBe(2);
|
|
228
|
+
expect(state.loopEnabled).toBe(true);
|
|
219
229
|
expect(state.zoomMode).toBe("manual");
|
|
220
230
|
expect(state.manualZoomPercent).toBe(200);
|
|
221
231
|
});
|
|
@@ -34,6 +34,7 @@ interface PlayerState {
|
|
|
34
34
|
elements: TimelineElement[];
|
|
35
35
|
selectedElementId: string | null;
|
|
36
36
|
playbackRate: number;
|
|
37
|
+
loopEnabled: boolean;
|
|
37
38
|
/** Timeline zoom: 'fit' auto-scales to viewport, 'manual' uses manualZoomPercent */
|
|
38
39
|
zoomMode: ZoomMode;
|
|
39
40
|
/** Timeline zoom percent relative to the fit width when in manual mode */
|
|
@@ -43,6 +44,7 @@ interface PlayerState {
|
|
|
43
44
|
setCurrentTime: (time: number) => void;
|
|
44
45
|
setDuration: (duration: number) => void;
|
|
45
46
|
setPlaybackRate: (rate: number) => void;
|
|
47
|
+
setLoopEnabled: (enabled: boolean) => void;
|
|
46
48
|
setTimelineReady: (ready: boolean) => void;
|
|
47
49
|
setElements: (elements: TimelineElement[]) => void;
|
|
48
50
|
setSelectedElementId: (id: string | null) => void;
|
|
@@ -76,11 +78,13 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
76
78
|
elements: [],
|
|
77
79
|
selectedElementId: null,
|
|
78
80
|
playbackRate: 1,
|
|
81
|
+
loopEnabled: false,
|
|
79
82
|
zoomMode: "fit",
|
|
80
83
|
manualZoomPercent: 100,
|
|
81
84
|
|
|
82
85
|
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
|
83
86
|
setPlaybackRate: (rate) => set({ playbackRate: rate }),
|
|
87
|
+
setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
|
|
84
88
|
setZoomMode: (mode) => set({ zoomMode: mode }),
|
|
85
89
|
setManualZoomPercent: (percent) =>
|
|
86
90
|
set({ manualZoomPercent: Math.max(10, Math.min(2000, Math.round(percent))) }),
|
|
@@ -96,7 +100,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
96
100
|
),
|
|
97
101
|
})),
|
|
98
102
|
// Resets project-specific state when switching compositions.
|
|
99
|
-
// playbackRate, zoomMode, and manualZoomPercent are intentionally preserved
|
|
103
|
+
// playbackRate, loopEnabled, zoomMode, and manualZoomPercent are intentionally preserved
|
|
100
104
|
// because they are user preferences that should survive project switches.
|
|
101
105
|
reset: () =>
|
|
102
106
|
set({
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media(min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media(min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media(min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media(min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media(min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.\!visible{visibility:visible!important}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.inset-x-0{left:0;right:0}.inset-y-0{top:0;bottom:0}.-bottom-1\.5{bottom:-.375rem}.-right-1\.5{right:-.375rem}.bottom-0{bottom:0}.bottom-1{bottom:.25rem}.bottom-2{bottom:.5rem}.bottom-6{bottom:1.5rem}.bottom-full{bottom:100%}.left-0{left:0}.left-1\/2{left:50%}.left-2{left:.5rem}.left-\[100px\]{left:100px}.left-\[176px\]{left:176px}.left-\[24px\]{left:24px}.left-\[31px\]{left:31px}.left-\[34px\]{left:34px}.left-\[52px\]{left:52px}.left-\[82px\]{left:82px}.right-0{right:0}.right-3{right:.75rem}.top-0{top:0}.top-1{top:.25rem}.top-1\/2{top:50%}.top-2{top:.5rem}.top-3{top:.75rem}.top-\[18px\]{top:18px}.top-\[21px\]{top:21px}.top-\[27px\]{top:27px}.top-\[3px\]{top:3px}.top-\[51px\]{top:51px}.top-\[calc\(100\%\+6px\)\]{top:calc(100% + 6px)}.top-full{top:100%}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.z-\[100\]{z-index:100}.z-\[1\]{z-index:1}.z-\[200\]{z-index:200}.z-\[2\]{z-index:2}.z-\[90\]{z-index:90}.z-\[91\]{z-index:91}.z-\[9999\]{z-index:9999}.mx-1{margin-left:.25rem;margin-right:.25rem}.my-0\.5{margin-top:.125rem;margin-bottom:.125rem}.my-1{margin-top:.25rem;margin-bottom:.25rem}.mb-0\.5{margin-bottom:.125rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.ml-1\.5{margin-left:.375rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-\[22px\]{margin-top:22px}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.flow-root{display:flow-root}.grid{display:grid}.inline-grid{display:inline-grid}.contents{display:contents}.list-item{display:list-item}.hidden{display:none}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-2{height:.5rem}.h-24{height:6rem}.h-28{height:7rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-36{height:9rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-\[18px\]{height:18px}.h-\[3px\]{height:3px}.h-\[45px\]{height:45px}.h-\[52px\]{height:52px}.h-\[5px\]{height:5px}.h-\[70px\]{height:70px}.h-full{height:100%}.h-px{height:1px}.max-h-24{max-height:6rem}.max-h-64{max-height:16rem}.max-h-\[70\%\]{max-height:70%}.max-h-\[80vh\]{max-height:80vh}.max-h-full{max-height:100%}.min-h-0{min-height:0px}.min-h-7{min-height:1.75rem}.min-h-8{min-height:2rem}.min-h-9{min-height:2.25rem}.w-0{width:0px}.w-1\.5{width:.375rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-20{width:5rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-\[110px\]{width:110px}.w-\[160px\]{width:160px}.w-\[292px\]{width:292px}.w-\[320px\]{width:320px}.w-\[480px\]{width:480px}.w-\[56px\]{width:56px}.w-\[72px\]{width:72px}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0px}.min-w-7{min-width:1.75rem}.min-w-8{min-width:2rem}.min-w-9{min-width:2.25rem}.min-w-\[140px\]{min-width:140px}.min-w-\[160px\]{min-width:160px}.min-w-\[52px\]{min-width:52px}.min-w-\[56px\]{min-width:56px}.min-w-\[58px\]{min-width:58px}.min-w-\[72px\]{min-width:72px}.max-w-\[260px\]{max-width:260px}.max-w-\[280px\]{max-width:280px}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-full{max-width:100%}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.shrink{flex-shrink:1}.grow{flex-grow:1}.-translate-x-1\/2{--tw-translate-x: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.\!transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-col-resize{cursor:col-resize}.cursor-crosshair{cursor:crosshair}.cursor-default{cursor:default}.cursor-ew-resize{cursor:ew-resize}.cursor-help{cursor:help}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.cursor-row-resize{cursor:row-resize}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize-none{resize:none}.\!resize{resize:both!important}.resize{resize:both}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[minmax\(0\,1fr\)_68px_28px\]{grid-template-columns:minmax(0,1fr) 68px 28px}.grid-cols-\[minmax\(0\,1fr\)_auto\]{grid-template-columns:minmax(0,1fr) auto}.grid-cols-\[minmax\(0\,1fr\)_auto_auto\]{grid-template-columns:minmax(0,1fr) auto auto}.grid-cols-\[repeat\(auto-fit\,minmax\(118px\,1fr\)\)\]{grid-template-columns:repeat(auto-fit,minmax(118px,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-x-hidden{overflow-x:hidden}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-\[10px\]{border-radius:10px}.rounded-\[11px\]{border-radius:11px}.rounded-\[14px\]{border-radius:14px}.rounded-\[18px\]{border-radius:18px}.rounded-\[9px\]{border-radius:9px}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-sm{border-radius:.125rem}.rounded-xl{border-radius:.75rem}.rounded-l{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-l-2{border-left-width:2px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-none{border-style:none}.border-amber-500\/40{border-color:#f59e0b66}.border-green-500\/30{border-color:#22c55e4d}.border-neutral-600{--tw-border-opacity: 1;border-color:rgb(82 82 82 / var(--tw-border-opacity, 1))}.border-neutral-700{--tw-border-opacity: 1;border-color:rgb(64 64 64 / var(--tw-border-opacity, 1))}.border-neutral-700\/40{border-color:#40404066}.border-neutral-700\/50{border-color:#40404080}.border-neutral-700\/60{border-color:#40404099}.border-neutral-800{--tw-border-opacity: 1;border-color:rgb(38 38 38 / var(--tw-border-opacity, 1))}.border-neutral-800\/30{border-color:#2626264d}.border-neutral-800\/40{border-color:#26262666}.border-neutral-800\/50{border-color:#26262680}.border-neutral-800\/60{border-color:#26262699}.border-neutral-800\/80{border-color:#262626cc}.border-red-500{--tw-border-opacity: 1;border-color:rgb(239 68 68 / var(--tw-border-opacity, 1))}.border-red-700\/50{border-color:#b91c1c80}.border-studio-accent{--tw-border-opacity: 1;border-color:rgb(60 230 172 / var(--tw-border-opacity, 1))}.border-studio-accent\/20{border-color:#3ce6ac33}.border-studio-accent\/25{border-color:#3ce6ac40}.border-studio-accent\/30{border-color:#3ce6ac4d}.border-studio-accent\/50{border-color:#3ce6ac80}.border-studio-accent\/60{border-color:#3ce6ac99}.border-studio-accent\/80{border-color:#3ce6accc}.border-transparent{border-color:transparent}.border-white{--tw-border-opacity: 1;border-color:rgb(255 255 255 / var(--tw-border-opacity, 1))}.border-white\/10{border-color:#ffffff1a}.border-white\/20{border-color:#fff3}.border-white\/90{border-color:#ffffffe6}.border-t-white{--tw-border-opacity: 1;border-top-color:rgb(255 255 255 / var(--tw-border-opacity, 1))}.bg-\[\#0a0a0b\]{--tw-bg-opacity: 1;background-color:rgb(10 10 11 / var(--tw-bg-opacity, 1))}.bg-\[\#0d1117\]{--tw-bg-opacity: 1;background-color:rgb(13 17 23 / var(--tw-bg-opacity, 1))}.bg-\[\#0f141c\]{--tw-bg-opacity: 1;background-color:rgb(15 20 28 / var(--tw-bg-opacity, 1))}.bg-\[\#3CE6AC\]\/10{background-color:#3ce6ac1a}.bg-\[\#3CE6AC\]\/5{background-color:#3ce6ac0d}.bg-black{--tw-bg-opacity: 1;background-color:rgb(0 0 0 / var(--tw-bg-opacity, 1))}.bg-black\/20{background-color:#0003}.bg-black\/50{background-color:#00000080}.bg-black\/60{background-color:#0009}.bg-black\/80{background-color:#000c}.bg-green-500\/20{background-color:#22c55e33}.bg-green-600{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1))}.bg-neutral-600{--tw-bg-opacity: 1;background-color:rgb(82 82 82 / var(--tw-bg-opacity, 1))}.bg-neutral-700{--tw-bg-opacity: 1;background-color:rgb(64 64 64 / var(--tw-bg-opacity, 1))}.bg-neutral-700\/40{background-color:#40404066}.bg-neutral-800{--tw-bg-opacity: 1;background-color:rgb(38 38 38 / var(--tw-bg-opacity, 1))}.bg-neutral-800\/60{background-color:#26262699}.bg-neutral-800\/70{background-color:#262626b3}.bg-neutral-900{--tw-bg-opacity: 1;background-color:rgb(23 23 23 / var(--tw-bg-opacity, 1))}.bg-neutral-900\/50{background-color:#17171780}.bg-neutral-900\/60{background-color:#17171799}.bg-neutral-900\/80{background-color:#171717cc}.bg-neutral-900\/95{background-color:#171717f2}.bg-neutral-950{--tw-bg-opacity: 1;background-color:rgb(10 10 10 / var(--tw-bg-opacity, 1))}.bg-neutral-950\/80{background-color:#0a0a0acc}.bg-red-400{--tw-bg-opacity: 1;background-color:rgb(248 113 113 / var(--tw-bg-opacity, 1))}.bg-red-500\/10{background-color:#ef44441a}.bg-red-600{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.bg-red-900\/60{background-color:#7f1d1d99}.bg-red-900\/90{background-color:#7f1d1de6}.bg-red-950\/30{background-color:#450a0a4d}.bg-studio-accent{--tw-bg-opacity: 1;background-color:rgb(60 230 172 / var(--tw-bg-opacity, 1))}.bg-studio-accent\/10{background-color:#3ce6ac1a}.bg-studio-accent\/15{background-color:#3ce6ac26}.bg-studio-accent\/20{background-color:#3ce6ac33}.bg-studio-accent\/5{background-color:#3ce6ac0d}.bg-studio-accent\/90{background-color:#3ce6ace6}.bg-studio-accent\/\[0\.03\]{background-color:#3ce6ac08}.bg-studio-accent\/\[0\.05\]{background-color:#3ce6ac0d}.bg-studio-accent\/\[0\.06\]{background-color:#3ce6ac0f}.bg-studio-accent\/\[0\.07\]{background-color:#3ce6ac12}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.bg-white\/10{background-color:#ffffff1a}.bg-white\/70{background-color:#ffffffb3}.bg-white\/\[0\.035\]{background-color:#ffffff09}.bg-white\/\[0\.04\]{background-color:#ffffff0a}.bg-white\/\[0\.07\]{background-color:#ffffff12}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.bg-gradient-to-t{background-image:linear-gradient(to top,var(--tw-gradient-stops))}.from-black{--tw-gradient-from: #000 var(--tw-gradient-from-position);--tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-white{--tw-gradient-from: #fff var(--tw-gradient-from-position);--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.to-transparent{--tw-gradient-to: transparent var(--tw-gradient-to-position)}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-2\.5{padding:.625rem}.p-3{padding:.75rem}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-0\.5{padding-bottom:.125rem}.pb-2{padding-bottom:.5rem}.pb-3{padding-bottom:.75rem}.pl-3{padding-left:.75rem}.pl-6{padding-left:1.5rem}.pr-1{padding-right:.25rem}.pr-9{padding-right:2.25rem}.pt-1\.5{padding-top:.375rem}.pt-3{padding-top:.75rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-base{font-size:1rem;line-height:1.5rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.capitalize{text-transform:capitalize}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing: tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-4{line-height:1rem}.leading-5{line-height:1.25rem}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-tight{line-height:1.25}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.12em\]{letter-spacing:.12em}.tracking-\[0\.14em\]{letter-spacing:.14em}.tracking-\[0\.16em\]{letter-spacing:.16em}.tracking-\[0\.18em\]{letter-spacing:.18em}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-\[\#09090B\]{--tw-text-opacity: 1;color:rgb(9 9 11 / var(--tw-text-opacity, 1))}.text-\[\#3ce6ac\]{--tw-text-opacity: 1;color:rgb(60 230 172 / var(--tw-text-opacity, 1))}.text-\[\#7f8796\]{--tw-text-opacity: 1;color:rgb(127 135 150 / var(--tw-text-opacity, 1))}.text-amber-100{--tw-text-opacity: 1;color:rgb(254 243 199 / var(--tw-text-opacity, 1))}.text-amber-400{--tw-text-opacity: 1;color:rgb(251 191 36 / var(--tw-text-opacity, 1))}.text-green-400{--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.text-neutral-100{--tw-text-opacity: 1;color:rgb(245 245 245 / var(--tw-text-opacity, 1))}.text-neutral-200{--tw-text-opacity: 1;color:rgb(229 229 229 / var(--tw-text-opacity, 1))}.text-neutral-300{--tw-text-opacity: 1;color:rgb(212 212 212 / var(--tw-text-opacity, 1))}.text-neutral-400{--tw-text-opacity: 1;color:rgb(163 163 163 / var(--tw-text-opacity, 1))}.text-neutral-50{--tw-text-opacity: 1;color:rgb(250 250 250 / var(--tw-text-opacity, 1))}.text-neutral-500{--tw-text-opacity: 1;color:rgb(115 115 115 / var(--tw-text-opacity, 1))}.text-neutral-600{--tw-text-opacity: 1;color:rgb(82 82 82 / var(--tw-text-opacity, 1))}.text-neutral-700{--tw-text-opacity: 1;color:rgb(64 64 64 / var(--tw-text-opacity, 1))}.text-neutral-950{--tw-text-opacity: 1;color:rgb(10 10 10 / var(--tw-text-opacity, 1))}.text-purple-400{--tw-text-opacity: 1;color:rgb(192 132 252 / var(--tw-text-opacity, 1))}.text-red-200{--tw-text-opacity: 1;color:rgb(254 202 202 / var(--tw-text-opacity, 1))}.text-red-300{--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-studio-accent{--tw-text-opacity: 1;color:rgb(60 230 172 / var(--tw-text-opacity, 1))}.text-studio-accent\/50{color:#3ce6ac80}.text-studio-accent\/80{color:#3ce6accc}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-white\/60{color:#fff9}.underline{text-decoration-line:underline}.line-through{text-decoration-line:line-through}.placeholder-neutral-600::-moz-placeholder{--tw-placeholder-opacity: 1;color:rgb(82 82 82 / var(--tw-placeholder-opacity, 1))}.placeholder-neutral-600::placeholder{--tw-placeholder-opacity: 1;color:rgb(82 82 82 / var(--tw-placeholder-opacity, 1))}.accent-\[\#3ce6ac\]{accent-color:#3ce6ac}.accent-studio-accent{accent-color:#3CE6AC}.opacity-25{opacity:.25}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.mix-blend-difference{mix-blend-mode:difference}.shadow{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow: 0 25px 50px -12px rgb(0 0 0 / .25);--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_0_1px_rgba\(0\,0\,0\,0\.35\)\]{--tw-shadow: 0 0 0 1px rgba(0,0,0,.35);--tw-shadow-colored: 0 0 0 1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_0_1px_rgba\(0\,0\,0\,0\.45\)\]{--tw-shadow: 0 0 0 1px rgba(0,0,0,.45);--tw-shadow-colored: 0 0 0 1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_0_1px_rgba\(0\,0\,0\,0\.85\)\,0_6px_14px_rgba\(0\,0\,0\,0\.5\)\]{--tw-shadow: 0 0 0 1px rgba(0,0,0,.85),0 6px 14px rgba(0,0,0,.5);--tw-shadow-colored: 0 0 0 1px var(--tw-shadow-color), 0 6px 14px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_0_1px_rgba\(0\,0\,0\,0\.85\)\,0_8px_18px_rgba\(0\,0\,0\,0\.45\)\]{--tw-shadow: 0 0 0 1px rgba(0,0,0,.85),0 8px 18px rgba(0,0,0,.45);--tw-shadow-colored: 0 0 0 1px var(--tw-shadow-color), 0 8px 18px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_0_1px_rgba\(60\,230\,172\,0\.25\)\]{--tw-shadow: 0 0 0 1px rgba(60,230,172,.25);--tw-shadow-colored: 0 0 0 1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_18px_40px_rgba\(0\,0\,0\,0\.3\)\,0_4px_14px_rgba\(0\,0\,0\,0\.18\)\]{--tw-shadow: 0 18px 40px rgba(0,0,0,.3),0 4px 14px rgba(0,0,0,.18);--tw-shadow-colored: 0 18px 40px var(--tw-shadow-color), 0 4px 14px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_1px_2px_rgba\(0\,0\,0\,0\.2\)\]{--tw-shadow: 0 1px 2px rgba(0,0,0,.2);--tw-shadow-colored: 0 1px 2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_1px_3px_rgba\(0\,0\,0\,0\.28\)\]{--tw-shadow: 0 1px 3px rgba(0,0,0,.28);--tw-shadow-colored: 0 1px 3px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[inset_0_0_0_1px_rgba\(255\,255\,255\,0\.06\)\]{--tw-shadow: inset 0 0 0 1px rgba(255,255,255,.06);--tw-shadow-colored: inset 0 0 0 1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[inset_0_1px_0_rgba\(255\,255\,255\,0\.03\)\]{--tw-shadow: inset 0 1px 0 rgba(255,255,255,.03);--tw-shadow-colored: inset 0 1px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[inset_0_1px_0_rgba\(255\,255\,255\,0\.04\)\]{--tw-shadow: inset 0 1px 0 rgba(255,255,255,.04);--tw-shadow-colored: inset 0 1px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[inset_0_1px_0_rgba\(255\,255\,255\,0\.08\)\]{--tw-shadow: inset 0 1px 0 rgba(255,255,255,.08);--tw-shadow-colored: inset 0 1px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[inset_0_1px_2px_rgba\(0\,0\,0\,0\.55\)\]{--tw-shadow: inset 0 1px 2px rgba(0,0,0,.55);--tw-shadow-colored: inset 0 1px 2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-black\/40{--tw-shadow-color: rgb(0 0 0 / .4);--tw-shadow: var(--tw-shadow-colored)}.shadow-black\/50{--tw-shadow-color: rgb(0 0 0 / .5);--tw-shadow: var(--tw-shadow-colored)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.outline-1{outline-width:1px}.-outline-offset-1{outline-offset:-1px}.outline-\[\#3CE6AC\]\/30{outline-color:#3ce6ac4d}.outline-\[\#3CE6AC\]\/40{outline-color:#3ce6ac66}.ring{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-1{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-2{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-inset{--tw-ring-inset: inset}.ring-studio-accent{--tw-ring-opacity: 1;--tw-ring-color: rgb(60 230 172 / var(--tw-ring-opacity, 1))}.ring-white\/50{--tw-ring-color: rgb(255 255 255 / .5)}.blur{--tw-blur: blur(8px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow{--tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / .1)) drop-shadow(0 1px 1px rgb(0 0 0 / .06));filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.invert{--tw-invert: invert(100%);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur: blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-xl{--tw-backdrop-blur: blur(24px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-150{transition-duration:.15s}.duration-300{transition-duration:.3s}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}:root{color-scheme:dark}body{margin:0;padding:0;background:#0a0a0a;color:#e5e5e5;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;overflow:hidden}#root{width:100vw;height:100vh;height:100dvh}.cm-editor{height:100%;font-size:13px}.cm-editor .cm-scroller{font-family:JetBrains Mono,Fira Code,SF Mono,monospace}.cm-editor.cm-focused{outline:none}.placeholder\:text-neutral-600::-moz-placeholder{--tw-text-opacity: 1;color:rgb(82 82 82 / var(--tw-text-opacity, 1))}.placeholder\:text-neutral-600::placeholder{--tw-text-opacity: 1;color:rgb(82 82 82 / var(--tw-text-opacity, 1))}.last\:border-0:last-child{border-width:0px}.focus-within\:border-neutral-600:focus-within{--tw-border-opacity: 1;border-color:rgb(82 82 82 / var(--tw-border-opacity, 1))}.hover\:border-amber-400\/70:hover{border-color:#fbbf24b3}.hover\:border-neutral-500:hover{--tw-border-opacity: 1;border-color:rgb(115 115 115 / var(--tw-border-opacity, 1))}.hover\:border-neutral-600:hover{--tw-border-opacity: 1;border-color:rgb(82 82 82 / var(--tw-border-opacity, 1))}.hover\:border-neutral-700:hover{--tw-border-opacity: 1;border-color:rgb(64 64 64 / var(--tw-border-opacity, 1))}.hover\:border-studio-accent\/40:hover{border-color:#3ce6ac66}.hover\:border-studio-accent\/50:hover{border-color:#3ce6ac80}.hover\:bg-neutral-200:hover{--tw-bg-opacity: 1;background-color:rgb(229 229 229 / var(--tw-bg-opacity, 1))}.hover\:bg-neutral-600:hover{--tw-bg-opacity: 1;background-color:rgb(82 82 82 / var(--tw-bg-opacity, 1))}.hover\:bg-neutral-800:hover{--tw-bg-opacity: 1;background-color:rgb(38 38 38 / var(--tw-bg-opacity, 1))}.hover\:bg-neutral-800\/30:hover{background-color:#2626264d}.hover\:bg-neutral-800\/50:hover{background-color:#26262680}.hover\:bg-neutral-800\/70:hover{background-color:#262626b3}.hover\:bg-neutral-900:hover{--tw-bg-opacity: 1;background-color:rgb(23 23 23 / var(--tw-bg-opacity, 1))}.hover\:bg-red-500:hover{--tw-bg-opacity: 1;background-color:rgb(239 68 68 / var(--tw-bg-opacity, 1))}.hover\:bg-red-600:hover{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.hover\:bg-red-800\/60:hover{background-color:#991b1b99}.hover\:bg-red-900\/30:hover{background-color:#7f1d1d4d}.hover\:bg-studio-accent:hover{--tw-bg-opacity: 1;background-color:rgb(60 230 172 / var(--tw-bg-opacity, 1))}.hover\:bg-studio-accent\/25:hover{background-color:#3ce6ac40}.hover\:bg-studio-accent\/80:hover{background-color:#3ce6accc}.hover\:bg-white\/\[0\.06\]:hover{background-color:#ffffff0f}.hover\:text-amber-100:hover{--tw-text-opacity: 1;color:rgb(254 243 199 / var(--tw-text-opacity, 1))}.hover\:text-amber-300:hover{--tw-text-opacity: 1;color:rgb(252 211 77 / var(--tw-text-opacity, 1))}.hover\:text-green-400:hover{--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.hover\:text-neutral-100:hover{--tw-text-opacity: 1;color:rgb(245 245 245 / var(--tw-text-opacity, 1))}.hover\:text-neutral-200:hover{--tw-text-opacity: 1;color:rgb(229 229 229 / var(--tw-text-opacity, 1))}.hover\:text-neutral-300:hover{--tw-text-opacity: 1;color:rgb(212 212 212 / var(--tw-text-opacity, 1))}.hover\:text-neutral-400:hover{--tw-text-opacity: 1;color:rgb(163 163 163 / var(--tw-text-opacity, 1))}.hover\:text-red-400:hover{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.hover\:text-studio-accent:hover{--tw-text-opacity: 1;color:rgb(60 230 172 / var(--tw-text-opacity, 1))}.hover\:text-white:hover{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.hover\:ring-1:hover{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.hover\:ring-white\/30:hover{--tw-ring-color: rgb(255 255 255 / .3)}.hover\:brightness-110:hover{--tw-brightness: brightness(1.1);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.focus\:border-\[\#3CE6AC\]:focus{--tw-border-opacity: 1;border-color:rgb(60 230 172 / var(--tw-border-opacity, 1))}.focus\:border-\[\#f5a400\]:focus{--tw-border-opacity: 1;border-color:rgb(245 164 0 / var(--tw-border-opacity, 1))}.focus\:border-neutral-600:focus{--tw-border-opacity: 1;border-color:rgb(82 82 82 / var(--tw-border-opacity, 1))}.focus\:border-studio-accent:focus{--tw-border-opacity: 1;border-color:rgb(60 230 172 / var(--tw-border-opacity, 1))}.focus\:border-studio-accent\/40:focus{border-color:#3ce6ac66}.focus\:border-studio-accent\/60:focus{border-color:#3ce6ac99}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-1:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-2:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-\[\#3ce6ac\]:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(60 230 172 / var(--tw-ring-opacity, 1))}.focus\:ring-\[\#f5a400\]\/40:focus{--tw-ring-color: rgb(245 164 0 / .4)}.focus\:ring-studio-accent\/30:focus{--tw-ring-color: rgb(60 230 172 / .3)}.focus-visible\:outline-none:focus-visible{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus-visible\:ring-studio-accent\/50:focus-visible{--tw-ring-color: rgb(60 230 172 / .5)}.active\:scale-\[0\.97\]:active{--tw-scale-x: .97;--tw-scale-y: .97;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.active\:scale-\[0\.98\]:active{--tw-scale-x: .98;--tw-scale-y: .98;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:text-neutral-600:disabled{--tw-text-opacity: 1;color:rgb(82 82 82 / var(--tw-text-opacity, 1))}.disabled\:text-neutral-700:disabled{--tw-text-opacity: 1;color:rgb(64 64 64 / var(--tw-text-opacity, 1))}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:scale-125{--tw-scale-x: 1.25;--tw-scale-y: 1.25;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@media(min-width:768px){.md\:inline{display:inline}}
|