@hyperframes/studio 0.1.9 → 0.1.11

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 (38) hide show
  1. package/dist/assets/index-Bj0pPj_X.js +92 -0
  2. package/dist/assets/index-BnvciBdD.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +10 -4
  5. package/src/App.tsx +744 -271
  6. package/src/components/editor/FileTree.tsx +186 -32
  7. package/src/components/editor/SourceEditor.tsx +3 -1
  8. package/src/components/nle/NLELayout.tsx +125 -23
  9. package/src/components/renders/RenderQueue.tsx +123 -0
  10. package/src/components/renders/RenderQueueItem.tsx +133 -0
  11. package/src/components/renders/useRenderQueue.ts +161 -0
  12. package/src/components/sidebar/AssetsTab.tsx +360 -0
  13. package/src/components/sidebar/CompositionsTab.tsx +227 -0
  14. package/src/components/sidebar/LeftSidebar.tsx +102 -0
  15. package/src/components/ui/ExpandOnHover.tsx +194 -0
  16. package/src/hooks/useCodeEditor.ts +1 -1
  17. package/src/hooks/useElementPicker.ts +5 -1
  18. package/src/index.ts +10 -2
  19. package/src/player/components/AudioWaveform.tsx +168 -0
  20. package/src/player/components/CompositionThumbnail.tsx +140 -0
  21. package/src/player/components/EditModal.tsx +165 -0
  22. package/src/player/components/Player.tsx +6 -5
  23. package/src/player/components/PlayerControls.tsx +78 -39
  24. package/src/player/components/Timeline.test.ts +110 -0
  25. package/src/player/components/Timeline.tsx +537 -260
  26. package/src/player/components/TimelineClip.tsx +80 -0
  27. package/src/player/components/VideoThumbnail.tsx +196 -0
  28. package/src/player/hooks/useTimelinePlayer.ts +404 -112
  29. package/src/player/index.ts +3 -3
  30. package/src/player/lib/time.test.ts +57 -0
  31. package/src/player/lib/time.ts +1 -0
  32. package/src/player/store/playerStore.test.ts +265 -0
  33. package/src/player/store/playerStore.ts +44 -16
  34. package/src/utils/htmlEditor.ts +164 -0
  35. package/dist/assets/index-Df6fO-S6.js +0 -78
  36. package/dist/assets/index-KoBceNoU.css +0 -1
  37. package/src/player/components/AgentActivityTrack.tsx +0 -93
  38. package/src/player/lib/useMountEffect.ts +0 -10
@@ -0,0 +1,168 @@
1
+ import { memo, useRef, useState, useCallback, useEffect } from "react";
2
+
3
+ interface AudioWaveformProps {
4
+ audioUrl: string;
5
+ label: string;
6
+ labelColor: string;
7
+ }
8
+
9
+ const BAR_W = 2;
10
+ const GAP = 1;
11
+ const STEP = BAR_W + GAP;
12
+
13
+ /** Downsample PCM channel data into peak amplitudes (0–1). */
14
+ function extractPeaks(channelData: Float32Array, barCount: number): number[] {
15
+ const peaks: number[] = [];
16
+ const samplesPerBar = Math.floor(channelData.length / barCount);
17
+ if (samplesPerBar === 0) return Array(barCount).fill(0);
18
+ for (let i = 0; i < barCount; i++) {
19
+ let max = 0;
20
+ const start = i * samplesPerBar;
21
+ const end = Math.min(start + samplesPerBar, channelData.length);
22
+ for (let j = start; j < end; j++) {
23
+ const abs = Math.abs(channelData[j] ?? 0);
24
+ if (abs > max) max = abs;
25
+ }
26
+ peaks.push(max);
27
+ }
28
+ const maxPeak = Math.max(...peaks, 0.001);
29
+ return peaks.map((p) => p / maxPeak);
30
+ }
31
+
32
+ /** Deterministic fake waveform as fallback (matches demo app). */
33
+ function fakePeaks(url: string, count: number): number[] {
34
+ let seed = 0;
35
+ for (let i = 0; i < url.length; i++) seed = ((seed << 5) - seed + url.charCodeAt(i)) | 0;
36
+ seed = Math.abs(seed) || 42;
37
+ const rand = () => {
38
+ seed = (seed * 16807) % 2147483647;
39
+ return (seed & 0x7fffffff) / 2147483647;
40
+ };
41
+ const peaks: number[] = [];
42
+ for (let i = 0; i < count; i++) {
43
+ const t = i / count;
44
+ const envelope = 0.3 + 0.3 * Math.sin(t * Math.PI * 3.2) + 0.2 * Math.sin(t * Math.PI * 7.1);
45
+ peaks.push(Math.max(0.05, Math.min(1, envelope * (0.4 + 0.6 * rand()))));
46
+ }
47
+ return peaks;
48
+ }
49
+
50
+ // Module-level cache so decoded audio persists across re-renders and re-mounts
51
+ const peaksCache = new Map<string, number[]>();
52
+
53
+ /**
54
+ * Audio waveform rendered from real PCM data via Web Audio API.
55
+ * Falls back to a deterministic fake pattern if decoding fails.
56
+ * Bars grow from bottom to top, rendered as CSS divs for zoom resilience.
57
+ */
58
+ export const AudioWaveform = memo(function AudioWaveform({
59
+ audioUrl,
60
+ label,
61
+ labelColor,
62
+ }: AudioWaveformProps) {
63
+ const containerRef = useRef<HTMLDivElement | null>(null);
64
+ const barsRef = useRef<HTMLDivElement | null>(null);
65
+ const roRef = useRef<ResizeObserver | null>(null);
66
+ const [peaks, setPeaks] = useState<number[] | null>(peaksCache.get(audioUrl) ?? null);
67
+
68
+ // Fetch + decode audio once
69
+ useEffect(() => {
70
+ if (peaks || !audioUrl) return;
71
+
72
+ const ctrl = new AbortController();
73
+ fetch(audioUrl, { signal: ctrl.signal })
74
+ .then((r) => r.arrayBuffer())
75
+ .then((buf) => {
76
+ const ctx = new AudioContext();
77
+ return ctx.decodeAudioData(buf).finally(() => ctx.close());
78
+ })
79
+ .then((decoded) => {
80
+ if (ctrl.signal.aborted) return;
81
+ const channel = decoded.getChannelData(0);
82
+ // Extract enough peaks for wide clips (up to 4000 bars)
83
+ const p = extractPeaks(channel, 4000);
84
+ peaksCache.set(audioUrl, p);
85
+ setPeaks(p);
86
+ })
87
+ .catch(() => {
88
+ if (ctrl.signal.aborted) return;
89
+ // Fallback to fake waveform
90
+ const p = fakePeaks(audioUrl, 4000);
91
+ peaksCache.set(audioUrl, p);
92
+ setPeaks(p);
93
+ });
94
+
95
+ return () => ctrl.abort();
96
+ }, [audioUrl, peaks]);
97
+
98
+ // Draw bars into the container using innerHTML (fast, zoom-resilient)
99
+ const draw = useCallback(() => {
100
+ const container = containerRef.current;
101
+ const barsEl = barsRef.current;
102
+ if (!container || !barsEl || !peaks) return;
103
+
104
+ const w = container.clientWidth || 400;
105
+ const barCount = Math.min(Math.floor(w / STEP), peaks.length);
106
+
107
+ let html = "";
108
+ for (let i = 0; i < barCount; i++) {
109
+ // Map bar index to peak index (resample)
110
+ const peakIdx = Math.floor((i / barCount) * peaks.length);
111
+ const amp = peaks[peakIdx] ?? 0;
112
+ const pct = Math.max(3, Math.round(amp * 100));
113
+ const opacity = (0.45 + amp * 0.4).toFixed(2);
114
+ html += `<div style="position:absolute;bottom:0;left:${i * STEP}px;width:${BAR_W}px;height:${pct}%;background:rgba(75,163,210,${opacity})"></div>`;
115
+ }
116
+ barsEl.innerHTML = html;
117
+ }, [peaks]);
118
+
119
+ // Observe container size and redraw
120
+ const setContainerRef = useCallback(
121
+ (el: HTMLDivElement | null) => {
122
+ roRef.current?.disconnect();
123
+ containerRef.current = el;
124
+ if (!el) return;
125
+ draw();
126
+ roRef.current = new ResizeObserver(() => draw());
127
+ roRef.current.observe(el);
128
+ },
129
+ [draw],
130
+ );
131
+
132
+ // Redraw when peaks arrive
133
+ useEffect(() => {
134
+ draw();
135
+ }, [draw]);
136
+
137
+ useEffect(
138
+ () => () => {
139
+ roRef.current?.disconnect();
140
+ },
141
+ [],
142
+ );
143
+
144
+ return (
145
+ <div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
146
+ <div ref={barsRef} className="absolute left-0 right-0 bottom-0" style={{ top: 16 }} />
147
+ {/* Shimmer while decoding */}
148
+ {!peaks && (
149
+ <div
150
+ className="absolute left-0 right-0 bottom-0 animate-pulse"
151
+ style={{
152
+ top: 16,
153
+ background:
154
+ "linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
155
+ }}
156
+ />
157
+ )}
158
+ <div className="absolute top-0 left-0 right-0 px-1.5 py-0.5 z-10">
159
+ <span
160
+ className="text-[9px] font-semibold truncate block leading-tight"
161
+ style={{ color: labelColor, textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
162
+ >
163
+ {label}
164
+ </span>
165
+ </div>
166
+ </div>
167
+ );
168
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * CompositionThumbnail — Film-strip of server-rendered JPEG thumbnails.
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).
9
+ */
10
+
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;
16
+
17
+ interface CompositionThumbnailProps {
18
+ previewUrl: string;
19
+ label: string;
20
+ labelColor: string;
21
+ seekTime?: number;
22
+ duration?: number;
23
+ width?: number;
24
+ height?: number;
25
+ }
26
+
27
+ export const CompositionThumbnail = memo(function CompositionThumbnail({
28
+ previewUrl,
29
+ label,
30
+ labelColor,
31
+ seekTime = 0.4,
32
+ duration = 5,
33
+ width = 1920,
34
+ height = 1080,
35
+ }: 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
67
+ const thumbnailBase = previewUrl
68
+ .replace("/preview/comp/", "/thumbnail/")
69
+ .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
+ }
88
+
89
+ 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-cover"
116
+ style={{ opacity: 0, transition: "opacity 200ms ease-out" }}
117
+ />
118
+ </div>
119
+ );
120
+ })}
121
+ </div>
122
+
123
+ {/* Label */}
124
+ <div
125
+ className="absolute bottom-0 left-0 right-0 z-10 px-1.5 pb-0.5 pt-3"
126
+ style={{
127
+ background:
128
+ "linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
129
+ }}
130
+ >
131
+ <span
132
+ className="text-[9px] font-semibold truncate block leading-tight"
133
+ style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
134
+ >
135
+ {label}
136
+ </span>
137
+ </div>
138
+ </div>
139
+ );
140
+ });
@@ -0,0 +1,165 @@
1
+ import { useState, useCallback, useMemo, useRef } from "react";
2
+ import { useMountEffect } from "../../hooks/useMountEffect";
3
+ import { usePlayerStore } from "../store/playerStore";
4
+ import { formatTime } from "../lib/time";
5
+
6
+ interface EditPopoverProps {
7
+ rangeStart: number;
8
+ rangeEnd: number;
9
+ anchorX: number;
10
+ anchorY: number;
11
+ onClose: () => void;
12
+ }
13
+
14
+ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }: EditPopoverProps) {
15
+ const elements = usePlayerStore((s) => s.elements);
16
+ const [prompt, setPrompt] = useState("");
17
+ const [copied, setCopied] = useState(false);
18
+ const popoverRef = useRef<HTMLDivElement>(null);
19
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
20
+
21
+ const start = Math.min(rangeStart, rangeEnd);
22
+ const end = Math.max(rangeStart, rangeEnd);
23
+
24
+ const elementsInRange = useMemo(() => {
25
+ return elements.filter((el) => {
26
+ const elEnd = el.start + el.duration;
27
+ return el.start < end && elEnd > start;
28
+ });
29
+ }, [elements, start, end]);
30
+
31
+ useMountEffect(() => {
32
+ setTimeout(() => textareaRef.current?.focus(), 50);
33
+ });
34
+
35
+ useMountEffect(() => {
36
+ const handleKey = (e: KeyboardEvent) => {
37
+ if (e.key === "Escape") onClose();
38
+ };
39
+ window.addEventListener("keydown", handleKey);
40
+ return () => window.removeEventListener("keydown", handleKey);
41
+ });
42
+
43
+ useMountEffect(() => {
44
+ const handleClick = (e: MouseEvent) => {
45
+ if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
46
+ onClose();
47
+ }
48
+ };
49
+ setTimeout(() => window.addEventListener("mousedown", handleClick), 100);
50
+ return () => window.removeEventListener("mousedown", handleClick);
51
+ });
52
+
53
+ const buildClipboardText = useCallback(() => {
54
+ const elementLines = elementsInRange
55
+ .map(
56
+ (el) =>
57
+ `- #${el.id} (${el.tag}) — ${formatTime(el.start)} to ${formatTime(el.start + el.duration)}, track ${el.track}`,
58
+ )
59
+ .join("\n");
60
+
61
+ return `Edit the following HyperFrames composition:
62
+
63
+ Time range: ${formatTime(start)} — ${formatTime(end)}
64
+
65
+ Elements in range:
66
+ ${elementLines || "(none)"}
67
+
68
+ User request:
69
+ ${prompt.trim() || "(no prompt provided)"}
70
+
71
+ Instructions:
72
+ Modify only the elements listed above within the specified time range.
73
+ The composition uses HyperFrames data attributes (data-start, data-duration, data-track-index) and GSAP for animations.
74
+ Preserve all other elements and timing outside this range.`;
75
+ }, [start, end, elementsInRange, prompt]);
76
+
77
+ const handleCopy = useCallback(async () => {
78
+ try {
79
+ await navigator.clipboard.writeText(buildClipboardText());
80
+ } catch {
81
+ const ta = document.createElement("textarea");
82
+ ta.value = buildClipboardText();
83
+ document.body.appendChild(ta);
84
+ ta.select();
85
+ document.execCommand("copy");
86
+ document.body.removeChild(ta);
87
+ }
88
+ setCopied(true);
89
+ setTimeout(() => {
90
+ setCopied(false);
91
+ onClose();
92
+ }, 800);
93
+ }, [buildClipboardText, onClose]);
94
+
95
+ const style: React.CSSProperties = {
96
+ position: "fixed",
97
+ left: Math.max(8, Math.min(anchorX - 160, window.innerWidth - 336)),
98
+ top: Math.max(8, anchorY - 280),
99
+ zIndex: 200,
100
+ };
101
+
102
+ return (
103
+ <div ref={popoverRef} style={style}>
104
+ <div className="w-80 bg-neutral-900 border border-neutral-700/60 rounded-xl shadow-2xl shadow-black/40 overflow-hidden">
105
+ {/* Header */}
106
+ <div className="flex items-center justify-between px-4 py-2.5 border-b border-neutral-800/60">
107
+ <div className="flex items-center gap-2">
108
+ <div className="w-1.5 h-1.5 rounded-full bg-blue-400" />
109
+ <span className="text-[11px] font-medium text-neutral-300">
110
+ {formatTime(start)} — {formatTime(end)}
111
+ </span>
112
+ </div>
113
+ <span className="text-[10px] text-neutral-600">
114
+ {elementsInRange.length} element{elementsInRange.length !== 1 ? "s" : ""}
115
+ </span>
116
+ </div>
117
+
118
+ {/* Elements */}
119
+ {elementsInRange.length > 0 && (
120
+ <div className="px-4 py-2 border-b border-neutral-800/40 max-h-24 overflow-y-auto">
121
+ {elementsInRange.map((el) => (
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>
124
+ <span className="text-[10px] text-neutral-600">{el.tag}</span>
125
+ </div>
126
+ ))}
127
+ </div>
128
+ )}
129
+
130
+ {/* Prompt */}
131
+ <div className="p-3">
132
+ <textarea
133
+ ref={textareaRef}
134
+ value={prompt}
135
+ onChange={(e) => setPrompt(e.target.value)}
136
+ onKeyDown={(e) => {
137
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
138
+ e.preventDefault();
139
+ handleCopy();
140
+ }
141
+ }}
142
+ placeholder="What should change?"
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"
145
+ />
146
+ </div>
147
+
148
+ {/* Action */}
149
+ <div className="px-3 pb-3">
150
+ <button
151
+ onClick={handleCopy}
152
+ className={`w-full py-1.5 text-[11px] font-medium rounded-lg transition-all ${
153
+ copied
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"
156
+ }`}
157
+ >
158
+ {copied ? "Copied!" : "Copy to Agent"}
159
+ {!copied && <span className="text-[9px] text-blue-400/50 ml-1.5">Cmd+Enter</span>}
160
+ </button>
161
+ </div>
162
+ </div>
163
+ </div>
164
+ );
165
+ }
@@ -1,5 +1,5 @@
1
1
  import { forwardRef, useRef, useState, useCallback } from "react";
2
- import { useMountEffect } from "../lib/useMountEffect";
2
+ import { useMountEffect } from "../../hooks/useMountEffect";
3
3
 
4
4
  const NATIVE_W = 1920;
5
5
  const NATIVE_H = 1080;
@@ -39,7 +39,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
39
39
  const handleMessage = (e: MessageEvent) => {
40
40
  const data = e.data;
41
41
  if (
42
- (data?.source === "hf-preview" || data?.source === "hf-preview") &&
42
+ data?.source === "hf-preview" &&
43
43
  data?.type === "stage-size" &&
44
44
  data.width > 0 &&
45
45
  data.height > 0
@@ -83,8 +83,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
83
83
  }
84
84
  }
85
85
  }
86
- } catch {
87
- // Cross-origin
86
+ } catch (err) {
87
+ console.warn("[Player] Could not read iframe dimensions (cross-origin)", err);
88
88
  }
89
89
 
90
90
  if (loadCountRef.current > 1) {
@@ -103,7 +103,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
103
103
  return (
104
104
  <div
105
105
  ref={containerRef}
106
- className="w-full h-full max-w-full max-h-full overflow-hidden shadow-float border border-neutral-800 bg-black flex items-center justify-center rounded-card-inner"
106
+ className="w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center"
107
107
  >
108
108
  <iframe
109
109
  ref={ref}
@@ -117,6 +117,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
117
117
  width: dims.w,
118
118
  height: dims.h,
119
119
  border: "none",
120
+ outline: "1px solid black",
120
121
  transform: `scale(${scale})`,
121
122
  transformOrigin: "center center",
122
123
  flexShrink: 0,