@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.
- package/dist/assets/index-B1830ANq.js +78 -0
- package/dist/assets/index-KoBceNoU.css +1 -0
- package/dist/icons/timeline/audio.svg +7 -0
- package/dist/icons/timeline/captions.svg +5 -0
- package/dist/icons/timeline/composition.svg +12 -0
- package/dist/icons/timeline/image.svg +18 -0
- package/dist/icons/timeline/music.svg +10 -0
- package/dist/icons/timeline/text.svg +3 -0
- package/dist/index.html +13 -0
- package/package.json +50 -0
- package/src/App.tsx +557 -0
- package/src/components/editor/FileTree.tsx +70 -0
- package/src/components/editor/PropertyPanel.tsx +209 -0
- package/src/components/editor/SourceEditor.tsx +116 -0
- package/src/components/nle/CompositionBreadcrumb.tsx +57 -0
- package/src/components/nle/NLELayout.tsx +252 -0
- package/src/components/nle/NLEPreview.tsx +37 -0
- package/src/components/ui/Button.tsx +123 -0
- package/src/components/ui/index.ts +2 -0
- package/src/hooks/useCodeEditor.ts +82 -0
- package/src/hooks/useElementPicker.ts +338 -0
- package/src/hooks/useMountEffect.ts +18 -0
- package/src/icons/SystemIcons.tsx +130 -0
- package/src/index.ts +31 -0
- package/src/main.tsx +10 -0
- package/src/player/components/AgentActivityTrack.tsx +98 -0
- package/src/player/components/Player.tsx +120 -0
- package/src/player/components/PlayerControls.tsx +181 -0
- package/src/player/components/PreviewPanel.tsx +149 -0
- package/src/player/components/Timeline.tsx +431 -0
- package/src/player/hooks/useTimelinePlayer.ts +465 -0
- package/src/player/index.ts +17 -0
- package/src/player/lib/time.ts +5 -0
- package/src/player/lib/useMountEffect.ts +10 -0
- package/src/player/store/playerStore.ts +93 -0
- package/src/styles/studio.css +31 -0
- package/src/utils/sourcePatcher.ts +149 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { useRef, useMemo, useCallback, useState, memo, type ReactNode } from "react";
|
|
2
|
+
import { usePlayerStore, liveTime } from "../store/playerStore";
|
|
3
|
+
import { useMountEffect } from "../lib/useMountEffect";
|
|
4
|
+
|
|
5
|
+
/* ── Layout ─────────────────────────────────────────────────────── */
|
|
6
|
+
const GUTTER = 32;
|
|
7
|
+
const TRACK_H = 28;
|
|
8
|
+
const RULER_H = 24;
|
|
9
|
+
const CLIP_Y = 2; // vertical inset inside track
|
|
10
|
+
|
|
11
|
+
/* ── Vibrant Color System (Figma-inspired, dark-mode adapted) ──── */
|
|
12
|
+
interface TrackStyle {
|
|
13
|
+
/** Clip solid background */
|
|
14
|
+
clip: string;
|
|
15
|
+
/** Dark text color for label on clip */
|
|
16
|
+
label: string;
|
|
17
|
+
/** Track row tint (very subtle) */
|
|
18
|
+
row: string;
|
|
19
|
+
/** Gutter icon circle background */
|
|
20
|
+
gutter: string;
|
|
21
|
+
/** SVG icon paths (viewBox 0 0 24 24) */
|
|
22
|
+
icon: ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* ── Icons from Figma HyperFrames design system ── */
|
|
26
|
+
const ICON_BASE = "/icons/timeline";
|
|
27
|
+
function TimelineIcon({ src }: { src: string }) {
|
|
28
|
+
return <img src={src} alt="" width={12} height={12} style={{ filter: "brightness(0) invert(1)" }} draggable={false} />;
|
|
29
|
+
}
|
|
30
|
+
const IconCaptions = <TimelineIcon src={`${ICON_BASE}/captions.svg`} />;
|
|
31
|
+
const IconImage = <TimelineIcon src={`${ICON_BASE}/image.svg`} />;
|
|
32
|
+
const IconMusic = <TimelineIcon src={`${ICON_BASE}/music.svg`} />;
|
|
33
|
+
const IconText = <TimelineIcon src={`${ICON_BASE}/text.svg`} />;
|
|
34
|
+
const IconComposition = <TimelineIcon src={`${ICON_BASE}/composition.svg`} />;
|
|
35
|
+
const IconAudio = <TimelineIcon src={`${ICON_BASE}/audio.svg`} />;
|
|
36
|
+
|
|
37
|
+
const STYLES: Record<string, TrackStyle> = {
|
|
38
|
+
video: {
|
|
39
|
+
clip: "#1F6AFF",
|
|
40
|
+
label: "#DBEAFE",
|
|
41
|
+
row: "rgba(31,106,255,0.04)",
|
|
42
|
+
gutter: "#1F6AFF",
|
|
43
|
+
icon: IconImage,
|
|
44
|
+
},
|
|
45
|
+
audio: {
|
|
46
|
+
clip: "#00C4FF",
|
|
47
|
+
label: "#013A4B",
|
|
48
|
+
row: "rgba(0,196,255,0.04)",
|
|
49
|
+
gutter: "#00C4FF",
|
|
50
|
+
icon: IconMusic,
|
|
51
|
+
},
|
|
52
|
+
img: {
|
|
53
|
+
clip: "#8B5CF6",
|
|
54
|
+
label: "#EDE9FE",
|
|
55
|
+
row: "rgba(139,92,246,0.04)",
|
|
56
|
+
gutter: "#8B5CF6",
|
|
57
|
+
icon: IconImage,
|
|
58
|
+
},
|
|
59
|
+
div: {
|
|
60
|
+
clip: "#68B200",
|
|
61
|
+
label: "#1A2B03",
|
|
62
|
+
row: "rgba(104,178,0,0.04)",
|
|
63
|
+
gutter: "#68B200",
|
|
64
|
+
icon: IconComposition,
|
|
65
|
+
},
|
|
66
|
+
span: {
|
|
67
|
+
clip: "#F3A6FF",
|
|
68
|
+
label: "#8D00A3",
|
|
69
|
+
row: "rgba(243,166,255,0.04)",
|
|
70
|
+
gutter: "#F3A6FF",
|
|
71
|
+
icon: IconCaptions,
|
|
72
|
+
},
|
|
73
|
+
p: {
|
|
74
|
+
clip: "#35C838",
|
|
75
|
+
label: "#024A03",
|
|
76
|
+
row: "rgba(53,200,56,0.04)",
|
|
77
|
+
gutter: "#35C838",
|
|
78
|
+
icon: IconText,
|
|
79
|
+
},
|
|
80
|
+
h1: {
|
|
81
|
+
clip: "#35C838",
|
|
82
|
+
label: "#024A03",
|
|
83
|
+
row: "rgba(53,200,56,0.04)",
|
|
84
|
+
gutter: "#35C838",
|
|
85
|
+
icon: IconText,
|
|
86
|
+
},
|
|
87
|
+
section: {
|
|
88
|
+
clip: "#68B200",
|
|
89
|
+
label: "#1A2B03",
|
|
90
|
+
row: "rgba(104,178,0,0.04)",
|
|
91
|
+
gutter: "#68B200",
|
|
92
|
+
icon: IconComposition,
|
|
93
|
+
},
|
|
94
|
+
sfx: {
|
|
95
|
+
clip: "#FF8C42",
|
|
96
|
+
label: "#512000",
|
|
97
|
+
row: "rgba(255,140,66,0.04)",
|
|
98
|
+
gutter: "#FF8C42",
|
|
99
|
+
icon: IconAudio,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const DEFAULT: TrackStyle = {
|
|
104
|
+
clip: "#6B7280",
|
|
105
|
+
label: "#F3F4F6",
|
|
106
|
+
row: "rgba(107,114,128,0.03)",
|
|
107
|
+
gutter: "#6B7280",
|
|
108
|
+
icon: IconComposition,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
function getStyle(tag: string): TrackStyle {
|
|
112
|
+
const t = tag.toLowerCase();
|
|
113
|
+
if (t.startsWith("h") && t.length === 2 && "123456".includes(t[1])) return STYLES.h1;
|
|
114
|
+
return STYLES[t] ?? DEFAULT;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* ── Tick Generation ────────────────────────────────────────────── */
|
|
118
|
+
function generateTicks(duration: number): { major: number[]; minor: number[] } {
|
|
119
|
+
if (duration <= 0) return { major: [], minor: [] };
|
|
120
|
+
const intervals = [0.5, 1, 2, 5, 10, 15, 30, 60];
|
|
121
|
+
const target = duration / 6;
|
|
122
|
+
const majorInterval = intervals.find((i) => i >= target) ?? 60;
|
|
123
|
+
const minorInterval = majorInterval / 2;
|
|
124
|
+
const major: number[] = [];
|
|
125
|
+
const minor: number[] = [];
|
|
126
|
+
for (let t = 0; t <= duration + 0.001; t += minorInterval) {
|
|
127
|
+
const rounded = Math.round(t * 100) / 100;
|
|
128
|
+
const isMajor = Math.abs(rounded % majorInterval) < 0.01 || Math.abs(rounded % majorInterval - majorInterval) < 0.01;
|
|
129
|
+
if (isMajor) major.push(rounded);
|
|
130
|
+
else minor.push(rounded);
|
|
131
|
+
}
|
|
132
|
+
return { major, minor };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function formatTick(s: number): string {
|
|
136
|
+
const m = Math.floor(s / 60);
|
|
137
|
+
const sec = Math.floor(s % 60);
|
|
138
|
+
return `${m}:${sec.toString().padStart(2, "0")}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* ── Component ──────────────────────────────────────────────────── */
|
|
142
|
+
interface TimelineProps {
|
|
143
|
+
/** Called when user seeks via ruler/track click or playhead drag */
|
|
144
|
+
onSeek?: (time: number) => void;
|
|
145
|
+
/** Called when user double-clicks a composition clip to drill into it */
|
|
146
|
+
onDrillDown?: (element: import("../store/playerStore").TimelineElement) => void;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: TimelineProps = {}) {
|
|
150
|
+
const elements = usePlayerStore((s) => s.elements);
|
|
151
|
+
const duration = usePlayerStore((s) => s.duration);
|
|
152
|
+
const timelineReady = usePlayerStore((s) => s.timelineReady);
|
|
153
|
+
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
|
154
|
+
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
155
|
+
const activeEdits = usePlayerStore((s) => s.activeEdits);
|
|
156
|
+
const playheadRef = useRef<HTMLDivElement>(null);
|
|
157
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
158
|
+
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
|
|
159
|
+
const isDragging = useRef(false);
|
|
160
|
+
|
|
161
|
+
const durationRef = useRef(duration);
|
|
162
|
+
durationRef.current = duration;
|
|
163
|
+
useMountEffect(() => {
|
|
164
|
+
const unsub = liveTime.subscribe((t) => {
|
|
165
|
+
const dur = durationRef.current;
|
|
166
|
+
if (!playheadRef.current || dur <= 0) return;
|
|
167
|
+
const pct = (t / dur) * 100;
|
|
168
|
+
playheadRef.current.style.left = `calc(${GUTTER}px + (100% - ${GUTTER}px) * ${pct / 100})`;
|
|
169
|
+
});
|
|
170
|
+
return unsub;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const seekFromX = useCallback(
|
|
174
|
+
(clientX: number) => {
|
|
175
|
+
const el = containerRef.current;
|
|
176
|
+
if (!el || duration <= 0) return;
|
|
177
|
+
const rect = el.getBoundingClientRect();
|
|
178
|
+
const start = rect.left + GUTTER;
|
|
179
|
+
const w = rect.width - GUTTER;
|
|
180
|
+
if (w <= 0) return;
|
|
181
|
+
const pct = Math.max(0, Math.min(1, (clientX - start) / w));
|
|
182
|
+
const time = pct * duration;
|
|
183
|
+
// Notify liveTime for instant visual update (direct DOM, no re-render)
|
|
184
|
+
liveTime.notify(time);
|
|
185
|
+
// Call parent's onSeek to actually seek the iframe/player
|
|
186
|
+
onSeek?.(time);
|
|
187
|
+
},
|
|
188
|
+
[duration, onSeek],
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const handlePointerDown = useCallback(
|
|
192
|
+
(e: React.PointerEvent) => {
|
|
193
|
+
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
|
194
|
+
isDragging.current = true;
|
|
195
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
196
|
+
seekFromX(e.clientX);
|
|
197
|
+
},
|
|
198
|
+
[seekFromX],
|
|
199
|
+
);
|
|
200
|
+
const handlePointerMove = useCallback(
|
|
201
|
+
(e: React.PointerEvent) => { if (isDragging.current) seekFromX(e.clientX); },
|
|
202
|
+
[seekFromX],
|
|
203
|
+
);
|
|
204
|
+
const handlePointerUp = useCallback(() => { isDragging.current = false; }, []);
|
|
205
|
+
|
|
206
|
+
const tracks = useMemo(() => {
|
|
207
|
+
const map = new Map<number, typeof elements>();
|
|
208
|
+
for (const el of elements) {
|
|
209
|
+
const list = map.get(el.track) ?? [];
|
|
210
|
+
list.push(el);
|
|
211
|
+
map.set(el.track, list);
|
|
212
|
+
}
|
|
213
|
+
return Array.from(map.entries()).sort(([a], [b]) => a - b);
|
|
214
|
+
}, [elements]);
|
|
215
|
+
|
|
216
|
+
// Determine dominant style per track (from first element)
|
|
217
|
+
const trackStyles = useMemo(() => {
|
|
218
|
+
const map = new Map<number, TrackStyle>();
|
|
219
|
+
for (const [trackNum, els] of tracks) {
|
|
220
|
+
map.set(trackNum, getStyle(els[0]?.tag ?? ""));
|
|
221
|
+
}
|
|
222
|
+
return map;
|
|
223
|
+
}, [tracks]);
|
|
224
|
+
|
|
225
|
+
const { major, minor } = useMemo(() => generateTicks(duration), [duration]);
|
|
226
|
+
|
|
227
|
+
if (!timelineReady) return null;
|
|
228
|
+
if (elements.length === 0) {
|
|
229
|
+
return <div className="px-3 py-3 text-2xs text-neutral-600 border-t border-neutral-800/50">No timeline elements</div>;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const totalH = RULER_H + tracks.length * TRACK_H;
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<div
|
|
236
|
+
ref={containerRef}
|
|
237
|
+
aria-label="Timeline"
|
|
238
|
+
className="border-t border-neutral-800/50 bg-[#0a0a0b] select-none overflow-x-hidden cursor-crosshair"
|
|
239
|
+
style={{ touchAction: "none" }}
|
|
240
|
+
onPointerDown={handlePointerDown}
|
|
241
|
+
onPointerMove={handlePointerMove}
|
|
242
|
+
onPointerUp={handlePointerUp}
|
|
243
|
+
>
|
|
244
|
+
<div className="relative" style={{ height: totalH }}>
|
|
245
|
+
{/* Grid lines */}
|
|
246
|
+
<svg className="absolute pointer-events-none" style={{ left: GUTTER }} width={`calc(100% - ${GUTTER}px)`} height={totalH}>
|
|
247
|
+
{major.map((t) => (
|
|
248
|
+
<line key={`g-${t}`} x1={`${(t / duration) * 100}%`} y1={RULER_H} x2={`${(t / duration) * 100}%`} y2={totalH} stroke="rgba(255,255,255,0.035)" strokeWidth="1" />
|
|
249
|
+
))}
|
|
250
|
+
</svg>
|
|
251
|
+
|
|
252
|
+
{/* Ruler */}
|
|
253
|
+
<div className="relative border-b border-neutral-800/40" style={{ height: RULER_H, marginLeft: GUTTER }}>
|
|
254
|
+
{minor.map((t) => (
|
|
255
|
+
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: `${(t / duration) * 100}%` }}>
|
|
256
|
+
<div className="w-px h-[3px] bg-neutral-700/40" />
|
|
257
|
+
</div>
|
|
258
|
+
))}
|
|
259
|
+
{major.map((t) => (
|
|
260
|
+
<div key={`M-${t}`} className="absolute bottom-0 flex flex-col items-center" style={{ left: `${(t / duration) * 100}%` }}>
|
|
261
|
+
<span className="text-[9px] text-neutral-500 font-mono tabular-nums leading-none mb-0.5">{formatTick(t)}</span>
|
|
262
|
+
<div className="w-px h-[5px] bg-neutral-600/60" />
|
|
263
|
+
</div>
|
|
264
|
+
))}
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
{/* Tracks */}
|
|
268
|
+
{tracks.map(([trackNum, els]) => {
|
|
269
|
+
const ts = trackStyles.get(trackNum) ?? DEFAULT;
|
|
270
|
+
return (
|
|
271
|
+
<div key={trackNum} className="relative flex" style={{ height: TRACK_H, backgroundColor: ts.row }}>
|
|
272
|
+
{/* Gutter: colored icon badge (Figma HyperFrames style) */}
|
|
273
|
+
<div className="flex-shrink-0 flex items-center justify-center" style={{ width: GUTTER }}>
|
|
274
|
+
<div
|
|
275
|
+
className="flex items-center justify-center"
|
|
276
|
+
style={{
|
|
277
|
+
width: 20,
|
|
278
|
+
height: 20,
|
|
279
|
+
borderRadius: 6,
|
|
280
|
+
backgroundColor: ts.gutter,
|
|
281
|
+
border: "1px solid rgba(255,255,255,0.35)",
|
|
282
|
+
color: "#fff",
|
|
283
|
+
}}
|
|
284
|
+
>
|
|
285
|
+
{ts.icon}
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
{/* Clips */}
|
|
290
|
+
<div className="flex-1 relative">
|
|
291
|
+
{els.map((el, i) => {
|
|
292
|
+
const leftPct = (el.start / duration) * 100;
|
|
293
|
+
const widthPct = (el.duration / duration) * 100;
|
|
294
|
+
const style = getStyle(el.tag);
|
|
295
|
+
const isSelected = selectedElementId === el.id;
|
|
296
|
+
const isComposition = !!el.compositionSrc;
|
|
297
|
+
const clipKey = `${el.id}-${i}`;
|
|
298
|
+
const isHovered = hoveredClip === clipKey;
|
|
299
|
+
const activeEdit = activeEdits[el.id];
|
|
300
|
+
const isBeingEdited = !!activeEdit;
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
<div
|
|
304
|
+
key={clipKey}
|
|
305
|
+
data-clip="true"
|
|
306
|
+
className="absolute flex items-center overflow-hidden"
|
|
307
|
+
style={{
|
|
308
|
+
left: `${leftPct}%`,
|
|
309
|
+
width: `${Math.max(widthPct, 1)}%`,
|
|
310
|
+
top: CLIP_Y,
|
|
311
|
+
bottom: CLIP_Y,
|
|
312
|
+
borderRadius: 5,
|
|
313
|
+
backgroundColor: style.clip,
|
|
314
|
+
backgroundImage: isComposition
|
|
315
|
+
? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.08) 3px, rgba(255,255,255,0.08) 6px)`
|
|
316
|
+
: undefined,
|
|
317
|
+
border: isSelected ? `2px solid rgba(255,255,255,0.9)` : `1px solid rgba(255,255,255,${isHovered ? 0.3 : 0.15})`,
|
|
318
|
+
boxShadow: isSelected
|
|
319
|
+
? `0 0 0 1px ${style.clip}, 0 2px 8px rgba(0,0,0,0.4)`
|
|
320
|
+
: isBeingEdited
|
|
321
|
+
? `0 0 0 1px ${activeEdit.agentColor}80, 0 0 8px ${activeEdit.agentColor}40`
|
|
322
|
+
: isHovered
|
|
323
|
+
? "0 1px 4px rgba(0,0,0,0.3)"
|
|
324
|
+
: "none",
|
|
325
|
+
cursor: "pointer",
|
|
326
|
+
transition: "border-color 120ms, box-shadow 120ms, transform 80ms",
|
|
327
|
+
transform: isHovered && !isSelected ? "scaleY(1.04)" : "scaleY(1)",
|
|
328
|
+
zIndex: isSelected ? 10 : isHovered ? 5 : 1,
|
|
329
|
+
}}
|
|
330
|
+
title={isComposition
|
|
331
|
+
? `${el.compositionSrc} \u2022 Double-click to open`
|
|
332
|
+
: `${el.id || el.tag} \u2022 ${el.start.toFixed(1)}s \u2013 ${(el.start + el.duration).toFixed(1)}s`}
|
|
333
|
+
onPointerEnter={() => setHoveredClip(clipKey)}
|
|
334
|
+
onPointerLeave={() => setHoveredClip(null)}
|
|
335
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
336
|
+
onClick={(e) => {
|
|
337
|
+
e.stopPropagation();
|
|
338
|
+
setSelectedElementId(isSelected ? null : el.id);
|
|
339
|
+
}}
|
|
340
|
+
onDoubleClick={(e) => {
|
|
341
|
+
e.stopPropagation();
|
|
342
|
+
if (isComposition && onDrillDown) {
|
|
343
|
+
onDrillDown(el);
|
|
344
|
+
}
|
|
345
|
+
}}
|
|
346
|
+
>
|
|
347
|
+
{/* Agent ownership dot */}
|
|
348
|
+
{el.agentColor && (
|
|
349
|
+
<div
|
|
350
|
+
className="flex-shrink-0 w-1.5 h-1.5 rounded-full ml-1"
|
|
351
|
+
style={{ backgroundColor: el.agentColor }}
|
|
352
|
+
title={el.agentId ? `Agent: ${el.agentId}` : undefined}
|
|
353
|
+
/>
|
|
354
|
+
)}
|
|
355
|
+
{/* Editing glow pulse */}
|
|
356
|
+
{/* Agent editing indicator — cursor on the clip */}
|
|
357
|
+
{isBeingEdited && (
|
|
358
|
+
<>
|
|
359
|
+
<div
|
|
360
|
+
className="absolute inset-0 rounded-[5px] animate-pulse pointer-events-none"
|
|
361
|
+
style={{ boxShadow: `inset 0 0 0 1px ${activeEdit.agentColor}60` }}
|
|
362
|
+
/>
|
|
363
|
+
{/* Agent name badge above clip */}
|
|
364
|
+
<div
|
|
365
|
+
className="absolute pointer-events-none flex items-center gap-1"
|
|
366
|
+
style={{
|
|
367
|
+
top: -16,
|
|
368
|
+
left: 2,
|
|
369
|
+
zIndex: 30,
|
|
370
|
+
}}
|
|
371
|
+
>
|
|
372
|
+
{/* Mini cursor arrow */}
|
|
373
|
+
<svg width="8" height="10" viewBox="0 0 12 16" fill="none" style={{ flexShrink: 0 }}>
|
|
374
|
+
<path d="M1 1L11 7L6 8L4 14L1 1Z" fill={activeEdit.agentColor} stroke="white" strokeWidth="0.8" />
|
|
375
|
+
</svg>
|
|
376
|
+
<span
|
|
377
|
+
className="text-[8px] font-semibold px-1 py-px rounded whitespace-nowrap"
|
|
378
|
+
style={{
|
|
379
|
+
backgroundColor: activeEdit.agentColor,
|
|
380
|
+
color: "white",
|
|
381
|
+
boxShadow: `0 1px 4px ${activeEdit.agentColor}40`,
|
|
382
|
+
}}
|
|
383
|
+
>
|
|
384
|
+
{activeEdit.agentId}
|
|
385
|
+
</span>
|
|
386
|
+
</div>
|
|
387
|
+
</>
|
|
388
|
+
)}
|
|
389
|
+
<span
|
|
390
|
+
className="text-[10px] font-semibold truncate px-1.5 leading-none"
|
|
391
|
+
style={{ color: style.label }}
|
|
392
|
+
>
|
|
393
|
+
{el.id || el.tag}
|
|
394
|
+
</span>
|
|
395
|
+
{widthPct > 10 && (
|
|
396
|
+
<span
|
|
397
|
+
className="text-[9px] font-mono tabular-nums pr-1.5 ml-auto flex-shrink-0 leading-none opacity-70"
|
|
398
|
+
style={{ color: style.label }}
|
|
399
|
+
>
|
|
400
|
+
{el.duration.toFixed(1)}s
|
|
401
|
+
</span>
|
|
402
|
+
)}
|
|
403
|
+
</div>
|
|
404
|
+
);
|
|
405
|
+
})}
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
);
|
|
409
|
+
})}
|
|
410
|
+
|
|
411
|
+
{/* Playhead */}
|
|
412
|
+
<div
|
|
413
|
+
ref={playheadRef}
|
|
414
|
+
className="absolute top-0 bottom-0 z-20 pointer-events-none"
|
|
415
|
+
style={{ left: `${GUTTER}px` }}
|
|
416
|
+
>
|
|
417
|
+
<div className="absolute top-0 bottom-0 left-1/2 -translate-x-1/2 w-px bg-white/90" />
|
|
418
|
+
<div className="absolute left-1/2 -translate-x-1/2" style={{ top: 0 }}>
|
|
419
|
+
<div style={{
|
|
420
|
+
width: 0,
|
|
421
|
+
height: 0,
|
|
422
|
+
borderLeft: "5px solid transparent",
|
|
423
|
+
borderRight: "5px solid transparent",
|
|
424
|
+
borderTop: "7px solid rgba(255,255,255,0.95)",
|
|
425
|
+
}} />
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
</div>
|
|
429
|
+
</div>
|
|
430
|
+
);
|
|
431
|
+
});
|