@hyperframes/studio 0.1.0

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 (37) hide show
  1. package/dist/assets/index-B1830ANq.js +78 -0
  2. package/dist/assets/index-KoBceNoU.css +1 -0
  3. package/dist/icons/timeline/audio.svg +7 -0
  4. package/dist/icons/timeline/captions.svg +5 -0
  5. package/dist/icons/timeline/composition.svg +12 -0
  6. package/dist/icons/timeline/image.svg +18 -0
  7. package/dist/icons/timeline/music.svg +10 -0
  8. package/dist/icons/timeline/text.svg +3 -0
  9. package/dist/index.html +13 -0
  10. package/package.json +50 -0
  11. package/src/App.tsx +557 -0
  12. package/src/components/editor/FileTree.tsx +70 -0
  13. package/src/components/editor/PropertyPanel.tsx +209 -0
  14. package/src/components/editor/SourceEditor.tsx +116 -0
  15. package/src/components/nle/CompositionBreadcrumb.tsx +57 -0
  16. package/src/components/nle/NLELayout.tsx +252 -0
  17. package/src/components/nle/NLEPreview.tsx +37 -0
  18. package/src/components/ui/Button.tsx +123 -0
  19. package/src/components/ui/index.ts +2 -0
  20. package/src/hooks/useCodeEditor.ts +82 -0
  21. package/src/hooks/useElementPicker.ts +338 -0
  22. package/src/hooks/useMountEffect.ts +18 -0
  23. package/src/icons/SystemIcons.tsx +130 -0
  24. package/src/index.ts +31 -0
  25. package/src/main.tsx +10 -0
  26. package/src/player/components/AgentActivityTrack.tsx +98 -0
  27. package/src/player/components/Player.tsx +120 -0
  28. package/src/player/components/PlayerControls.tsx +181 -0
  29. package/src/player/components/PreviewPanel.tsx +149 -0
  30. package/src/player/components/Timeline.tsx +431 -0
  31. package/src/player/hooks/useTimelinePlayer.ts +465 -0
  32. package/src/player/index.ts +17 -0
  33. package/src/player/lib/time.ts +5 -0
  34. package/src/player/lib/useMountEffect.ts +10 -0
  35. package/src/player/store/playerStore.ts +93 -0
  36. package/src/styles/studio.css +31 -0
  37. package/src/utils/sourcePatcher.ts +149 -0
@@ -0,0 +1,465 @@
1
+ import { useRef, useCallback } from "react";
2
+ import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore";
3
+ import { useMountEffect } from "../lib/useMountEffect";
4
+
5
+ interface PlayerAPI {
6
+ play: () => void;
7
+ pause: () => void;
8
+ seek: (time: number) => void;
9
+ getTime: () => number;
10
+ getDuration: () => number;
11
+ isPlaying: () => boolean;
12
+ }
13
+
14
+ interface TimelineLike {
15
+ play: () => void;
16
+ pause: () => void;
17
+ seek: (time: number) => void;
18
+ time: () => number;
19
+ duration: () => number;
20
+ isActive: () => boolean;
21
+ }
22
+
23
+ interface ClipManifestClip {
24
+ id: string | null;
25
+ label: string;
26
+ start: number;
27
+ duration: number;
28
+ track: number;
29
+ kind: "video" | "audio" | "image" | "element" | "composition";
30
+ tagName: string | null;
31
+ compositionId: string | null;
32
+ parentCompositionId: string | null;
33
+ compositionSrc: string | null;
34
+ assetUrl: string | null;
35
+ }
36
+
37
+ interface ClipManifest {
38
+ clips: ClipManifestClip[];
39
+ scenes: Array<{ id: string; label: string; start: number; duration: number }>;
40
+ durationInFrames: number;
41
+ }
42
+
43
+ type IframeWindow = Window & {
44
+ __player?: PlayerAPI;
45
+ __timeline?: TimelineLike;
46
+ __timelines?: Record<string, TimelineLike>;
47
+ __clipManifest?: ClipManifest;
48
+ };
49
+
50
+ interface PlaybackAdapter {
51
+ play: () => void;
52
+ pause: () => void;
53
+ seek: (time: number) => void;
54
+ getTime: () => number;
55
+ getDuration: () => number;
56
+ isPlaying: () => boolean;
57
+ }
58
+
59
+ function wrapPlayer(p: PlayerAPI): PlaybackAdapter {
60
+ return {
61
+ play: () => p.play(),
62
+ pause: () => p.pause(),
63
+ seek: (t) => p.seek(t),
64
+ getTime: () => p.getTime(),
65
+ getDuration: () => p.getDuration(),
66
+ isPlaying: () => p.isPlaying(),
67
+ };
68
+ }
69
+
70
+ function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
71
+ return {
72
+ play: () => tl.play(),
73
+ pause: () => tl.pause(),
74
+ seek: (t) => {
75
+ tl.pause();
76
+ tl.seek(t);
77
+ },
78
+ getTime: () => tl.time(),
79
+ getDuration: () => tl.duration(),
80
+ isPlaying: () => tl.isActive(),
81
+ };
82
+ }
83
+
84
+ function normalizePreviewViewport(doc: Document, win: Window): void {
85
+ if (doc.documentElement) {
86
+ doc.documentElement.style.overflow = "hidden";
87
+ doc.documentElement.style.margin = "0";
88
+ }
89
+ if (doc.body) {
90
+ doc.body.style.overflow = "hidden";
91
+ doc.body.style.margin = "0";
92
+ }
93
+ win.scrollTo({ top: 0, left: 0, behavior: "auto" });
94
+ }
95
+
96
+ function autoHealMissingCompositionIds(doc: Document): void {
97
+ const compositionIdRe = /data-composition-id=["']([^"']+)["']/gi;
98
+ const referencedIds = new Set<string>();
99
+ const scopedNodes = Array.from(doc.querySelectorAll("style, script"));
100
+ for (const node of scopedNodes) {
101
+ const text = node.textContent || "";
102
+ if (!text) continue;
103
+ let match: RegExpExecArray | null;
104
+ while ((match = compositionIdRe.exec(text)) !== null) {
105
+ const id = (match[1] || "").trim();
106
+ if (id) referencedIds.add(id);
107
+ }
108
+ }
109
+
110
+ if (referencedIds.size === 0) return;
111
+
112
+ const existingIds = new Set<string>();
113
+ const existingNodes = Array.from(doc.querySelectorAll<HTMLElement>("[data-composition-id]"));
114
+ for (const node of existingNodes) {
115
+ const id = node.getAttribute("data-composition-id");
116
+ if (id) existingIds.add(id);
117
+ }
118
+
119
+ for (const compId of referencedIds) {
120
+ if (compId === "root" || existingIds.has(compId)) continue;
121
+ const host =
122
+ doc.getElementById(`${compId}-layer`) || doc.getElementById(`${compId}-comp`) || doc.getElementById(compId);
123
+ if (!host) continue;
124
+ if (!host.getAttribute("data-composition-id")) {
125
+ host.setAttribute("data-composition-id", compId);
126
+ }
127
+ }
128
+ }
129
+
130
+ function unmutePreviewMedia(iframe: HTMLIFrameElement | null): void {
131
+ if (!iframe) return;
132
+ try {
133
+ iframe.contentWindow?.postMessage(
134
+ { source: "hf-parent", type: "control", action: "set-muted", muted: false },
135
+ "*",
136
+ );
137
+ // Fallback for CDN runtime that still uses the old source name
138
+ iframe.contentWindow?.postMessage(
139
+ { source: "hf-parent", type: "control", action: "set-muted", muted: false },
140
+ "*",
141
+ );
142
+ } catch {
143
+ /* ignore */
144
+ }
145
+ }
146
+
147
+ export function useTimelinePlayer() {
148
+ const iframeRef = useRef<HTMLIFrameElement>(null);
149
+ const rafRef = useRef<number>(0);
150
+ const probeIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
151
+ const pendingSeekRef = useRef<number | null>(null);
152
+ const isRefreshingRef = useRef(false);
153
+
154
+ // ZERO store subscriptions — this hook never causes re-renders.
155
+ // All reads use getState() (point-in-time), all writes use the stable setters.
156
+ const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements, reset } =
157
+ usePlayerStore.getState();
158
+
159
+ const getAdapter = useCallback((): PlaybackAdapter | null => {
160
+ try {
161
+ const win = iframeRef.current?.contentWindow as IframeWindow | null;
162
+ if (!win) return null;
163
+
164
+ if (win.__player && typeof win.__player.play === "function") {
165
+ return wrapPlayer(win.__player);
166
+ }
167
+
168
+ if (win.__timeline) return wrapTimeline(win.__timeline);
169
+
170
+ if (win.__timelines) {
171
+ const keys = Object.keys(win.__timelines);
172
+ if (keys.length > 0) return wrapTimeline(win.__timelines[keys[keys.length - 1]]);
173
+ }
174
+
175
+ return null;
176
+ } catch {
177
+ return null;
178
+ }
179
+ }, []);
180
+
181
+ const startRAFLoop = useCallback(() => {
182
+ const tick = () => {
183
+ const adapter = getAdapter();
184
+ if (adapter) {
185
+ const time = adapter.getTime();
186
+ const dur = adapter.getDuration();
187
+ liveTime.notify(time); // direct DOM updates, no React re-render
188
+ if (time >= dur && !adapter.isPlaying()) {
189
+ setCurrentTime(time); // sync Zustand once at end
190
+ setIsPlaying(false);
191
+ cancelAnimationFrame(rafRef.current);
192
+ return;
193
+ }
194
+ }
195
+ rafRef.current = requestAnimationFrame(tick);
196
+ };
197
+ rafRef.current = requestAnimationFrame(tick);
198
+ }, [getAdapter, setCurrentTime, setIsPlaying]);
199
+
200
+ const stopRAFLoop = useCallback(() => {
201
+ cancelAnimationFrame(rafRef.current);
202
+ }, []);
203
+
204
+ const applyPlaybackRate = useCallback((rate: number) => {
205
+ const iframe = iframeRef.current;
206
+ if (!iframe) return;
207
+ // Send to runtime via bridge (works with both new and CDN runtime)
208
+ iframe.contentWindow?.postMessage({ source: "hf-parent", type: "control", action: "set-playback-rate", playbackRate: rate }, "*");
209
+ iframe.contentWindow?.postMessage({ source: "hf-parent", type: "control", action: "set-playback-rate", playbackRate: rate }, "*");
210
+ // Also set directly on GSAP timeline if accessible
211
+ try {
212
+ const win = iframe.contentWindow as IframeWindow | null;
213
+ if (win?.__timelines) {
214
+ for (const tl of Object.values(win.__timelines)) {
215
+ if (tl && typeof (tl as unknown as { timeScale?: (v: number) => void }).timeScale === "function") {
216
+ (tl as unknown as { timeScale: (v: number) => void }).timeScale(rate);
217
+ }
218
+ }
219
+ }
220
+ } catch { /* cross-origin */ }
221
+ }, []);
222
+
223
+ const play = useCallback(() => {
224
+ const adapter = getAdapter();
225
+ if (!adapter) return;
226
+ if (adapter.getTime() >= adapter.getDuration()) {
227
+ adapter.seek(0);
228
+ }
229
+ unmutePreviewMedia(iframeRef.current);
230
+ applyPlaybackRate(usePlayerStore.getState().playbackRate);
231
+ adapter.play();
232
+ setIsPlaying(true);
233
+ startRAFLoop();
234
+ }, [getAdapter, setIsPlaying, startRAFLoop, applyPlaybackRate]);
235
+
236
+ const pause = useCallback(() => {
237
+ const adapter = getAdapter();
238
+ if (!adapter) return;
239
+ adapter.pause();
240
+ setIsPlaying(false);
241
+ stopRAFLoop();
242
+ }, [getAdapter, setIsPlaying, stopRAFLoop]);
243
+
244
+ const togglePlay = useCallback(() => {
245
+ if (usePlayerStore.getState().isPlaying) {
246
+ pause();
247
+ } else {
248
+ play();
249
+ }
250
+ }, [play, pause]);
251
+
252
+ const seek = useCallback(
253
+ (time: number) => {
254
+ const adapter = getAdapter();
255
+ if (!adapter) return;
256
+ adapter.seek(time);
257
+ liveTime.notify(time); // Direct DOM updates (playhead, timecode, progress) — no re-render
258
+ stopRAFLoop();
259
+ // Only update store if state actually changes (avoids unnecessary re-renders)
260
+ if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
261
+ },
262
+ [getAdapter, setIsPlaying, stopRAFLoop],
263
+ );
264
+
265
+ // Convert a runtime timeline message (from iframe postMessage) into TimelineElements
266
+ const processTimelineMessage = useCallback(
267
+ (data: { clips: ClipManifestClip[]; durationInFrames: number }) => {
268
+ if (!data.clips || data.clips.length === 0) return;
269
+
270
+ // Show only root-level clips: those with no parentCompositionId (direct children of root).
271
+ // Sub-composition children (parentCompositionId !== null) belong to the drill-down view.
272
+ const els: TimelineElement[] = data.clips
273
+ .filter((clip) => !clip.parentCompositionId)
274
+ .map((clip) => {
275
+ const entry: TimelineElement = {
276
+ id: clip.id || clip.label || clip.tagName || "element",
277
+ tag: clip.tagName || clip.kind,
278
+ start: clip.start,
279
+ duration: clip.duration,
280
+ track: clip.track,
281
+ };
282
+ if (clip.assetUrl) entry.src = clip.assetUrl;
283
+ if (clip.kind === "composition" && clip.compositionId) {
284
+ entry.compositionSrc = clip.compositionSrc || `compositions/${clip.compositionId}.html`;
285
+ }
286
+ return entry;
287
+ });
288
+ setElements(els);
289
+ },
290
+ [setElements],
291
+ );
292
+
293
+ const onIframeLoad = useCallback(() => {
294
+ unmutePreviewMedia(iframeRef.current);
295
+
296
+ let attempts = 0;
297
+ const maxAttempts = 25;
298
+
299
+ if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
300
+
301
+ probeIntervalRef.current = setInterval(() => {
302
+ attempts++;
303
+ const adapter = getAdapter();
304
+ if (adapter && adapter.getDuration() > 0) {
305
+ clearInterval(probeIntervalRef.current);
306
+ adapter.pause();
307
+
308
+ const seekTo = pendingSeekRef.current;
309
+ pendingSeekRef.current = null;
310
+ const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
311
+
312
+ adapter.seek(startTime);
313
+ setDuration(adapter.getDuration());
314
+ setCurrentTime(startTime);
315
+ if (!isRefreshingRef.current) {
316
+ setTimelineReady(true);
317
+ }
318
+ isRefreshingRef.current = false;
319
+ setIsPlaying(false);
320
+
321
+ try {
322
+ const doc = iframeRef.current?.contentDocument;
323
+ const iframeWin = iframeRef.current?.contentWindow as IframeWindow | null;
324
+ if (doc && iframeWin) {
325
+ normalizePreviewViewport(doc, iframeWin);
326
+ autoHealMissingCompositionIds(doc);
327
+ }
328
+
329
+ // Try reading __clipManifest if already available (fast path)
330
+ const manifest = iframeWin?.__clipManifest;
331
+ if (manifest && manifest.clips.length > 0) {
332
+ processTimelineMessage(manifest);
333
+ } else if (doc) {
334
+ // Fallback: parse data-start elements directly from DOM (raw HTML without runtime)
335
+ const rootComp = doc.querySelector("[data-composition-id]");
336
+ const nodes = doc.querySelectorAll("[data-start]");
337
+ const els: TimelineElement[] = [];
338
+ let trackCounter = 0;
339
+ const rootDuration = adapter.getDuration();
340
+ nodes.forEach((node) => {
341
+ if (node === rootComp) return;
342
+ const el = node as HTMLElement;
343
+ const startStr = el.getAttribute("data-start");
344
+ if (startStr == null) return;
345
+ const start = parseFloat(startStr);
346
+ if (isNaN(start)) return;
347
+
348
+ const tagLower = el.tagName.toLowerCase();
349
+ let dur = 0;
350
+ const durStr = el.getAttribute("data-duration");
351
+ if (durStr != null) dur = parseFloat(durStr);
352
+ if (isNaN(dur) || dur <= 0) dur = Math.max(0, rootDuration - start);
353
+
354
+ const trackStr = el.getAttribute("data-track-index");
355
+ const track = trackStr != null ? parseInt(trackStr, 10) : trackCounter++;
356
+ const entry: TimelineElement = {
357
+ id: el.id || el.className?.split(" ")[0] || tagLower,
358
+ tag: tagLower,
359
+ start,
360
+ duration: dur,
361
+ track: isNaN(track) ? 0 : track,
362
+ };
363
+ if (tagLower === "video" || tagLower === "audio" || tagLower === "img") {
364
+ const src = el.getAttribute("src");
365
+ if (src) entry.src = src;
366
+ }
367
+ // Detect sub-compositions
368
+ const compSrc = el.getAttribute("data-composition-src");
369
+ const compId = el.getAttribute("data-composition-id");
370
+ if (compSrc || (compId && compId !== rootComp?.getAttribute("data-composition-id"))) {
371
+ entry.compositionSrc = compSrc || `compositions/${compId}.html`;
372
+ }
373
+ els.push(entry);
374
+ });
375
+ if (els.length > 0) setElements(els);
376
+ }
377
+ // The runtime will also postMessage the full timeline after all compositions load.
378
+ // That message is handled by the window listener below, which will update elements
379
+ // with the complete data (including async-loaded compositions).
380
+ } catch {
381
+ // Cross-origin or DOM access error
382
+ }
383
+
384
+ return;
385
+ }
386
+ if (attempts >= maxAttempts) {
387
+ clearInterval(probeIntervalRef.current);
388
+ console.warn("Could not find __player, __timeline, or __timelines on iframe after 5s");
389
+ }
390
+ }, 200);
391
+ }, [getAdapter, setDuration, setCurrentTime, setTimelineReady, setIsPlaying, processTimelineMessage]);
392
+
393
+ /** Save the current playback time so the next onIframeLoad restores it. */
394
+ const saveSeekPosition = useCallback(() => {
395
+ const adapter = getAdapter();
396
+ pendingSeekRef.current = adapter ? adapter.getTime() : (usePlayerStore.getState().currentTime ?? 0);
397
+ isRefreshingRef.current = true;
398
+ stopRAFLoop();
399
+ setIsPlaying(false);
400
+ }, [getAdapter, stopRAFLoop, setIsPlaying]);
401
+
402
+ const refreshPlayer = useCallback(() => {
403
+ const iframe = iframeRef.current;
404
+ if (!iframe) return;
405
+
406
+ saveSeekPosition();
407
+
408
+ const src = iframe.src;
409
+ const url = new URL(src, window.location.origin);
410
+ url.searchParams.set("_t", String(Date.now()));
411
+ iframe.src = url.toString();
412
+ }, [saveSeekPosition]);
413
+
414
+ const togglePlayRef = useRef(togglePlay);
415
+ togglePlayRef.current = togglePlay;
416
+ const processTimelineMessageRef = useRef(processTimelineMessage);
417
+ processTimelineMessageRef.current = processTimelineMessage;
418
+
419
+ useMountEffect(() => {
420
+ const handleKeyDown = (e: KeyboardEvent) => {
421
+ if (e.code === "Space" && e.target === document.body) {
422
+ e.preventDefault();
423
+ togglePlayRef.current();
424
+ }
425
+ };
426
+
427
+ // Listen for timeline messages from the iframe runtime.
428
+ // The runtime sends this AFTER all external compositions load,
429
+ // so we get the complete clip list (not just the first few).
430
+ const handleMessage = (e: MessageEvent) => {
431
+ const data = e.data;
432
+ if ((data?.source === "hf-preview" || data?.source === "hf-preview") && data?.type === "timeline" && Array.isArray(data.clips)) {
433
+ processTimelineMessageRef.current(data);
434
+ // Update duration only if the new value is longer (don't downgrade during generation)
435
+ if (data.durationInFrames > 0) {
436
+ const fps = 30;
437
+ const dur = data.durationInFrames / fps;
438
+ const currentDur = usePlayerStore.getState().duration;
439
+ if (dur > currentDur) usePlayerStore.getState().setDuration(dur);
440
+ }
441
+ }
442
+ };
443
+
444
+ window.addEventListener("keydown", handleKeyDown);
445
+ window.addEventListener("message", handleMessage);
446
+ return () => {
447
+ window.removeEventListener("keydown", handleKeyDown);
448
+ window.removeEventListener("message", handleMessage);
449
+ stopRAFLoop();
450
+ if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
451
+ reset();
452
+ };
453
+ });
454
+
455
+ return {
456
+ iframeRef,
457
+ play,
458
+ pause,
459
+ togglePlay,
460
+ seek,
461
+ onIframeLoad,
462
+ refreshPlayer,
463
+ saveSeekPosition,
464
+ };
465
+ }
@@ -0,0 +1,17 @@
1
+ // Components
2
+ export { Player } from "./components/Player";
3
+ export { PlayerControls } from "./components/PlayerControls";
4
+ export { Timeline } from "./components/Timeline";
5
+ export { PreviewPanel } from "./components/PreviewPanel";
6
+ export { AgentActivityTrack } from "./components/AgentActivityTrack";
7
+ export type { AgentActivity } from "./components/AgentActivityTrack";
8
+
9
+ // Hooks
10
+ export { useTimelinePlayer } from "./hooks/useTimelinePlayer";
11
+
12
+ // Store
13
+ export { usePlayerStore, liveTime } from "./store/playerStore";
14
+ export type { TimelineElement, ActiveEdits } from "./store/playerStore";
15
+
16
+ // Utils
17
+ export { formatTime } from "./lib/time";
@@ -0,0 +1,5 @@
1
+ export function formatTime(time: number): string {
2
+ const mins = Math.floor(time / 60);
3
+ const secs = Math.floor(time % 60);
4
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
5
+ }
@@ -0,0 +1,10 @@
1
+ import { useEffect } from "react";
2
+
3
+ /**
4
+ * Run an effect exactly once on mount (and optional cleanup on unmount).
5
+ * This is the ONLY sanctioned way to call useEffect in this package.
6
+ */
7
+ export function useMountEffect(effect: () => void | (() => void)) {
8
+ // eslint-disable-next-line react-hooks/exhaustive-deps
9
+ useEffect(effect, []);
10
+ }
@@ -0,0 +1,93 @@
1
+ import { create } from "zustand";
2
+
3
+ export interface TimelineElement {
4
+ id: string;
5
+ tag: string;
6
+ start: number;
7
+ duration: number;
8
+ track: number;
9
+ src?: string;
10
+ playbackStart?: number;
11
+ volume?: number;
12
+ /** Path from data-composition-src — identifies sub-composition elements */
13
+ compositionSrc?: string;
14
+ /** Agent that created/last edited this element */
15
+ agentId?: string;
16
+ /** Agent's color for ownership visualization */
17
+ agentColor?: string;
18
+ }
19
+
20
+ /** Map of elementId → agentColor for clips currently being edited */
21
+ export interface ActiveEdits {
22
+ [elementId: string]: { agentId: string; agentColor: string };
23
+ }
24
+
25
+ interface PlayerState {
26
+ isPlaying: boolean;
27
+ currentTime: number;
28
+ duration: number;
29
+ timelineReady: boolean;
30
+ elements: TimelineElement[];
31
+ selectedElementId: string | null;
32
+ /** Clips currently being edited by agents — for glow animation */
33
+ activeEdits: ActiveEdits;
34
+ playbackRate: number;
35
+
36
+ setIsPlaying: (playing: boolean) => void;
37
+ setCurrentTime: (time: number) => void;
38
+ setDuration: (duration: number) => void;
39
+ setPlaybackRate: (rate: number) => void;
40
+ setTimelineReady: (ready: boolean) => void;
41
+ setElements: (elements: TimelineElement[]) => void;
42
+ setSelectedElementId: (id: string | null) => void;
43
+ setActiveEdits: (edits: ActiveEdits) => void;
44
+ updateElementStart: (elementId: string, newStart: number) => void;
45
+ reset: () => void;
46
+ }
47
+
48
+ // Lightweight pub-sub for current time during playback.
49
+ // Bypasses React state so the RAF loop can update the playhead/time display
50
+ // without triggering re-renders on every frame.
51
+ type TimeListener = (time: number) => void;
52
+ const _timeListeners = new Set<TimeListener>();
53
+ export const liveTime = {
54
+ notify: (t: number) => _timeListeners.forEach((cb) => cb(t)),
55
+ subscribe: (cb: TimeListener) => {
56
+ _timeListeners.add(cb);
57
+ return () => _timeListeners.delete(cb);
58
+ },
59
+ };
60
+
61
+ export const usePlayerStore = create<PlayerState>((set) => ({
62
+ isPlaying: false,
63
+ currentTime: 0,
64
+ duration: 0,
65
+ timelineReady: false,
66
+ elements: [],
67
+ selectedElementId: null,
68
+ activeEdits: {},
69
+ playbackRate: 1,
70
+
71
+ setIsPlaying: (playing) => set({ isPlaying: playing }),
72
+ setPlaybackRate: (rate) => set({ playbackRate: rate }),
73
+ setCurrentTime: (time) => set({ currentTime: time }),
74
+ setDuration: (duration) => set({ duration }),
75
+ setTimelineReady: (ready) => set({ timelineReady: ready }),
76
+ setElements: (elements) => set({ elements }),
77
+ setSelectedElementId: (id) => set({ selectedElementId: id }),
78
+ setActiveEdits: (edits) => set({ activeEdits: edits }),
79
+ updateElementStart: (elementId, newStart) =>
80
+ set((state) => ({
81
+ elements: state.elements.map((el) => (el.id === elementId ? { ...el, start: newStart } : el)),
82
+ })),
83
+ reset: () =>
84
+ set({
85
+ isPlaying: false,
86
+ currentTime: 0,
87
+ duration: 0,
88
+ timelineReady: false,
89
+ elements: [],
90
+ selectedElementId: null,
91
+ activeEdits: {},
92
+ }),
93
+ }));
@@ -0,0 +1,31 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ body {
6
+ margin: 0;
7
+ padding: 0;
8
+ background: #0a0a0a;
9
+ color: #e5e5e5;
10
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
11
+ overflow: hidden;
12
+ }
13
+
14
+ #root {
15
+ width: 100vw;
16
+ height: 100vh;
17
+ }
18
+
19
+ /* CodeMirror overrides */
20
+ .cm-editor {
21
+ height: 100%;
22
+ font-size: 13px;
23
+ }
24
+
25
+ .cm-editor .cm-scroller {
26
+ font-family: "JetBrains Mono", "Fira Code", "SF Mono", monospace;
27
+ }
28
+
29
+ .cm-editor.cm-focused {
30
+ outline: none;
31
+ }