@hyperframes/studio 0.5.0-alpha.1 → 0.5.0-alpha.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-Bl4Deziq.js +105 -0
- package/dist/assets/index-KioPDrX6.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +494 -185
- package/src/captions/components/CaptionOverlay.tsx +2 -1
- package/src/captions/keyboard.test.ts +38 -0
- package/src/captions/keyboard.ts +8 -0
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/DomEditOverlay.tsx +41 -6
- package/src/components/editor/PropertyPanel.tsx +7 -3
- package/src/components/editor/domEditing.test.ts +110 -0
- package/src/components/editor/domEditing.ts +33 -4
- package/src/components/nle/NLELayout.tsx +43 -8
- package/src/components/nle/NLEPreview.tsx +5 -1
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- package/src/components/sidebar/LeftSidebar.tsx +64 -36
- package/src/hooks/usePersistentEditHistory.test.ts +255 -0
- package/src/hooks/usePersistentEditHistory.ts +336 -0
- package/src/icons/SystemIcons.tsx +4 -0
- package/src/player/components/AudioWaveform.tsx +44 -29
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +42 -10
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/PlayerControls.tsx +117 -49
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/Timeline.tsx +198 -27
- package/src/player/components/timelineEditing.test.ts +2 -2
- package/src/player/components/timelineEditing.ts +1 -1
- package/src/player/components/timelineTheme.ts +3 -3
- package/src/player/components/timelineZoom.test.ts +21 -0
- package/src/player/components/timelineZoom.ts +11 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +138 -0
- package/src/player/hooks/useTimelinePlayer.ts +354 -43
- package/src/player/lib/time.test.ts +29 -1
- package/src/player/lib/time.ts +26 -0
- package/src/player/store/playerStore.test.ts +11 -1
- package/src/player/store/playerStore.ts +5 -1
- package/src/styles/studio.css +9 -0
- package/src/utils/clipboard.test.ts +88 -0
- package/src/utils/clipboard.ts +57 -0
- package/src/utils/editHistory.test.ts +244 -0
- package/src/utils/editHistory.ts +218 -0
- package/src/utils/editHistoryStorage.test.ts +37 -0
- package/src/utils/editHistoryStorage.ts +99 -0
- package/src/utils/frameCapture.test.ts +26 -0
- package/src/utils/frameCapture.ts +38 -0
- package/src/utils/studioFileHistory.test.ts +156 -0
- package/src/utils/studioFileHistory.ts +61 -0
- package/src/utils/timelineAssetDrop.test.ts +64 -4
- package/src/utils/timelineAssetDrop.ts +27 -5
- package/dist/assets/index-Bi30tos-.js +0 -105
- package/dist/assets/index-Dm9VsShj.css +0 -1
|
@@ -53,6 +53,8 @@ import {
|
|
|
53
53
|
CaretRight,
|
|
54
54
|
ClipboardText,
|
|
55
55
|
ArrowCounterClockwise,
|
|
56
|
+
Camera as PhCamera,
|
|
57
|
+
ArrowClockwise,
|
|
56
58
|
Gear,
|
|
57
59
|
} from "@phosphor-icons/react";
|
|
58
60
|
import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
|
|
@@ -127,4 +129,6 @@ export const ChevronDown = makeIcon(CaretDown);
|
|
|
127
129
|
export const ChevronRight = makeIcon(CaretRight);
|
|
128
130
|
export const ClipboardList = makeIcon(ClipboardText);
|
|
129
131
|
export const RotateCcw = makeIcon(ArrowCounterClockwise);
|
|
132
|
+
export const Camera = makeIcon(PhCamera);
|
|
133
|
+
export const RotateCw = makeIcon(ArrowClockwise);
|
|
130
134
|
export const Settings = makeIcon(Gear);
|
|
@@ -2,6 +2,7 @@ import { memo, useRef, useState, useCallback, useEffect } from "react";
|
|
|
2
2
|
|
|
3
3
|
interface AudioWaveformProps {
|
|
4
4
|
audioUrl: string;
|
|
5
|
+
waveformUrl?: string;
|
|
5
6
|
label: string;
|
|
6
7
|
labelColor: string;
|
|
7
8
|
}
|
|
@@ -49,6 +50,7 @@ function fakePeaks(url: string, count: number): number[] {
|
|
|
49
50
|
|
|
50
51
|
// Module-level cache so decoded audio persists across re-renders and re-mounts
|
|
51
52
|
const peaksCache = new Map<string, number[]>();
|
|
53
|
+
const decodeInFlight = new Map<string, Promise<number[]>>();
|
|
52
54
|
|
|
53
55
|
/**
|
|
54
56
|
* Audio waveform rendered from real PCM data via Web Audio API.
|
|
@@ -57,43 +59,56 @@ const peaksCache = new Map<string, number[]>();
|
|
|
57
59
|
*/
|
|
58
60
|
export const AudioWaveform = memo(function AudioWaveform({
|
|
59
61
|
audioUrl,
|
|
62
|
+
waveformUrl,
|
|
60
63
|
label,
|
|
61
64
|
labelColor,
|
|
62
65
|
}: AudioWaveformProps) {
|
|
63
66
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
64
67
|
const barsRef = useRef<HTMLDivElement | null>(null);
|
|
65
68
|
const roRef = useRef<ResizeObserver | null>(null);
|
|
66
|
-
const
|
|
69
|
+
const cacheKey = waveformUrl ?? audioUrl;
|
|
70
|
+
const [peaks, setPeaks] = useState<number[] | null>(peaksCache.get(cacheKey) ?? null);
|
|
67
71
|
|
|
68
|
-
// Fetch + decode audio once
|
|
69
72
|
useEffect(() => {
|
|
70
|
-
if (peaks || !
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
73
|
+
if (peaks || !cacheKey) return;
|
|
74
|
+
|
|
75
|
+
let cancelled = false;
|
|
76
|
+
|
|
77
|
+
let promise = decodeInFlight.get(cacheKey);
|
|
78
|
+
if (!promise) {
|
|
79
|
+
promise = (
|
|
80
|
+
waveformUrl
|
|
81
|
+
? fetch(waveformUrl)
|
|
82
|
+
.then((r) => r.json())
|
|
83
|
+
.then((d: { peaks?: number[] }) => {
|
|
84
|
+
if (!Array.isArray(d.peaks)) throw new Error("bad response");
|
|
85
|
+
return d.peaks;
|
|
86
|
+
})
|
|
87
|
+
: fetch(audioUrl)
|
|
88
|
+
.then((r) => r.arrayBuffer())
|
|
89
|
+
.then((buf) => {
|
|
90
|
+
const ctx = new AudioContext();
|
|
91
|
+
return ctx.decodeAudioData(buf).finally(() => ctx.close());
|
|
92
|
+
})
|
|
93
|
+
.then((decoded) => extractPeaks(decoded.getChannelData(0), 4000))
|
|
94
|
+
)
|
|
95
|
+
.catch(() => fakePeaks(cacheKey, 4000))
|
|
96
|
+
.then((p) => {
|
|
97
|
+
peaksCache.set(cacheKey, p);
|
|
98
|
+
return p;
|
|
99
|
+
})
|
|
100
|
+
.finally(() => decodeInFlight.delete(cacheKey));
|
|
101
|
+
|
|
102
|
+
decodeInFlight.set(cacheKey, promise);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
promise.then((p) => {
|
|
106
|
+
if (!cancelled) setPeaks(p);
|
|
107
|
+
});
|
|
108
|
+
return () => {
|
|
109
|
+
cancelled = true;
|
|
110
|
+
};
|
|
111
|
+
}, [audioUrl, waveformUrl, cacheKey, peaks]);
|
|
97
112
|
|
|
98
113
|
// Draw bars into the container using innerHTML (fast, zoom-resilient)
|
|
99
114
|
const draw = useCallback(() => {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildCompositionThumbnailUrl } from "./CompositionThumbnail";
|
|
3
|
+
|
|
4
|
+
describe("buildCompositionThumbnailUrl", () => {
|
|
5
|
+
it("includes selector and occurrence index for precise element thumbnails", () => {
|
|
6
|
+
expect(
|
|
7
|
+
buildCompositionThumbnailUrl({
|
|
8
|
+
previewUrl: "/api/projects/demo/preview",
|
|
9
|
+
seekTime: 1,
|
|
10
|
+
duration: 2,
|
|
11
|
+
selector: ".card",
|
|
12
|
+
selectorIndex: 2,
|
|
13
|
+
origin: "http://localhost:3000",
|
|
14
|
+
}),
|
|
15
|
+
).toBe(
|
|
16
|
+
"http://localhost:3000/api/projects/demo/thumbnail/index.html?t=2.00&v=v2&selector=.card&selectorIndex=2",
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -7,6 +7,7 @@ interface CompositionThumbnailProps {
|
|
|
7
7
|
labelColor: string;
|
|
8
8
|
accentColor?: string;
|
|
9
9
|
selector?: string;
|
|
10
|
+
selectorIndex?: number;
|
|
10
11
|
seekTime?: number;
|
|
11
12
|
duration?: number;
|
|
12
13
|
width?: number;
|
|
@@ -16,12 +17,44 @@ interface CompositionThumbnailProps {
|
|
|
16
17
|
const CLIP_HEIGHT = 66;
|
|
17
18
|
const THUMBNAIL_URL_VERSION = "v2";
|
|
18
19
|
|
|
20
|
+
export function buildCompositionThumbnailUrl({
|
|
21
|
+
previewUrl,
|
|
22
|
+
seekTime = 2,
|
|
23
|
+
duration = 5,
|
|
24
|
+
selector,
|
|
25
|
+
selectorIndex,
|
|
26
|
+
origin,
|
|
27
|
+
}: {
|
|
28
|
+
previewUrl: string;
|
|
29
|
+
seekTime?: number;
|
|
30
|
+
duration?: number;
|
|
31
|
+
selector?: string;
|
|
32
|
+
selectorIndex?: number;
|
|
33
|
+
origin: string;
|
|
34
|
+
}): string {
|
|
35
|
+
const thumbnailBase = previewUrl
|
|
36
|
+
.replace("/preview/comp/", "/thumbnail/")
|
|
37
|
+
.replace(/\/preview$/, "/thumbnail/index.html");
|
|
38
|
+
const midTime = seekTime + duration / 2;
|
|
39
|
+
const thumbnailUrl = new URL(thumbnailBase, origin);
|
|
40
|
+
thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
|
|
41
|
+
thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
|
|
42
|
+
if (selector) {
|
|
43
|
+
thumbnailUrl.searchParams.set("selector", selector);
|
|
44
|
+
if (selectorIndex != null && selectorIndex > 0) {
|
|
45
|
+
thumbnailUrl.searchParams.set("selectorIndex", String(selectorIndex));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return thumbnailUrl.toString();
|
|
49
|
+
}
|
|
50
|
+
|
|
19
51
|
export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
20
52
|
previewUrl,
|
|
21
53
|
label,
|
|
22
54
|
labelColor,
|
|
23
55
|
accentColor = "#6B7280",
|
|
24
56
|
selector,
|
|
57
|
+
selectorIndex,
|
|
25
58
|
seekTime = 2,
|
|
26
59
|
duration = 5,
|
|
27
60
|
}: CompositionThumbnailProps) {
|
|
@@ -48,15 +81,14 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
48
81
|
roRef.current?.disconnect();
|
|
49
82
|
});
|
|
50
83
|
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const url = thumbnailUrl.toString();
|
|
84
|
+
const url = buildCompositionThumbnailUrl({
|
|
85
|
+
previewUrl,
|
|
86
|
+
seekTime,
|
|
87
|
+
duration,
|
|
88
|
+
selector,
|
|
89
|
+
selectorIndex,
|
|
90
|
+
origin: window.location.origin,
|
|
91
|
+
});
|
|
60
92
|
const frameW = Math.max(48, Math.round(CLIP_HEIGHT * aspect));
|
|
61
93
|
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
|
|
62
94
|
|
|
@@ -66,7 +98,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
66
98
|
src={url}
|
|
67
99
|
alt=""
|
|
68
100
|
draggable={false}
|
|
69
|
-
loading="
|
|
101
|
+
loading="eager"
|
|
70
102
|
onLoad={(e) => {
|
|
71
103
|
const img = e.currentTarget;
|
|
72
104
|
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
|
@@ -3,6 +3,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
|
|
|
3
3
|
import { usePlayerStore } from "../store/playerStore";
|
|
4
4
|
import { formatTime } from "../lib/time";
|
|
5
5
|
import { buildPromptCopyText, buildTimelineAgentPrompt } from "./timelineEditing";
|
|
6
|
+
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
6
7
|
|
|
7
8
|
interface EditPopoverProps {
|
|
8
9
|
rangeStart: number;
|
|
@@ -62,16 +63,8 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
|
|
62
63
|
}, [start, end, elementsInRange, prompt]);
|
|
63
64
|
|
|
64
65
|
const handleCopy = useCallback(async () => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
} catch {
|
|
68
|
-
const ta = document.createElement("textarea");
|
|
69
|
-
ta.value = buildClipboardText();
|
|
70
|
-
document.body.appendChild(ta);
|
|
71
|
-
ta.select();
|
|
72
|
-
document.execCommand("copy");
|
|
73
|
-
document.body.removeChild(ta);
|
|
74
|
-
}
|
|
66
|
+
const copied = await copyTextToClipboard(buildClipboardText());
|
|
67
|
+
if (!copied) return;
|
|
75
68
|
setCopiedAgentPrompt(true);
|
|
76
69
|
setTimeout(() => {
|
|
77
70
|
setCopiedAgentPrompt(false);
|
|
@@ -82,16 +75,8 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
|
|
82
75
|
const handleCopyPrompt = useCallback(async () => {
|
|
83
76
|
const promptText = buildPromptCopyText(prompt);
|
|
84
77
|
if (!promptText) return;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
} catch {
|
|
88
|
-
const ta = document.createElement("textarea");
|
|
89
|
-
ta.value = promptText;
|
|
90
|
-
document.body.appendChild(ta);
|
|
91
|
-
ta.select();
|
|
92
|
-
document.execCommand("copy");
|
|
93
|
-
document.body.removeChild(ta);
|
|
94
|
-
}
|
|
78
|
+
const copied = await copyTextToClipboard(promptText);
|
|
79
|
+
if (!copied) return;
|
|
95
80
|
setCopiedPromptOnly(true);
|
|
96
81
|
setTimeout(() => {
|
|
97
82
|
setCopiedPromptOnly(false);
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { useRef, useState, useCallback, useEffect, memo } from "react";
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
|
-
import {
|
|
4
|
-
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
|
5
|
-
getTimelineToggleTitle,
|
|
6
|
-
} from "../../utils/timelineDiscovery";
|
|
7
|
-
import { formatTime } from "../lib/time";
|
|
3
|
+
import { formatFrameTime, frameToSeconds, stepFrameTime, formatTime } from "../lib/time";
|
|
8
4
|
import { usePlayerStore, liveTime } from "../store/playerStore";
|
|
9
5
|
|
|
10
6
|
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
|
|
11
7
|
const SEEK_EDGE_SNAP_PX = 8;
|
|
8
|
+
type TimeDisplayMode = "time" | "frame";
|
|
9
|
+
const SHORTCUT_HINTS = [
|
|
10
|
+
{ key: "J", label: "Play backward" },
|
|
11
|
+
{ key: "K", label: "Stop playback" },
|
|
12
|
+
{ key: "L", label: "Play forward" },
|
|
13
|
+
{ key: "←/→", label: "Step one frame backward or forward" },
|
|
14
|
+
] as const;
|
|
12
15
|
|
|
13
16
|
export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number {
|
|
14
17
|
if (!Number.isFinite(rectWidth) || rectWidth <= 0) return 0;
|
|
@@ -23,23 +26,23 @@ export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth:
|
|
|
23
26
|
interface PlayerControlsProps {
|
|
24
27
|
onTogglePlay: () => void;
|
|
25
28
|
onSeek: (time: number) => void;
|
|
26
|
-
timelineVisible?: boolean;
|
|
27
|
-
onToggleTimeline?: () => void;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
export const PlayerControls = memo(function PlayerControls({
|
|
31
32
|
onTogglePlay,
|
|
32
33
|
onSeek,
|
|
33
|
-
timelineVisible,
|
|
34
|
-
onToggleTimeline,
|
|
35
34
|
}: PlayerControlsProps) {
|
|
36
35
|
// Subscribe to only the fields we render — each selector prevents cascading re-renders
|
|
37
36
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
|
38
37
|
const duration = usePlayerStore((s) => s.duration);
|
|
39
38
|
const timelineReady = usePlayerStore((s) => s.timelineReady);
|
|
40
39
|
const playbackRate = usePlayerStore((s) => s.playbackRate);
|
|
40
|
+
const loopEnabled = usePlayerStore((s) => s.loopEnabled);
|
|
41
41
|
const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
|
|
42
|
+
const setLoopEnabled = usePlayerStore.getState().setLoopEnabled;
|
|
42
43
|
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
|
|
44
|
+
const [timeDisplayMode, setTimeDisplayMode] = useState<TimeDisplayMode>("time");
|
|
45
|
+
const [jumpFrame, setJumpFrame] = useState("");
|
|
43
46
|
|
|
44
47
|
const progressFillRef = useRef<HTMLDivElement>(null);
|
|
45
48
|
const progressThumbRef = useRef<HTMLDivElement>(null);
|
|
@@ -49,6 +52,8 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
49
52
|
const speedMenuContainerRef = useRef<HTMLDivElement>(null);
|
|
50
53
|
const isDraggingRef = useRef(false);
|
|
51
54
|
const currentTimeRef = useRef(0);
|
|
55
|
+
const timeDisplayModeRef = useRef(timeDisplayMode);
|
|
56
|
+
timeDisplayModeRef.current = timeDisplayMode;
|
|
52
57
|
|
|
53
58
|
const durationRef = useRef(duration);
|
|
54
59
|
durationRef.current = duration;
|
|
@@ -59,7 +64,10 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
59
64
|
const pct = dur > 0 ? Math.min(100, (t / dur) * 100) : 0;
|
|
60
65
|
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
|
|
61
66
|
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
|
|
62
|
-
if (timeDisplayRef.current)
|
|
67
|
+
if (timeDisplayRef.current) {
|
|
68
|
+
timeDisplayRef.current.textContent =
|
|
69
|
+
timeDisplayModeRef.current === "frame" ? formatFrameTime(t, dur) : formatTime(t);
|
|
70
|
+
}
|
|
63
71
|
if (sliderRef.current) sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t)));
|
|
64
72
|
};
|
|
65
73
|
const unsub = liveTime.subscribe(updateProgress);
|
|
@@ -82,6 +90,13 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
82
90
|
};
|
|
83
91
|
});
|
|
84
92
|
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!timeDisplayRef.current) return;
|
|
95
|
+
const t = currentTimeRef.current;
|
|
96
|
+
timeDisplayRef.current.textContent =
|
|
97
|
+
timeDisplayMode === "frame" ? formatFrameTime(t, duration) : formatTime(t);
|
|
98
|
+
}, [duration, timeDisplayMode]);
|
|
99
|
+
|
|
85
100
|
useEffect(() => {
|
|
86
101
|
if (!showSpeedMenu) return;
|
|
87
102
|
const handleMouseDown = (e: MouseEvent) => {
|
|
@@ -190,21 +205,44 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
190
205
|
const handleKeyDown = useCallback(
|
|
191
206
|
(e: React.KeyboardEvent) => {
|
|
192
207
|
if (!timelineReady || duration <= 0) return;
|
|
193
|
-
const step = e.shiftKey ?
|
|
208
|
+
const step = e.shiftKey ? 10 : 1;
|
|
194
209
|
if (e.key === "ArrowLeft") {
|
|
195
210
|
e.preventDefault();
|
|
196
|
-
onSeek(
|
|
211
|
+
onSeek(stepFrameTime(currentTimeRef.current, -step));
|
|
197
212
|
} else if (e.key === "ArrowRight") {
|
|
198
213
|
e.preventDefault();
|
|
199
|
-
onSeek(Math.min(duration, currentTimeRef.current
|
|
214
|
+
onSeek(Math.min(duration, stepFrameTime(currentTimeRef.current, step)));
|
|
200
215
|
}
|
|
201
216
|
},
|
|
202
217
|
[timelineReady, duration, onSeek],
|
|
203
218
|
);
|
|
204
219
|
|
|
220
|
+
const commitJumpFrame = useCallback(() => {
|
|
221
|
+
const frame = Number.parseInt(jumpFrame, 10);
|
|
222
|
+
if (!Number.isFinite(frame) || duration <= 0) return;
|
|
223
|
+
onSeek(Math.min(duration, frameToSeconds(Math.max(0, frame))));
|
|
224
|
+
}, [duration, jumpFrame, onSeek]);
|
|
225
|
+
|
|
226
|
+
const handleJumpSubmit = useCallback(
|
|
227
|
+
(e: React.FormEvent) => {
|
|
228
|
+
e.preventDefault();
|
|
229
|
+
commitJumpFrame();
|
|
230
|
+
},
|
|
231
|
+
[commitJumpFrame],
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const handleJumpKeyDown = useCallback(
|
|
235
|
+
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
236
|
+
if (e.key !== "Enter") return;
|
|
237
|
+
e.preventDefault();
|
|
238
|
+
commitJumpFrame();
|
|
239
|
+
},
|
|
240
|
+
[commitJumpFrame],
|
|
241
|
+
);
|
|
242
|
+
|
|
205
243
|
return (
|
|
206
244
|
<div
|
|
207
|
-
className="px-4 py-2 flex items-center gap-
|
|
245
|
+
className="px-4 py-2 flex flex-wrap items-center gap-x-2 gap-y-1"
|
|
208
246
|
style={{
|
|
209
247
|
borderTop: "1px solid rgba(255,255,255,0.04)",
|
|
210
248
|
// Add iOS safe-area inset so Safari's bottom URL bar doesn't occlude
|
|
@@ -236,12 +274,16 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
236
274
|
|
|
237
275
|
{/* Time display */}
|
|
238
276
|
<span
|
|
239
|
-
className="font-mono text-[11px] tabular-nums flex-shrink-0
|
|
277
|
+
className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px]"
|
|
240
278
|
style={{ color: "#A1A1AA" }}
|
|
241
279
|
>
|
|
242
280
|
<span ref={timeDisplayRef}>{formatTime(0)}</span>
|
|
243
|
-
|
|
244
|
-
|
|
281
|
+
{timeDisplayMode === "time" ? (
|
|
282
|
+
<>
|
|
283
|
+
<span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span>
|
|
284
|
+
<span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
|
|
285
|
+
</>
|
|
286
|
+
) : null}
|
|
245
287
|
</span>
|
|
246
288
|
|
|
247
289
|
{/* Seek bar — teal progress fill */}
|
|
@@ -256,7 +298,7 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
256
298
|
aria-valuemin={0}
|
|
257
299
|
aria-valuemax={Math.round(duration)}
|
|
258
300
|
aria-valuenow={0}
|
|
259
|
-
className="flex-1 h-6 flex items-center cursor-pointer group"
|
|
301
|
+
className="min-w-[96px] flex-1 h-6 flex items-center cursor-pointer group"
|
|
260
302
|
// `touch-action: none` tells the browser we're handling every
|
|
261
303
|
// pointer gesture on this element ourselves. Without it, iOS
|
|
262
304
|
// Safari consumes horizontal swipes for its own swipe-back-to-
|
|
@@ -292,7 +334,7 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
292
334
|
<button
|
|
293
335
|
type="button"
|
|
294
336
|
onClick={() => setShowSpeedMenu((v) => !v)}
|
|
295
|
-
className="px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
|
|
337
|
+
className="w-10 px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
|
|
296
338
|
style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }}
|
|
297
339
|
>
|
|
298
340
|
{playbackRate === 1 ? "1x" : `${playbackRate}x`}
|
|
@@ -329,38 +371,64 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
329
371
|
)}
|
|
330
372
|
</div>
|
|
331
373
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
374
|
+
<button
|
|
375
|
+
type="button"
|
|
376
|
+
onClick={() => setLoopEnabled(!loopEnabled)}
|
|
377
|
+
className={`h-7 w-14 rounded-md border px-2 text-[10px] font-medium transition-colors ${
|
|
378
|
+
loopEnabled
|
|
379
|
+
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
380
|
+
: "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
|
|
381
|
+
}`}
|
|
382
|
+
title="Loop playback"
|
|
383
|
+
aria-label={loopEnabled ? "Disable loop playback" : "Enable loop playback"}
|
|
384
|
+
aria-pressed={loopEnabled}
|
|
385
|
+
>
|
|
386
|
+
Loop
|
|
387
|
+
</button>
|
|
388
|
+
|
|
389
|
+
<button
|
|
390
|
+
type="button"
|
|
391
|
+
onClick={() => setTimeDisplayMode((mode) => (mode === "time" ? "frame" : "time"))}
|
|
392
|
+
className="h-7 w-14 rounded-md border border-neutral-700 px-2 text-[10px] font-mono text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
|
|
393
|
+
title="Toggle time/frame display"
|
|
394
|
+
aria-label="Toggle time and frame display"
|
|
395
|
+
>
|
|
396
|
+
{timeDisplayMode === "time" ? "m:ss" : "frames"}
|
|
397
|
+
</button>
|
|
398
|
+
|
|
399
|
+
<form
|
|
400
|
+
onSubmit={handleJumpSubmit}
|
|
401
|
+
className="hidden sm:flex flex-shrink-0 w-[58px] items-center"
|
|
402
|
+
>
|
|
403
|
+
<input
|
|
404
|
+
value={jumpFrame}
|
|
405
|
+
onChange={(e) => setJumpFrame(e.target.value)}
|
|
406
|
+
inputMode="numeric"
|
|
407
|
+
pattern="[0-9]*"
|
|
408
|
+
aria-label="Jump to frame"
|
|
409
|
+
placeholder="frame"
|
|
410
|
+
className="h-7 w-[58px] rounded-md border border-neutral-700 bg-neutral-900 px-2 text-[10px] font-mono tabular-nums text-neutral-200 outline-none transition-colors placeholder:text-neutral-600 focus:border-studio-accent/60"
|
|
411
|
+
onKeyDown={handleJumpKeyDown}
|
|
412
|
+
onBlur={commitJumpFrame}
|
|
413
|
+
/>
|
|
414
|
+
</form>
|
|
415
|
+
|
|
416
|
+
<div
|
|
417
|
+
className="hidden lg:flex items-center gap-1 text-[9px] font-mono text-neutral-500"
|
|
418
|
+
aria-label="Playback shortcuts: J backward, K stop, L forward, arrows step one frame"
|
|
419
|
+
>
|
|
420
|
+
{SHORTCUT_HINTS.map((shortcut) => (
|
|
421
|
+
<span
|
|
422
|
+
key={shortcut.key}
|
|
423
|
+
className="group relative rounded border border-neutral-800 px-1 py-0.5"
|
|
353
424
|
>
|
|
354
|
-
|
|
355
|
-
<
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
<span>Timeline</span>
|
|
359
|
-
<span className="hidden md:inline rounded bg-black/20 px-1 py-0.5 text-[9px] font-mono opacity-70">
|
|
360
|
-
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
|
|
425
|
+
{shortcut.key}
|
|
426
|
+
<span className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-1.5 hidden -translate-x-1/2 whitespace-nowrap rounded-md border border-neutral-700 bg-neutral-950 px-2 py-1 font-sans text-[10px] text-neutral-200 shadow-lg group-hover:block">
|
|
427
|
+
{shortcut.label}
|
|
428
|
+
</span>
|
|
361
429
|
</span>
|
|
362
|
-
|
|
363
|
-
|
|
430
|
+
))}
|
|
431
|
+
</div>
|
|
364
432
|
</div>
|
|
365
433
|
);
|
|
366
434
|
});
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import {
|
|
3
|
+
formatTimelineTickLabel,
|
|
3
4
|
generateTicks,
|
|
4
5
|
getDefaultDroppedTrack,
|
|
5
6
|
getTimelineCanvasHeight,
|
|
6
7
|
resolveTimelineAssetDrop,
|
|
7
8
|
getTimelinePlayheadLeft,
|
|
9
|
+
getTimelineScrollLeftForZoomAnchor,
|
|
8
10
|
getTimelineScrollLeftForZoomTransition,
|
|
11
|
+
shouldShowTimelineShortcutHint,
|
|
9
12
|
shouldHandleTimelineDeleteKey,
|
|
10
13
|
shouldAutoScrollTimeline,
|
|
11
14
|
} from "./Timeline";
|
|
@@ -78,6 +81,20 @@ describe("generateTicks", () => {
|
|
|
78
81
|
expect(major[0]).toBe(0);
|
|
79
82
|
}
|
|
80
83
|
});
|
|
84
|
+
|
|
85
|
+
it("uses denser major labels as timeline zoom increases", () => {
|
|
86
|
+
const fitTicks = generateTicks(180, 10);
|
|
87
|
+
const zoomedTicks = generateTicks(180, 48);
|
|
88
|
+
expect(fitTicks.major[1] - fitTicks.major[0]).toBe(15);
|
|
89
|
+
expect(zoomedTicks.major[1] - zoomedTicks.major[0]).toBe(5);
|
|
90
|
+
expect(zoomedTicks.minor).toContain(1);
|
|
91
|
+
expect(zoomedTicks.minor).toContain(4);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("keeps labels readable instead of placing one at every tiny tick", () => {
|
|
95
|
+
const { major } = generateTicks(180, 80);
|
|
96
|
+
expect(major[1] - major[0]).toBe(2);
|
|
97
|
+
});
|
|
81
98
|
});
|
|
82
99
|
|
|
83
100
|
describe("formatTime", () => {
|
|
@@ -118,6 +135,20 @@ describe("formatTime", () => {
|
|
|
118
135
|
});
|
|
119
136
|
});
|
|
120
137
|
|
|
138
|
+
describe("formatTimelineTickLabel", () => {
|
|
139
|
+
it("uses minute-second labels for normal timeline intervals", () => {
|
|
140
|
+
expect(formatTimelineTickLabel(90, 180, 5)).toBe("1:30");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("uses hour labels for long timelines", () => {
|
|
144
|
+
expect(formatTimelineTickLabel(3661, 4000, 60)).toBe("1:01:01");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("shows subsecond labels when the major ruler interval is below one second", () => {
|
|
148
|
+
expect(formatTimelineTickLabel(1.5, 3, 0.5)).toBe("0:01.5");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
121
152
|
describe("shouldAutoScrollTimeline", () => {
|
|
122
153
|
it("never auto-scrolls in fit mode", () => {
|
|
123
154
|
expect(shouldAutoScrollTimeline("fit", 1200, 800)).toBe(false);
|
|
@@ -144,6 +175,48 @@ describe("getTimelineScrollLeftForZoomTransition", () => {
|
|
|
144
175
|
expect(getTimelineScrollLeftForZoomTransition("manual", "manual", 480)).toBe(480);
|
|
145
176
|
});
|
|
146
177
|
});
|
|
178
|
+
|
|
179
|
+
describe("getTimelineScrollLeftForZoomAnchor", () => {
|
|
180
|
+
it("preserves the time under the pointer when zooming in", () => {
|
|
181
|
+
expect(
|
|
182
|
+
getTimelineScrollLeftForZoomAnchor({
|
|
183
|
+
pointerX: 300,
|
|
184
|
+
currentScrollLeft: 200,
|
|
185
|
+
gutter: 32,
|
|
186
|
+
currentPixelsPerSecond: 10,
|
|
187
|
+
nextPixelsPerSecond: 20,
|
|
188
|
+
duration: 120,
|
|
189
|
+
}),
|
|
190
|
+
).toBe(668);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("clamps negative scroll targets", () => {
|
|
194
|
+
expect(
|
|
195
|
+
getTimelineScrollLeftForZoomAnchor({
|
|
196
|
+
pointerX: 300,
|
|
197
|
+
currentScrollLeft: 0,
|
|
198
|
+
gutter: 32,
|
|
199
|
+
currentPixelsPerSecond: 20,
|
|
200
|
+
nextPixelsPerSecond: 5,
|
|
201
|
+
duration: 120,
|
|
202
|
+
}),
|
|
203
|
+
).toBe(0);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("preserves current scroll when inputs are invalid", () => {
|
|
207
|
+
expect(
|
|
208
|
+
getTimelineScrollLeftForZoomAnchor({
|
|
209
|
+
pointerX: 300,
|
|
210
|
+
currentScrollLeft: 120,
|
|
211
|
+
gutter: 32,
|
|
212
|
+
currentPixelsPerSecond: 0,
|
|
213
|
+
nextPixelsPerSecond: 20,
|
|
214
|
+
duration: 120,
|
|
215
|
+
}),
|
|
216
|
+
).toBe(120);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
147
220
|
describe("getTimelinePlayheadLeft", () => {
|
|
148
221
|
it("converts time to a pixel offset from the gutter", () => {
|
|
149
222
|
expect(getTimelinePlayheadLeft(4, 20)).toBe(112);
|
|
@@ -165,6 +238,17 @@ describe("getTimelineCanvasHeight", () => {
|
|
|
165
238
|
});
|
|
166
239
|
});
|
|
167
240
|
|
|
241
|
+
describe("shouldShowTimelineShortcutHint", () => {
|
|
242
|
+
it("shows the hint when the timeline does not vertically overflow", () => {
|
|
243
|
+
expect(shouldShowTimelineShortcutHint(220, 220)).toBe(true);
|
|
244
|
+
expect(shouldShowTimelineShortcutHint(220.5, 220)).toBe(true);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("hides the hint when timeline tracks need vertical scrolling", () => {
|
|
248
|
+
expect(shouldShowTimelineShortcutHint(221.5, 220)).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
168
252
|
describe("shouldHandleTimelineDeleteKey", () => {
|
|
169
253
|
it("handles Delete and Backspace when focus is not in an editor", () => {
|
|
170
254
|
expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true);
|