@hyperframes/studio 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/assets/index-CLmYRLY-.css +1 -0
  2. package/dist/assets/index-CRvFpc0E.js +84 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +2 -2
  5. package/src/App.tsx +139 -657
  6. package/src/components/LintModal.tsx +149 -0
  7. package/src/components/MediaPreview.tsx +79 -0
  8. package/src/components/editor/FileTree.tsx +50 -40
  9. package/src/components/editor/PropertyPanel.tsx +3 -3
  10. package/src/components/nle/NLELayout.tsx +59 -43
  11. package/src/components/renders/RenderQueue.tsx +19 -16
  12. package/src/components/renders/RenderQueueItem.tsx +13 -8
  13. package/src/components/sidebar/AssetsTab.tsx +34 -144
  14. package/src/components/sidebar/CompositionsTab.tsx +47 -161
  15. package/src/components/sidebar/LeftSidebar.tsx +79 -8
  16. package/src/components/ui/VideoFrameThumbnail.tsx +1 -5
  17. package/src/index.ts +0 -3
  18. package/src/player/components/CompositionThumbnail.tsx +20 -94
  19. package/src/player/components/EditModal.tsx +5 -5
  20. package/src/player/components/PlayerControls.tsx +56 -3
  21. package/src/player/components/Timeline.tsx +13 -17
  22. package/src/player/components/TimelineClip.tsx +0 -1
  23. package/src/player/index.ts +0 -1
  24. package/src/player/store/playerStore.ts +3 -28
  25. package/src/utils/mediaTypes.ts +9 -0
  26. package/dist/assets/index-2uBPlHR_.css +0 -1
  27. package/dist/assets/index-uQ8cgxb3.js +0 -92
  28. package/src/components/ui/ExpandOnHover.tsx +0 -194
  29. package/src/components/ui/ExpandedVideoPreview.tsx +0 -37
  30. package/src/hooks/useCodeEditor.ts +0 -88
  31. package/src/player/components/PreviewPanel.tsx +0 -181
@@ -1,18 +1,12 @@
1
1
  /**
2
- * CompositionThumbnail — Film-strip of server-rendered JPEG thumbnails.
2
+ * CompositionThumbnail — Single server-rendered JPEG stretched across the clip.
3
3
  *
4
- * Requests multiple thumbnails at different timestamps across the clip duration
5
- * and tiles them horizontally like VideoThumbnail does for video clips.
6
- * Each frame is a separate <img> from /api/projects/:id/thumbnail/:path?t=X.
7
- *
8
- * Uses ResizeObserver to adapt frame count when the clip width changes (zoom).
4
+ * Takes one screenshot at the midpoint of the clip and covers the full width —
5
+ * same approach as After Effects for precomps. This avoids the 1-2s per-frame
6
+ * Puppeteer cost of rendering multiple filmstrip frames.
9
7
  */
10
8
 
11
- import { memo, useRef, useState, useCallback } from "react";
12
- import { useMountEffect } from "../../hooks/useMountEffect";
13
-
14
- const CLIP_HEIGHT = 66;
15
- const MAX_UNIQUE_FRAMES = 6;
9
+ import { memo } from "react";
16
10
 
17
11
  interface CompositionThumbnailProps {
18
12
  previewUrl: string;
@@ -30,95 +24,27 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
30
24
  labelColor,
31
25
  seekTime = 2,
32
26
  duration = 5,
33
- width = 1920,
34
- height = 1080,
35
27
  }: CompositionThumbnailProps) {
36
- const [containerWidth, setContainerWidth] = useState(0);
37
- const roRef = useRef<ResizeObserver | null>(null);
38
-
39
- const setRef = useCallback((el: HTMLDivElement | null) => {
40
- roRef.current?.disconnect();
41
- if (!el) return;
42
-
43
- // Walk up to data-clip parent for accurate width
44
- let target: HTMLElement = el;
45
- let parent = el.parentElement;
46
- let depth = 0;
47
- while (parent && !parent.hasAttribute("data-clip") && depth < 5) {
48
- parent = parent.parentElement;
49
- depth++;
50
- }
51
- if (parent?.hasAttribute("data-clip")) target = parent;
52
-
53
- requestAnimationFrame(() => {
54
- const w = target.clientWidth || target.getBoundingClientRect().width;
55
- if (w > 0) setContainerWidth(w);
56
- });
57
-
58
- roRef.current = new ResizeObserver(([entry]) => setContainerWidth(entry.contentRect.width));
59
- roRef.current.observe(target);
60
- }, []);
61
-
62
- useMountEffect(() => () => {
63
- roRef.current?.disconnect();
64
- });
65
-
66
- // Convert preview URL to thumbnail base URL
28
+ // Single screenshot at the midpoint of the clip
67
29
  const thumbnailBase = previewUrl
68
30
  .replace("/preview/comp/", "/thumbnail/")
69
31
  .replace(/\/preview$/, "/thumbnail/index.html");
70
-
71
- // Calculate frame layout
72
- const aspect = width / height;
73
- const frameW = Math.round(CLIP_HEIGHT * aspect);
74
- const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
75
- const uniqueFrames = Math.min(frameCount, MAX_UNIQUE_FRAMES);
76
-
77
- // Each frame tile represents a real position in the clip.
78
- // Offset slightly (0.5s) into each segment to avoid landing on transition
79
- // points where content is invisible due to fade-in/fade-out animations.
80
- const timestamps: number[] = [];
81
- const pad = Math.min(0.5, duration * 0.05);
82
- for (let i = 0; i < uniqueFrames; i++) {
83
- const frac = uniqueFrames === 1 ? 0.5 : i / (uniqueFrames - 1);
84
- const raw = seekTime + frac * duration;
85
- // Clamp to [pad, duration - pad] to stay inside visible content
86
- timestamps.push(seekTime + Math.max(pad, Math.min(duration - pad, raw - seekTime)));
87
- }
32
+ const midTime = seekTime + duration / 2;
33
+ const url = `${thumbnailBase}?t=${midTime.toFixed(2)}`;
88
34
 
89
35
  return (
90
- <div ref={setRef} className="absolute inset-0 overflow-hidden bg-neutral-950">
91
- {/* Film strip — each tile maps to its real timeline position */}
92
- <div className="absolute inset-0 flex">
93
- {Array.from({ length: frameCount }).map((_, i) => {
94
- // Map this tile's visual position to a timestamp
95
- const tileFrac = frameCount === 1 ? 0.5 : i / (frameCount - 1);
96
- const t = seekTime + tileFrac * duration;
97
- // Use the nearest cached unique frame
98
- const uniqueIdx = Math.min(Math.round(tileFrac * (uniqueFrames - 1)), uniqueFrames - 1);
99
- const cachedT = timestamps[uniqueIdx];
100
- const url = `${thumbnailBase}?t=${(cachedT ?? t).toFixed(2)}`;
101
- return (
102
- <div
103
- key={i}
104
- className="flex-shrink-0 h-full relative overflow-hidden bg-neutral-900"
105
- style={{ width: frameW }}
106
- >
107
- <img
108
- src={url}
109
- alt=""
110
- draggable={false}
111
- loading="lazy"
112
- onLoad={(e) => {
113
- (e.target as HTMLImageElement).style.opacity = "1";
114
- }}
115
- className="absolute inset-0 w-full h-full object-contain"
116
- style={{ opacity: 0, transition: "opacity 200ms ease-out" }}
117
- />
118
- </div>
119
- );
120
- })}
121
- </div>
36
+ <div className="absolute inset-0 overflow-hidden bg-neutral-950">
37
+ <img
38
+ src={url}
39
+ alt=""
40
+ draggable={false}
41
+ loading="lazy"
42
+ onLoad={(e) => {
43
+ (e.target as HTMLImageElement).style.opacity = "1";
44
+ }}
45
+ className="absolute inset-0 w-full h-full object-cover"
46
+ style={{ opacity: 0, transition: "opacity 200ms ease-out" }}
47
+ />
122
48
 
123
49
  {/* Label */}
124
50
  <div
@@ -105,7 +105,7 @@ Preserve all other elements and timing outside this range.`;
105
105
  {/* Header */}
106
106
  <div className="flex items-center justify-between px-4 py-2.5 border-b border-neutral-800/60">
107
107
  <div className="flex items-center gap-2">
108
- <div className="w-1.5 h-1.5 rounded-full bg-blue-400" />
108
+ <div className="w-1.5 h-1.5 rounded-full bg-studio-accent" />
109
109
  <span className="text-[11px] font-medium text-neutral-300">
110
110
  {formatTime(start)} — {formatTime(end)}
111
111
  </span>
@@ -120,7 +120,7 @@ Preserve all other elements and timing outside this range.`;
120
120
  <div className="px-4 py-2 border-b border-neutral-800/40 max-h-24 overflow-y-auto">
121
121
  {elementsInRange.map((el) => (
122
122
  <div key={el.id} className="flex items-center justify-between py-0.5">
123
- <span className="text-[10px] font-mono text-blue-400/80">#{el.id}</span>
123
+ <span className="text-[10px] font-mono text-studio-accent/80">#{el.id}</span>
124
124
  <span className="text-[10px] text-neutral-600">{el.tag}</span>
125
125
  </div>
126
126
  ))}
@@ -141,7 +141,7 @@ Preserve all other elements and timing outside this range.`;
141
141
  }}
142
142
  placeholder="What should change?"
143
143
  rows={2}
144
- className="w-full px-3 py-2 text-xs bg-neutral-800/60 border border-neutral-700/40 rounded-lg text-neutral-200 placeholder:text-neutral-600 resize-none focus:outline-none focus:border-blue-500/40 transition-colors"
144
+ className="w-full px-3 py-2 text-xs bg-neutral-800/60 border border-neutral-700/40 rounded-lg text-neutral-200 placeholder:text-neutral-600 resize-none focus:outline-none focus:border-studio-accent/40 transition-colors"
145
145
  />
146
146
  </div>
147
147
 
@@ -152,11 +152,11 @@ Preserve all other elements and timing outside this range.`;
152
152
  className={`w-full py-1.5 text-[11px] font-medium rounded-lg transition-all ${
153
153
  copied
154
154
  ? "bg-green-500/20 text-green-400 border border-green-500/30"
155
- : "bg-blue-500/15 text-blue-400 border border-blue-500/25 hover:bg-blue-500/25"
155
+ : "bg-studio-accent/15 text-studio-accent border border-studio-accent/25 hover:bg-studio-accent/25"
156
156
  }`}
157
157
  >
158
158
  {copied ? "Copied!" : "Copy to Agent"}
159
- {!copied && <span className="text-[9px] text-blue-400/50 ml-1.5">Cmd+Enter</span>}
159
+ {!copied && <span className="text-[9px] text-studio-accent/50 ml-1.5">Cmd+Enter</span>}
160
160
  </button>
161
161
  </div>
162
162
  </div>
@@ -1,4 +1,4 @@
1
- import { useRef, useState, useCallback, memo } from "react";
1
+ import { useRef, useState, useCallback, useEffect, memo } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { formatTime } from "../lib/time";
4
4
  import { usePlayerStore, liveTime } from "../store/playerStore";
@@ -8,11 +8,15 @@ const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
8
8
  interface PlayerControlsProps {
9
9
  onTogglePlay: () => void;
10
10
  onSeek: (time: number) => void;
11
+ timelineVisible?: boolean;
12
+ onToggleTimeline?: () => void;
11
13
  }
12
14
 
13
15
  export const PlayerControls = memo(function PlayerControls({
14
16
  onTogglePlay,
15
17
  onSeek,
18
+ timelineVisible,
19
+ onToggleTimeline,
16
20
  }: PlayerControlsProps) {
17
21
  // Subscribe to only the fields we render — each selector prevents cascading re-renders
18
22
  const isPlaying = usePlayerStore((s) => s.isPlaying);
@@ -26,6 +30,8 @@ export const PlayerControls = memo(function PlayerControls({
26
30
  const progressThumbRef = useRef<HTMLDivElement>(null);
27
31
  const timeDisplayRef = useRef<HTMLSpanElement>(null);
28
32
  const seekBarRef = useRef<HTMLDivElement>(null);
33
+ const sliderRef = useRef<HTMLDivElement>(null);
34
+ const speedMenuContainerRef = useRef<HTMLDivElement>(null);
29
35
  const isDraggingRef = useRef(false);
30
36
  const currentTimeRef = useRef(0);
31
37
 
@@ -39,6 +45,7 @@ export const PlayerControls = memo(function PlayerControls({
39
45
  if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
40
46
  if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
41
47
  if (timeDisplayRef.current) timeDisplayRef.current.textContent = formatTime(t);
48
+ if (sliderRef.current) sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t)));
42
49
  };
43
50
  const unsub = liveTime.subscribe(updateProgress);
44
51
  updateProgress(usePlayerStore.getState().currentTime);
@@ -60,6 +67,22 @@ export const PlayerControls = memo(function PlayerControls({
60
67
  };
61
68
  });
62
69
 
70
+ useEffect(() => {
71
+ if (!showSpeedMenu) return;
72
+ const handleMouseDown = (e: MouseEvent) => {
73
+ if (
74
+ speedMenuContainerRef.current &&
75
+ !speedMenuContainerRef.current.contains(e.target as Node)
76
+ ) {
77
+ setShowSpeedMenu(false);
78
+ }
79
+ };
80
+ document.addEventListener("mousedown", handleMouseDown);
81
+ return () => {
82
+ document.removeEventListener("mousedown", handleMouseDown);
83
+ };
84
+ }, [showSpeedMenu]);
85
+
63
86
  const seekFromClientX = useCallback(
64
87
  (clientX: number) => {
65
88
  const bar = seekBarRef.current;
@@ -149,7 +172,10 @@ export const PlayerControls = memo(function PlayerControls({
149
172
 
150
173
  {/* Seek bar — teal progress fill */}
151
174
  <div
152
- ref={seekBarRef}
175
+ ref={(el) => {
176
+ (seekBarRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
177
+ (sliderRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
178
+ }}
153
179
  role="slider"
154
180
  tabIndex={0}
155
181
  aria-label="Seek"
@@ -184,7 +210,7 @@ export const PlayerControls = memo(function PlayerControls({
184
210
  </div>
185
211
 
186
212
  {/* Speed control */}
187
- <div className="relative flex-shrink-0">
213
+ <div ref={speedMenuContainerRef} className="relative flex-shrink-0">
188
214
  <button
189
215
  type="button"
190
216
  onClick={() => setShowSpeedMenu((v) => !v)}
@@ -224,6 +250,33 @@ export const PlayerControls = memo(function PlayerControls({
224
250
  </div>
225
251
  )}
226
252
  </div>
253
+
254
+ {/* Timeline toggle */}
255
+ {onToggleTimeline !== undefined && (
256
+ <button
257
+ onClick={onToggleTimeline}
258
+ className={`w-7 h-7 flex items-center justify-center rounded-md border transition-colors ${
259
+ timelineVisible
260
+ ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
261
+ : "border-neutral-700 text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
262
+ }`}
263
+ title={timelineVisible ? "Hide timeline" : "Show timeline"}
264
+ >
265
+ <svg
266
+ width="13"
267
+ height="13"
268
+ viewBox="0 0 24 24"
269
+ fill="none"
270
+ stroke="currentColor"
271
+ strokeWidth="2"
272
+ strokeLinecap="round"
273
+ >
274
+ <rect x="3" y="13" width="18" height="8" rx="1" />
275
+ <line x1="3" y1="9" x2="21" y2="9" />
276
+ <line x1="3" y1="5" x2="21" y2="5" />
277
+ </svg>
278
+ </button>
279
+ )}
227
280
  </div>
228
281
  );
229
282
  });
@@ -152,9 +152,6 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe
152
152
  return { major, minor };
153
153
  }
154
154
 
155
- /** @deprecated Use formatTime from '../lib/time' instead */
156
- export const formatTick = formatTime;
157
-
158
155
  /* ── Component ──────────────────────────────────────────────────── */
159
156
  interface TimelineProps {
160
157
  /** Called when user seeks via ruler/track click or playhead drag */
@@ -170,11 +167,6 @@ interface TimelineProps {
170
167
  renderClipOverlay?: (element: import("../store/playerStore").TimelineElement) => ReactNode;
171
168
  /** Called when files are dropped onto the empty timeline */
172
169
  onFileDrop?: (files: File[]) => void;
173
- /** Called when a clip is moved, resized, or changes track via drag */
174
- onClipChange?: (
175
- elementId: string,
176
- updates: { start?: number; duration?: number; track?: number },
177
- ) => void;
178
170
  }
179
171
 
180
172
  export const Timeline = memo(function Timeline({
@@ -346,12 +338,11 @@ export const Timeline = memo(function Timeline({
346
338
 
347
339
  const handlePointerDown = useCallback(
348
340
  (e: React.PointerEvent) => {
349
- if ((e.target as HTMLElement).closest("[data-clip]")) return;
350
341
  if (e.button !== 0) return;
351
- (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
352
342
 
353
- // Shift+click starts range selection
343
+ // Shift+click starts range selection — even on clips
354
344
  if (e.shiftKey) {
345
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
355
346
  isRangeSelecting.current = true;
356
347
  setShowPopover(false);
357
348
  const rect = scrollRef.current?.getBoundingClientRect();
@@ -364,6 +355,10 @@ export const Timeline = memo(function Timeline({
364
355
  return;
365
356
  }
366
357
 
358
+ // Normal click on a clip — let the clip handle it
359
+ if ((e.target as HTMLElement).closest("[data-clip]")) return;
360
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
361
+
367
362
  isDragging.current = true;
368
363
  setRangeSelection(null);
369
364
  setShowPopover(false);
@@ -434,7 +429,7 @@ export const Timeline = memo(function Timeline({
434
429
  return (
435
430
  <div
436
431
  className={`h-full border-t bg-[#0a0a0b] flex flex-col select-none transition-colors duration-150 ${
437
- isDragOver ? "border-blue-500/50 bg-blue-500/[0.03]" : "border-neutral-800/50"
432
+ isDragOver ? "border-studio-accent/50 bg-studio-accent/[0.03]" : "border-neutral-800/50"
438
433
  }`}
439
434
  onDragOver={(e) => {
440
435
  e.preventDefault();
@@ -471,7 +466,9 @@ export const Timeline = memo(function Timeline({
471
466
  <div className="flex-1 flex items-center justify-center">
472
467
  <div
473
468
  className={`flex items-center gap-3 px-6 py-3 border border-dashed rounded-lg transition-colors duration-150 ${
474
- isDragOver ? "border-blue-400/60 bg-blue-500/[0.06]" : "border-neutral-700/50"
469
+ isDragOver
470
+ ? "border-studio-accent/60 bg-studio-accent/[0.06]"
471
+ : "border-neutral-700/50"
475
472
  }`}
476
473
  >
477
474
  {isDragOver ? (
@@ -485,13 +482,13 @@ export const Timeline = memo(function Timeline({
485
482
  strokeWidth="1.5"
486
483
  strokeLinecap="round"
487
484
  strokeLinejoin="round"
488
- className="text-blue-400 flex-shrink-0"
485
+ className="text-studio-accent flex-shrink-0"
489
486
  >
490
487
  <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
491
488
  <polyline points="7 10 12 15 17 10" />
492
489
  <line x1="12" y1="15" x2="12" y2="3" />
493
490
  </svg>
494
- <span className="text-[13px] text-blue-400">Drop media files to import</span>
491
+ <span className="text-[13px] text-studio-accent">Drop media files to import</span>
495
492
  </>
496
493
  ) : (
497
494
  <>
@@ -573,7 +570,7 @@ export const Timeline = memo(function Timeline({
573
570
  {/* Shift hint */}
574
571
  {shiftHeld && !rangeSelection && (
575
572
  <div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
576
- <span className="text-[9px] text-blue-400/60 font-medium">
573
+ <span className="text-[9px] text-studio-accent/60 font-medium">
577
574
  Drag to select range
578
575
  </span>
579
576
  </div>
@@ -642,7 +639,6 @@ export const Timeline = memo(function Timeline({
642
639
  key={clipKey}
643
640
  el={el}
644
641
  pps={pps}
645
- trackH={TRACK_H}
646
642
  clipY={CLIP_Y}
647
643
  isSelected={isSelected}
648
644
  isHovered={isHovered}
@@ -6,7 +6,6 @@ import type { TimelineElement } from "../store/playerStore";
6
6
  interface TimelineClipProps {
7
7
  el: TimelineElement;
8
8
  pps: number;
9
- trackH: number;
10
9
  clipY: number;
11
10
  isSelected: boolean;
12
11
  isHovered: boolean;
@@ -2,7 +2,6 @@
2
2
  export { Player } from "./components/Player";
3
3
  export { PlayerControls } from "./components/PlayerControls";
4
4
  export { Timeline } from "./components/Timeline";
5
- export { PreviewPanel } from "./components/PreviewPanel";
6
5
  export { VideoThumbnail } from "./components/VideoThumbnail";
7
6
  export { CompositionThumbnail } from "./components/CompositionThumbnail";
8
7
 
@@ -27,10 +27,6 @@ interface PlayerState {
27
27
  zoomMode: ZoomMode;
28
28
  /** Pixels per second when in manual zoom mode */
29
29
  pixelsPerSecond: number;
30
- /** Edit range selection */
31
- editRangeStart: number | null;
32
- editRangeEnd: number | null;
33
- editMode: boolean;
34
30
 
35
31
  setIsPlaying: (playing: boolean) => void;
36
32
  setCurrentTime: (time: number) => void;
@@ -39,11 +35,6 @@ interface PlayerState {
39
35
  setTimelineReady: (ready: boolean) => void;
40
36
  setElements: (elements: TimelineElement[]) => void;
41
37
  setSelectedElementId: (id: string | null) => void;
42
- setEditRange: (start: number | null, end: number | null) => void;
43
- setEditMode: (active: boolean) => void;
44
- updateElementStart: (elementId: string, newStart: number) => void;
45
- updateElementDuration: (elementId: string, newDuration: number) => void;
46
- updateElementTrack: (elementId: string, newTrack: number) => void;
47
38
  updateElement: (
48
39
  elementId: string,
49
40
  updates: Partial<Pick<TimelineElement, "start" | "duration" | "track">>,
@@ -76,9 +67,6 @@ export const usePlayerStore = create<PlayerState>((set) => ({
76
67
  playbackRate: 1,
77
68
  zoomMode: "fit",
78
69
  pixelsPerSecond: 100,
79
- editRangeStart: null,
80
- editRangeEnd: null,
81
- editMode: false,
82
70
 
83
71
  setIsPlaying: (playing) => set({ isPlaying: playing }),
84
72
  setPlaybackRate: (rate) => set({ playbackRate: rate }),
@@ -89,26 +77,13 @@ export const usePlayerStore = create<PlayerState>((set) => ({
89
77
  setTimelineReady: (ready) => set({ timelineReady: ready }),
90
78
  setElements: (elements) => set({ elements }),
91
79
  setSelectedElementId: (id) => set({ selectedElementId: id }),
92
- setEditRange: (start, end) => set({ editRangeStart: start, editRangeEnd: end }),
93
- setEditMode: (active) => set({ editMode: active, editRangeStart: null, editRangeEnd: null }),
94
- updateElementStart: (elementId, newStart) =>
95
- set((state) => ({
96
- elements: state.elements.map((el) => (el.id === elementId ? { ...el, start: newStart } : el)),
97
- })),
98
- updateElementDuration: (elementId, newDuration) =>
99
- set((state) => ({
100
- elements: state.elements.map((el) =>
101
- el.id === elementId ? { ...el, duration: newDuration } : el,
102
- ),
103
- })),
104
- updateElementTrack: (elementId, newTrack) =>
105
- set((state) => ({
106
- elements: state.elements.map((el) => (el.id === elementId ? { ...el, track: newTrack } : el)),
107
- })),
108
80
  updateElement: (elementId, updates) =>
109
81
  set((state) => ({
110
82
  elements: state.elements.map((el) => (el.id === elementId ? { ...el, ...updates } : el)),
111
83
  })),
84
+ // Resets project-specific state when switching compositions.
85
+ // playbackRate, zoomMode, and pixelsPerSecond are intentionally preserved
86
+ // because they are user preferences that should survive project switches.
112
87
  reset: () =>
113
88
  set({
114
89
  isPlaying: false,
@@ -0,0 +1,9 @@
1
+ export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
2
+ export const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
3
+ export const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
4
+ export const FONT_EXT = /\.(woff|woff2|ttf|otf|eot)$/i;
5
+ export const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|svg|ico)$/i;
6
+
7
+ export function isMediaFile(path: string): boolean {
8
+ return MEDIA_EXT.test(path);
9
+ }
@@ -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}.\!visible{visibility:visible!important}.visible{visibility:visible}.invisible{visibility:hidden}.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}.bottom-0{bottom:0}.bottom-2{bottom:.5rem}.bottom-full{bottom:100%}.left-0{left:0}.right-0{right:0}.right-3{right:.75rem}.top-0{top:0}.top-1\/2{top:50%}.z-10{z-index:10}.z-20{z-index:20}.z-40{z-index:40}.z-50{z-index:50}.z-\[100\]{z-index:100}.z-\[1\]{z-index:1}.z-\[2\]{z-index:2}.mx-auto{margin-left:auto;margin-right:auto}.mb-0\.5{margin-bottom:.125rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.ml-1\.5{margin-left:.375rem}.ml-11{margin-left:2.75rem}.ml-auto{margin-left:auto}.mr-4{margin-right:1rem}.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}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.hidden{display:none}.aspect-video{aspect-ratio:16 / 9}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.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-\[3px\]{height:3px}.h-\[45px\]{height:45px}.h-\[5px\]{height:5px}.h-full{height:100%}.h-screen{height:100vh}.max-h-24{max-height:6rem}.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-1{width:.25rem}.w-1\.5{width:.375rem}.w-16{width:4rem}.w-2{width:.5rem}.w-20{width:5rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-5{width:1.25rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-80{width:20rem}.w-\[140px\]{width:140px}.w-full{width:100%}.w-px{width:1px}.w-screen{width:100vw}.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-\[56px\]{min-width:56px}.min-w-\[72px\]{min-width:72px}.max-w-4xl{max-width:56rem}.max-w-\[280px\]{max-width:280px}.max-w-full{max-width:100%}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.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))}@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-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-y{resize:vertical}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.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}.gap-5{gap:1.25rem}.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))}.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-\[16px\]{border-radius:16px}.rounded-\[4px\]{border-radius:4px}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.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-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.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-\[\#3CE6AC\]{--tw-border-opacity: 1;border-color:rgb(60 230 172 / var(--tw-border-opacity, 1))}.border-blue-400\/60{border-color:#60a5fa99}.border-blue-500{--tw-border-opacity: 1;border-color:rgb(59 130 246 / var(--tw-border-opacity, 1))}.border-blue-500\/25{border-color:#3b82f640}.border-blue-500\/30{border-color:#3b82f64d}.border-blue-500\/50{border-color:#3b82f680}.border-green-500\/30{border-color:#22c55e4d}.border-neutral-700{--tw-border-opacity: 1;border-color:rgb(64 64 64 / var(--tw-border-opacity, 1))}.border-neutral-700\/20{border-color:#40404033}.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-transparent{border-color:transparent}.bg-\[\#0a0a0b\]{--tw-bg-opacity: 1;background-color:rgb(10 10 11 / var(--tw-bg-opacity, 1))}.bg-\[\#3CE6AC\]{--tw-bg-opacity: 1;background-color:rgb(60 230 172 / var(--tw-bg-opacity, 1))}.bg-\[\#3CE6AC\]\/10{background-color:#3ce6ac1a}.bg-black{--tw-bg-opacity: 1;background-color:rgb(0 0 0 / var(--tw-bg-opacity, 1))}.bg-black\/60{background-color:#0009}.bg-blue-400{--tw-bg-opacity: 1;background-color:rgb(96 165 250 / var(--tw-bg-opacity, 1))}.bg-blue-500{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity, 1))}.bg-blue-500\/10{background-color:#3b82f61a}.bg-blue-500\/15{background-color:#3b82f626}.bg-blue-500\/20{background-color:#3b82f633}.bg-blue-500\/\[0\.03\]{background-color:#3b82f608}.bg-blue-500\/\[0\.06\]{background-color:#3b82f60f}.bg-blue-950\/20{background-color:#17255433}.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-600\/60{background-color:#52525299}.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\/50{background-color:#26262680}.bg-neutral-800\/60{background-color:#26262699}.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-950{--tw-bg-opacity: 1;background-color:rgb(10 10 10 / var(--tw-bg-opacity, 1))}.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-transparent{background-color:transparent}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.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}.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-24{padding-top:6rem;padding-bottom:6rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-0\.5{padding-bottom:.125rem}.pb-16{padding-bottom:4rem}.pb-3{padding-bottom:.75rem}.pb-8{padding-bottom:2rem}.pr-1\.5{padding-right:.375rem}.pt-16{padding-top:4rem}.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-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[13px\]{font-size:13px}.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-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.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-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-tight{line-height:1.25}.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-amber-400{--tw-text-opacity: 1;color:rgb(251 191 36 / var(--tw-text-opacity, 1))}.text-blue-400{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.text-blue-400\/50{color:#60a5fa80}.text-blue-400\/60{color:#60a5fa99}.text-blue-400\/80{color:#60a5facc}.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-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-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.opacity-20{opacity:.2}.opacity-25{opacity:.25}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.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-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)}.outline-none{outline:2px solid transparent;outline-offset:2px}.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)}.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)}.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-\[width\]{transition-property:width;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-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}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}.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}.hover\:border-\[\#3CE6AC\]\/30:hover{border-color:#3ce6ac4d}.hover\:border-neutral-600:hover{--tw-border-opacity: 1;border-color:rgb(82 82 82 / var(--tw-border-opacity, 1))}.hover\:bg-\[\#3CE6AC\]\/80:hover{background-color:#3ce6accc}.hover\:bg-blue-500:hover{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity, 1))}.hover\:bg-blue-500\/25:hover{background-color:#3b82f640}.hover\:bg-neutral-200:hover{--tw-bg-opacity: 1;background-color:rgb(229 229 229 / 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-red-600:hover{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-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-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-white:hover{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.hover\:shadow-lg:hover{--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)}.hover\:shadow-\[\#3CE6AC\]\/5:hover{--tw-shadow-color: rgb(60 230 172 / .05);--tw-shadow: var(--tw-shadow-colored)}.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-blue-500\/40:focus{border-color:#3b82f666}.focus\:border-neutral-600:focus{--tw-border-opacity: 1;border-color:rgb(82 82 82 / var(--tw-border-opacity, 1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.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))}.active\:bg-blue-400:active{--tw-bg-opacity: 1;background-color:rgb(96 165 250 / var(--tw-bg-opacity, 1))}.disabled\:pointer-events-none:disabled{pointer-events:none}.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: 640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width: 1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}