@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.
- package/dist/assets/index-Bj0pPj_X.js +92 -0
- package/dist/assets/index-BnvciBdD.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +10 -4
- package/src/App.tsx +744 -271
- package/src/components/editor/FileTree.tsx +186 -32
- package/src/components/editor/SourceEditor.tsx +3 -1
- package/src/components/nle/NLELayout.tsx +125 -23
- package/src/components/renders/RenderQueue.tsx +123 -0
- package/src/components/renders/RenderQueueItem.tsx +133 -0
- package/src/components/renders/useRenderQueue.ts +161 -0
- package/src/components/sidebar/AssetsTab.tsx +360 -0
- package/src/components/sidebar/CompositionsTab.tsx +227 -0
- package/src/components/sidebar/LeftSidebar.tsx +102 -0
- package/src/components/ui/ExpandOnHover.tsx +194 -0
- package/src/hooks/useCodeEditor.ts +1 -1
- package/src/hooks/useElementPicker.ts +5 -1
- package/src/index.ts +10 -2
- package/src/player/components/AudioWaveform.tsx +168 -0
- package/src/player/components/CompositionThumbnail.tsx +140 -0
- package/src/player/components/EditModal.tsx +165 -0
- package/src/player/components/Player.tsx +6 -5
- package/src/player/components/PlayerControls.tsx +78 -39
- package/src/player/components/Timeline.test.ts +110 -0
- package/src/player/components/Timeline.tsx +537 -260
- package/src/player/components/TimelineClip.tsx +80 -0
- package/src/player/components/VideoThumbnail.tsx +196 -0
- package/src/player/hooks/useTimelinePlayer.ts +404 -112
- package/src/player/index.ts +3 -3
- package/src/player/lib/time.test.ts +57 -0
- package/src/player/lib/time.ts +1 -0
- package/src/player/store/playerStore.test.ts +265 -0
- package/src/player/store/playerStore.ts +44 -16
- package/src/utils/htmlEditor.ts +164 -0
- package/dist/assets/index-Df6fO-S6.js +0 -78
- package/dist/assets/index-KoBceNoU.css +0 -1
- package/src/player/components/AgentActivityTrack.tsx +0 -93
- 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 "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|