@hyperframes/studio 0.4.12 → 0.4.13-alpha.1
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/hyperframes-player-BOs_kypk.js +198 -0
- package/dist/assets/index-BKkR67xb.css +1 -0
- package/dist/assets/index-rN5doSq1.js +93 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +289 -11
- package/src/components/nle/NLELayout.tsx +24 -7
- package/src/components/nle/NLEPreview.test.ts +32 -0
- package/src/components/nle/NLEPreview.tsx +12 -1
- package/src/player/components/CompositionThumbnail.tsx +94 -17
- package/src/player/components/EditModal.tsx +48 -29
- package/src/player/components/Player.tsx +5 -2
- package/src/player/components/PlayerControls.test.ts +20 -0
- package/src/player/components/PlayerControls.tsx +12 -1
- package/src/player/components/Timeline.test.ts +44 -1
- package/src/player/components/Timeline.tsx +686 -169
- package/src/player/components/TimelineClip.tsx +112 -16
- package/src/player/components/timelineEditing.test.ts +310 -0
- package/src/player/components/timelineEditing.ts +213 -0
- package/src/player/components/timelineTheme.test.ts +56 -0
- package/src/player/components/timelineTheme.ts +141 -0
- package/src/player/components/timelineZoom.test.ts +62 -0
- package/src/player/components/timelineZoom.ts +38 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +96 -0
- package/src/player/hooks/useTimelinePlayer.ts +313 -59
- package/src/player/store/playerStore.test.ts +30 -12
- package/src/player/store/playerStore.ts +23 -9
- package/src/types/hyperframes-player.d.ts +1 -0
- package/src/utils/sourcePatcher.test.ts +84 -0
- package/src/utils/sourcePatcher.ts +143 -0
- package/dist/assets/hyperframes-player-5iD9BZnx.js +0 -198
- package/dist/assets/index-CVDXfFQ6.js +0 -93
- package/dist/assets/index-jmDaI2F7.css +0 -1
|
@@ -1,52 +1,129 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* Takes one screenshot at the midpoint of the clip and covers the full width —
|
|
5
|
-
* same approach as After Effects for precomps. This avoids the 1-2s per-frame
|
|
6
|
-
* Puppeteer cost of rendering multiple filmstrip frames.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { memo } from "react";
|
|
1
|
+
import { memo, useCallback, useState, useRef } from "react";
|
|
2
|
+
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
10
3
|
|
|
11
4
|
interface CompositionThumbnailProps {
|
|
12
5
|
previewUrl: string;
|
|
13
6
|
label: string;
|
|
14
7
|
labelColor: string;
|
|
8
|
+
accentColor?: string;
|
|
9
|
+
selector?: string;
|
|
15
10
|
seekTime?: number;
|
|
16
11
|
duration?: number;
|
|
17
12
|
width?: number;
|
|
18
13
|
height?: number;
|
|
19
14
|
}
|
|
20
15
|
|
|
16
|
+
const CLIP_HEIGHT = 66;
|
|
17
|
+
const THUMBNAIL_URL_VERSION = "v2";
|
|
18
|
+
|
|
21
19
|
export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
22
20
|
previewUrl,
|
|
23
21
|
label,
|
|
24
22
|
labelColor,
|
|
23
|
+
accentColor = "#6B7280",
|
|
24
|
+
selector,
|
|
25
25
|
seekTime = 2,
|
|
26
26
|
duration = 5,
|
|
27
27
|
}: CompositionThumbnailProps) {
|
|
28
|
-
|
|
28
|
+
const [containerWidth, setContainerWidth] = useState(0);
|
|
29
|
+
const [loaded, setLoaded] = useState(false);
|
|
30
|
+
const [aspect, setAspect] = useState(16 / 9);
|
|
31
|
+
const roRef = useRef<ResizeObserver | null>(null);
|
|
32
|
+
|
|
33
|
+
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
|
34
|
+
roRef.current?.disconnect();
|
|
35
|
+
if (!el) return;
|
|
36
|
+
|
|
37
|
+
const measured = el.parentElement?.clientWidth || el.clientWidth;
|
|
38
|
+
setContainerWidth(measured);
|
|
39
|
+
|
|
40
|
+
const target = el.parentElement || el;
|
|
41
|
+
roRef.current = new ResizeObserver(([entry]) => {
|
|
42
|
+
setContainerWidth(entry.contentRect.width);
|
|
43
|
+
});
|
|
44
|
+
roRef.current.observe(target);
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
useMountEffect(() => () => {
|
|
48
|
+
roRef.current?.disconnect();
|
|
49
|
+
});
|
|
50
|
+
|
|
29
51
|
const thumbnailBase = previewUrl
|
|
30
52
|
.replace("/preview/comp/", "/thumbnail/")
|
|
31
53
|
.replace(/\/preview$/, "/thumbnail/index.html");
|
|
32
54
|
const midTime = seekTime + duration / 2;
|
|
33
|
-
const
|
|
55
|
+
const thumbnailUrl = new URL(thumbnailBase, window.location.origin);
|
|
56
|
+
thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
|
|
57
|
+
thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
|
|
58
|
+
if (selector) thumbnailUrl.searchParams.set("selector", selector);
|
|
59
|
+
const url = thumbnailUrl.toString();
|
|
60
|
+
const frameW = Math.max(48, Math.round(CLIP_HEIGHT * aspect));
|
|
61
|
+
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
|
|
34
62
|
|
|
35
63
|
return (
|
|
36
|
-
<div className="absolute inset-0 overflow-hidden
|
|
64
|
+
<div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
|
|
37
65
|
<img
|
|
38
66
|
src={url}
|
|
39
67
|
alt=""
|
|
40
68
|
draggable={false}
|
|
41
69
|
loading="lazy"
|
|
42
70
|
onLoad={(e) => {
|
|
43
|
-
|
|
71
|
+
const img = e.currentTarget;
|
|
72
|
+
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
|
73
|
+
setAspect(img.naturalWidth / img.naturalHeight);
|
|
74
|
+
}
|
|
75
|
+
setLoaded(true);
|
|
44
76
|
}}
|
|
45
|
-
className="
|
|
46
|
-
style={{ opacity: 0, transition: "opacity 200ms ease-out" }}
|
|
77
|
+
className="hidden"
|
|
47
78
|
/>
|
|
48
79
|
|
|
49
|
-
{
|
|
80
|
+
{loaded ? (
|
|
81
|
+
<div className="absolute inset-0 flex">
|
|
82
|
+
{Array.from({ length: frameCount }).map((_, i) => (
|
|
83
|
+
<div
|
|
84
|
+
key={i}
|
|
85
|
+
className="relative h-full flex-shrink-0 overflow-hidden"
|
|
86
|
+
style={{ width: frameW }}
|
|
87
|
+
>
|
|
88
|
+
<img
|
|
89
|
+
src={url}
|
|
90
|
+
alt=""
|
|
91
|
+
draggable={false}
|
|
92
|
+
className="absolute inset-0 h-full w-full object-cover opacity-60"
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
) : (
|
|
98
|
+
<div
|
|
99
|
+
className="absolute inset-0 animate-pulse"
|
|
100
|
+
style={{
|
|
101
|
+
background:
|
|
102
|
+
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
|
|
103
|
+
}}
|
|
104
|
+
/>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
<div
|
|
108
|
+
className="absolute inset-0"
|
|
109
|
+
style={{
|
|
110
|
+
background: `linear-gradient(120deg, ${accentColor}2e, transparent 34%), linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0.08))`,
|
|
111
|
+
}}
|
|
112
|
+
/>
|
|
113
|
+
|
|
114
|
+
<div className="absolute left-2 top-2 z-10">
|
|
115
|
+
<span
|
|
116
|
+
className="block max-w-full truncate rounded-md px-1.5 py-0.5 text-[9px] font-semibold uppercase leading-none"
|
|
117
|
+
style={{
|
|
118
|
+
color: labelColor,
|
|
119
|
+
background: `${accentColor}2e`,
|
|
120
|
+
boxShadow: `inset 0 0 0 1px ${accentColor}40`,
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
{label}
|
|
124
|
+
</span>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
50
127
|
<div
|
|
51
128
|
className="absolute bottom-0 left-0 right-0 z-10 px-1.5 pb-0.5 pt-3"
|
|
52
129
|
style={{
|
|
@@ -55,7 +132,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
55
132
|
}}
|
|
56
133
|
>
|
|
57
134
|
<span
|
|
58
|
-
className="text-[9px] font-semibold
|
|
135
|
+
className="block truncate text-[9px] font-semibold leading-tight"
|
|
59
136
|
style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
|
|
60
137
|
>
|
|
61
138
|
{label}
|
|
@@ -2,6 +2,7 @@ import { useState, useCallback, useMemo, useRef } from "react";
|
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
3
|
import { usePlayerStore } from "../store/playerStore";
|
|
4
4
|
import { formatTime } from "../lib/time";
|
|
5
|
+
import { buildPromptCopyText, buildTimelineAgentPrompt } from "./timelineEditing";
|
|
5
6
|
|
|
6
7
|
interface EditPopoverProps {
|
|
7
8
|
rangeStart: number;
|
|
@@ -14,7 +15,8 @@ interface EditPopoverProps {
|
|
|
14
15
|
export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }: EditPopoverProps) {
|
|
15
16
|
const elements = usePlayerStore((s) => s.elements);
|
|
16
17
|
const [prompt, setPrompt] = useState("");
|
|
17
|
-
const [
|
|
18
|
+
const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
|
|
19
|
+
const [copiedPromptOnly, setCopiedPromptOnly] = useState(false);
|
|
18
20
|
const popoverRef = useRef<HTMLDivElement>(null);
|
|
19
21
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
20
22
|
|
|
@@ -51,27 +53,12 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
|
|
51
53
|
});
|
|
52
54
|
|
|
53
55
|
const buildClipboardText = useCallback(() => {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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.`;
|
|
56
|
+
return buildTimelineAgentPrompt({
|
|
57
|
+
rangeStart: start,
|
|
58
|
+
rangeEnd: end,
|
|
59
|
+
elements: elementsInRange,
|
|
60
|
+
prompt,
|
|
61
|
+
});
|
|
75
62
|
}, [start, end, elementsInRange, prompt]);
|
|
76
63
|
|
|
77
64
|
const handleCopy = useCallback(async () => {
|
|
@@ -85,13 +72,32 @@ Preserve all other elements and timing outside this range.`;
|
|
|
85
72
|
document.execCommand("copy");
|
|
86
73
|
document.body.removeChild(ta);
|
|
87
74
|
}
|
|
88
|
-
|
|
75
|
+
setCopiedAgentPrompt(true);
|
|
89
76
|
setTimeout(() => {
|
|
90
|
-
|
|
77
|
+
setCopiedAgentPrompt(false);
|
|
91
78
|
onClose();
|
|
92
79
|
}, 800);
|
|
93
80
|
}, [buildClipboardText, onClose]);
|
|
94
81
|
|
|
82
|
+
const handleCopyPrompt = useCallback(async () => {
|
|
83
|
+
const promptText = buildPromptCopyText(prompt);
|
|
84
|
+
if (!promptText) return;
|
|
85
|
+
try {
|
|
86
|
+
await navigator.clipboard.writeText(promptText);
|
|
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
|
+
}
|
|
95
|
+
setCopiedPromptOnly(true);
|
|
96
|
+
setTimeout(() => {
|
|
97
|
+
setCopiedPromptOnly(false);
|
|
98
|
+
}, 800);
|
|
99
|
+
}, [prompt]);
|
|
100
|
+
|
|
95
101
|
const style: React.CSSProperties = {
|
|
96
102
|
position: "fixed",
|
|
97
103
|
left: Math.max(8, Math.min(anchorX - 160, window.innerWidth - 336)),
|
|
@@ -146,17 +152,30 @@ Preserve all other elements and timing outside this range.`;
|
|
|
146
152
|
</div>
|
|
147
153
|
|
|
148
154
|
{/* Action */}
|
|
149
|
-
<div className="px-3 pb-3">
|
|
155
|
+
<div className="grid grid-cols-2 gap-2 px-3 pb-3">
|
|
156
|
+
<button
|
|
157
|
+
onClick={handleCopyPrompt}
|
|
158
|
+
disabled={!buildPromptCopyText(prompt)}
|
|
159
|
+
className={`py-1.5 text-[11px] font-medium rounded-lg transition-all border ${
|
|
160
|
+
copiedPromptOnly
|
|
161
|
+
? "bg-green-500/20 text-green-400 border-green-500/30"
|
|
162
|
+
: "bg-neutral-800/70 text-neutral-200 border-neutral-700/50 hover:bg-neutral-800"
|
|
163
|
+
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
164
|
+
>
|
|
165
|
+
{copiedPromptOnly ? "Prompt Copied!" : "Copy Prompt"}
|
|
166
|
+
</button>
|
|
150
167
|
<button
|
|
151
168
|
onClick={handleCopy}
|
|
152
|
-
className={`
|
|
153
|
-
|
|
169
|
+
className={`py-1.5 text-[11px] font-medium rounded-lg transition-all ${
|
|
170
|
+
copiedAgentPrompt
|
|
154
171
|
? "bg-green-500/20 text-green-400 border border-green-500/30"
|
|
155
172
|
: "bg-studio-accent/15 text-studio-accent border border-studio-accent/25 hover:bg-studio-accent/25"
|
|
156
173
|
}`}
|
|
157
174
|
>
|
|
158
|
-
{
|
|
159
|
-
{!
|
|
175
|
+
{copiedAgentPrompt ? "Copied!" : "Copy to Agent"}
|
|
176
|
+
{!copiedAgentPrompt && (
|
|
177
|
+
<span className="text-[9px] text-studio-accent/50 ml-1.5">Cmd+Enter</span>
|
|
178
|
+
)}
|
|
160
179
|
</button>
|
|
161
180
|
</div>
|
|
162
181
|
</div>
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { forwardRef, useRef, useState } from "react";
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
|
-
import type { HyperframesPlayer } from "@hyperframes/player";
|
|
4
3
|
// NOTE: importing "@hyperframes/player" registers a class extending HTMLElement
|
|
5
4
|
// at module load, which throws under SSR. Defer the import to the mount effect
|
|
6
5
|
// so it only runs in the browser.
|
|
@@ -12,6 +11,10 @@ interface PlayerProps {
|
|
|
12
11
|
portrait?: boolean;
|
|
13
12
|
}
|
|
14
13
|
|
|
14
|
+
interface HyperframesPlayerElement extends HTMLElement {
|
|
15
|
+
iframeElement: HTMLIFrameElement;
|
|
16
|
+
}
|
|
17
|
+
|
|
15
18
|
/**
|
|
16
19
|
* Readiness check for a Lottie animation instance. Duck-types both supported
|
|
17
20
|
* player shapes:
|
|
@@ -96,7 +99,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
96
99
|
if (canceled) return;
|
|
97
100
|
|
|
98
101
|
// Create the web component imperatively to avoid JSX custom-element typing.
|
|
99
|
-
const player = document.createElement("hyperframes-player") as
|
|
102
|
+
const player = document.createElement("hyperframes-player") as HyperframesPlayerElement;
|
|
100
103
|
const src = directUrl || `/api/projects/${projectId}/preview`;
|
|
101
104
|
player.setAttribute("src", src);
|
|
102
105
|
player.setAttribute("width", String(portrait ? 1080 : 1920));
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveSeekPercent } from "./PlayerControls";
|
|
3
|
+
|
|
4
|
+
describe("resolveSeekPercent", () => {
|
|
5
|
+
it("returns 0 when the track width is invalid", () => {
|
|
6
|
+
expect(resolveSeekPercent(100, 0, 0)).toBe(0);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("snaps to the start within the edge threshold", () => {
|
|
10
|
+
expect(resolveSeekPercent(105, 100, 200)).toBe(0);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("snaps to the end within the edge threshold", () => {
|
|
14
|
+
expect(resolveSeekPercent(298, 100, 200)).toBe(1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("preserves the true percent away from the edges", () => {
|
|
18
|
+
expect(resolveSeekPercent(150, 100, 200)).toBe(0.25);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -4,6 +4,17 @@ import { formatTime } from "../lib/time";
|
|
|
4
4
|
import { usePlayerStore, liveTime } from "../store/playerStore";
|
|
5
5
|
|
|
6
6
|
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
|
|
7
|
+
const SEEK_EDGE_SNAP_PX = 8;
|
|
8
|
+
|
|
9
|
+
export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number {
|
|
10
|
+
if (!Number.isFinite(rectWidth) || rectWidth <= 0) return 0;
|
|
11
|
+
const rawPercent = (clientX - rectLeft) / rectWidth;
|
|
12
|
+
const clamped = Math.max(0, Math.min(1, rawPercent));
|
|
13
|
+
const snapThreshold = Math.min(0.5, SEEK_EDGE_SNAP_PX / rectWidth);
|
|
14
|
+
if (clamped <= snapThreshold) return 0;
|
|
15
|
+
if (clamped >= 1 - snapThreshold) return 1;
|
|
16
|
+
return clamped;
|
|
17
|
+
}
|
|
7
18
|
|
|
8
19
|
interface PlayerControlsProps {
|
|
9
20
|
onTogglePlay: () => void;
|
|
@@ -88,7 +99,7 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
88
99
|
const bar = seekBarRef.current;
|
|
89
100
|
if (!bar || duration <= 0) return;
|
|
90
101
|
const rect = bar.getBoundingClientRect();
|
|
91
|
-
const percent =
|
|
102
|
+
const percent = resolveSeekPercent(clientX, rect.left, rect.width);
|
|
92
103
|
// Immediately update progress bar visuals (don't wait for liveTime round-trip)
|
|
93
104
|
const pct = percent * 100;
|
|
94
105
|
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
generateTicks,
|
|
4
|
+
getTimelinePlayheadLeft,
|
|
5
|
+
getTimelineScrollLeftForZoomTransition,
|
|
6
|
+
shouldAutoScrollTimeline,
|
|
7
|
+
} from "./Timeline";
|
|
3
8
|
import { formatTime } from "../lib/time";
|
|
4
9
|
|
|
5
10
|
describe("generateTicks", () => {
|
|
@@ -108,3 +113,41 @@ describe("formatTime", () => {
|
|
|
108
113
|
expect(formatTime(61)).toBe("1:01");
|
|
109
114
|
});
|
|
110
115
|
});
|
|
116
|
+
|
|
117
|
+
describe("shouldAutoScrollTimeline", () => {
|
|
118
|
+
it("never auto-scrolls in fit mode", () => {
|
|
119
|
+
expect(shouldAutoScrollTimeline("fit", 1200, 800)).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("does not auto-scroll when there is no horizontal overflow", () => {
|
|
123
|
+
expect(shouldAutoScrollTimeline("manual", 800, 800)).toBe(false);
|
|
124
|
+
expect(shouldAutoScrollTimeline("manual", 800.5, 800)).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("auto-scrolls in manual mode when horizontal overflow exists", () => {
|
|
128
|
+
expect(shouldAutoScrollTimeline("manual", 1200, 800)).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("getTimelineScrollLeftForZoomTransition", () => {
|
|
133
|
+
it("resets horizontal scroll when switching from manual zoom back to fit", () => {
|
|
134
|
+
expect(getTimelineScrollLeftForZoomTransition("manual", "fit", 480)).toBe(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("preserves the current scroll offset for other zoom transitions", () => {
|
|
138
|
+
expect(getTimelineScrollLeftForZoomTransition("fit", "fit", 480)).toBe(480);
|
|
139
|
+
expect(getTimelineScrollLeftForZoomTransition("fit", "manual", 480)).toBe(480);
|
|
140
|
+
expect(getTimelineScrollLeftForZoomTransition("manual", "manual", 480)).toBe(480);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("getTimelinePlayheadLeft", () => {
|
|
145
|
+
it("converts time to a pixel offset from the gutter", () => {
|
|
146
|
+
expect(getTimelinePlayheadLeft(4, 20)).toBe(112);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("guards invalid input", () => {
|
|
150
|
+
expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(32);
|
|
151
|
+
expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(32);
|
|
152
|
+
});
|
|
153
|
+
});
|