@hyperframes/studio 0.5.0-alpha.1 → 0.5.0-alpha.10
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-DKaNgV2Z.css +1 -0
- package/dist/assets/index-peNJzL-4.js +105 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +132 -41
- 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/NLEPreview.tsx +5 -1
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- 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 +120 -11
- 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/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 +19 -1
- package/src/player/lib/time.ts +20 -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/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
|
@@ -34,6 +34,7 @@ interface PlayerState {
|
|
|
34
34
|
elements: TimelineElement[];
|
|
35
35
|
selectedElementId: string | null;
|
|
36
36
|
playbackRate: number;
|
|
37
|
+
loopEnabled: boolean;
|
|
37
38
|
/** Timeline zoom: 'fit' auto-scales to viewport, 'manual' uses manualZoomPercent */
|
|
38
39
|
zoomMode: ZoomMode;
|
|
39
40
|
/** Timeline zoom percent relative to the fit width when in manual mode */
|
|
@@ -43,6 +44,7 @@ interface PlayerState {
|
|
|
43
44
|
setCurrentTime: (time: number) => void;
|
|
44
45
|
setDuration: (duration: number) => void;
|
|
45
46
|
setPlaybackRate: (rate: number) => void;
|
|
47
|
+
setLoopEnabled: (enabled: boolean) => void;
|
|
46
48
|
setTimelineReady: (ready: boolean) => void;
|
|
47
49
|
setElements: (elements: TimelineElement[]) => void;
|
|
48
50
|
setSelectedElementId: (id: string | null) => void;
|
|
@@ -76,11 +78,13 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
76
78
|
elements: [],
|
|
77
79
|
selectedElementId: null,
|
|
78
80
|
playbackRate: 1,
|
|
81
|
+
loopEnabled: false,
|
|
79
82
|
zoomMode: "fit",
|
|
80
83
|
manualZoomPercent: 100,
|
|
81
84
|
|
|
82
85
|
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
|
83
86
|
setPlaybackRate: (rate) => set({ playbackRate: rate }),
|
|
87
|
+
setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
|
|
84
88
|
setZoomMode: (mode) => set({ zoomMode: mode }),
|
|
85
89
|
setManualZoomPercent: (percent) =>
|
|
86
90
|
set({ manualZoomPercent: Math.max(10, Math.min(2000, Math.round(percent))) }),
|
|
@@ -96,7 +100,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
96
100
|
),
|
|
97
101
|
})),
|
|
98
102
|
// Resets project-specific state when switching compositions.
|
|
99
|
-
// playbackRate, zoomMode, and manualZoomPercent are intentionally preserved
|
|
103
|
+
// playbackRate, loopEnabled, zoomMode, and manualZoomPercent are intentionally preserved
|
|
100
104
|
// because they are user preferences that should survive project switches.
|
|
101
105
|
reset: () =>
|
|
102
106
|
set({
|
package/src/styles/studio.css
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
@tailwind components;
|
|
3
3
|
@tailwind utilities;
|
|
4
4
|
|
|
5
|
+
/*
|
|
6
|
+
* Studio is a dark-only UI — pin the user-agent color scheme to dark so that
|
|
7
|
+
* browser-native chrome (scrollbars, form controls, focus rings) picks the
|
|
8
|
+
* matching palette instead of defaulting to light against our #0a0a0a body.
|
|
9
|
+
*/
|
|
10
|
+
:root {
|
|
11
|
+
color-scheme: dark;
|
|
12
|
+
}
|
|
13
|
+
|
|
5
14
|
body {
|
|
6
15
|
margin: 0;
|
|
7
16
|
padding: 0;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { Window } from "happy-dom";
|
|
3
|
+
import { copyTextToClipboard } from "./clipboard";
|
|
4
|
+
|
|
5
|
+
function installDocument(execCommand: (command: string) => boolean): void {
|
|
6
|
+
const window = new Window();
|
|
7
|
+
Object.defineProperty(window.document, "execCommand", {
|
|
8
|
+
configurable: true,
|
|
9
|
+
value: execCommand,
|
|
10
|
+
});
|
|
11
|
+
vi.stubGlobal("document", window.document);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function installNavigator(
|
|
15
|
+
writeText: (text: string) => Promise<void>,
|
|
16
|
+
userAgent = "Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36",
|
|
17
|
+
): void {
|
|
18
|
+
vi.stubGlobal("navigator", {
|
|
19
|
+
clipboard: { writeText },
|
|
20
|
+
userAgent,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("copyTextToClipboard", () => {
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.unstubAllGlobals();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("uses the synchronous selection copy path first in Safari", async () => {
|
|
30
|
+
const execCommand = vi.fn((command: string) => command === "copy");
|
|
31
|
+
const writeText = vi.fn((_text: string) => Promise.resolve());
|
|
32
|
+
|
|
33
|
+
installDocument(execCommand);
|
|
34
|
+
installNavigator(
|
|
35
|
+
writeText,
|
|
36
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
await expect(copyTextToClipboard("copy me")).resolves.toBe(true);
|
|
40
|
+
|
|
41
|
+
expect(execCommand).toHaveBeenCalledWith("copy");
|
|
42
|
+
expect(writeText).not.toHaveBeenCalled();
|
|
43
|
+
expect(document.querySelector("textarea")).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("uses navigator.clipboard first outside Safari", async () => {
|
|
47
|
+
const execCommand = vi.fn((command: string) => command === "copy");
|
|
48
|
+
const writeText = vi.fn((_text: string) => Promise.resolve());
|
|
49
|
+
|
|
50
|
+
installDocument(execCommand);
|
|
51
|
+
installNavigator(writeText);
|
|
52
|
+
|
|
53
|
+
await expect(copyTextToClipboard("copy me")).resolves.toBe(true);
|
|
54
|
+
|
|
55
|
+
expect(writeText).toHaveBeenCalledWith("copy me");
|
|
56
|
+
expect(execCommand).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("falls back to selection copy outside Safari when navigator.clipboard fails", async () => {
|
|
60
|
+
const execCommand = vi.fn((command: string) => command === "copy");
|
|
61
|
+
const writeText = vi.fn((_text: string) => Promise.reject(new Error("blocked")));
|
|
62
|
+
|
|
63
|
+
installDocument(execCommand);
|
|
64
|
+
installNavigator(writeText);
|
|
65
|
+
|
|
66
|
+
await expect(copyTextToClipboard("copy me")).resolves.toBe(true);
|
|
67
|
+
|
|
68
|
+
expect(writeText).toHaveBeenCalledWith("copy me");
|
|
69
|
+
expect(execCommand).toHaveBeenCalledWith("copy");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("reports failure when both copy paths fail", async () => {
|
|
73
|
+
const execCommand = vi.fn(() => false);
|
|
74
|
+
const writeText = vi.fn((_text: string) => Promise.reject(new Error("blocked")));
|
|
75
|
+
|
|
76
|
+
installDocument(execCommand);
|
|
77
|
+
installNavigator(
|
|
78
|
+
writeText,
|
|
79
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
await expect(copyTextToClipboard("copy me")).resolves.toBe(false);
|
|
83
|
+
|
|
84
|
+
expect(execCommand).toHaveBeenCalledWith("copy");
|
|
85
|
+
expect(writeText).toHaveBeenCalledWith("copy me");
|
|
86
|
+
expect(document.querySelector("textarea")).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
function copyWithSelection(text: string): boolean {
|
|
2
|
+
if (typeof document === "undefined" || !document.body || !document.execCommand) {
|
|
3
|
+
return false;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const textarea = document.createElement("textarea");
|
|
7
|
+
textarea.value = text;
|
|
8
|
+
textarea.setAttribute("readonly", "true");
|
|
9
|
+
textarea.style.position = "fixed";
|
|
10
|
+
textarea.style.top = "0";
|
|
11
|
+
textarea.style.left = "0";
|
|
12
|
+
textarea.style.width = "1px";
|
|
13
|
+
textarea.style.height = "1px";
|
|
14
|
+
textarea.style.padding = "0";
|
|
15
|
+
textarea.style.border = "0";
|
|
16
|
+
textarea.style.opacity = "0";
|
|
17
|
+
textarea.style.pointerEvents = "none";
|
|
18
|
+
|
|
19
|
+
document.body.appendChild(textarea);
|
|
20
|
+
textarea.focus({ preventScroll: true });
|
|
21
|
+
textarea.select();
|
|
22
|
+
textarea.setSelectionRange(0, text.length);
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
return document.execCommand("copy");
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
} finally {
|
|
29
|
+
document.body.removeChild(textarea);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function shouldCopyWithSelectionFirst(): boolean {
|
|
34
|
+
if (typeof navigator === "undefined") return false;
|
|
35
|
+
|
|
36
|
+
const userAgent = navigator.userAgent;
|
|
37
|
+
return /Safari/i.test(userAgent) && !/Chrome|Chromium|CriOS|FxiOS|Edg|OPR/i.test(userAgent);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function copyTextToClipboard(text: string): Promise<boolean> {
|
|
41
|
+
const useSelectionFirst = shouldCopyWithSelectionFirst();
|
|
42
|
+
if (useSelectionFirst && copyWithSelection(text)) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const clipboard = typeof navigator !== "undefined" ? navigator.clipboard : undefined;
|
|
47
|
+
if (clipboard?.writeText) {
|
|
48
|
+
try {
|
|
49
|
+
await clipboard.writeText(text);
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
// Fall back below when the browser still allows synchronous copy.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return !useSelectionFirst && copyWithSelection(text);
|
|
57
|
+
}
|
|
@@ -12,6 +12,8 @@ describe("getTimelineAssetKind", () => {
|
|
|
12
12
|
it("detects image, video, and audio assets", () => {
|
|
13
13
|
expect(getTimelineAssetKind("assets/photo.png")).toBe("image");
|
|
14
14
|
expect(getTimelineAssetKind("assets/clip.mp4")).toBe("video");
|
|
15
|
+
expect(getTimelineAssetKind("assets/clip.mov")).toBe("video");
|
|
16
|
+
expect(getTimelineAssetKind("assets/music.mp3")).toBe("audio");
|
|
15
17
|
expect(getTimelineAssetKind("assets/music.wav")).toBe("audio");
|
|
16
18
|
});
|
|
17
19
|
});
|
|
@@ -78,11 +80,69 @@ describe("resolveTimelineAssetSrc", () => {
|
|
|
78
80
|
});
|
|
79
81
|
|
|
80
82
|
describe("buildTimelineFileDropPlacements", () => {
|
|
81
|
-
it("
|
|
82
|
-
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 },
|
|
83
|
+
it("returns no placements for an empty drop set", () => {
|
|
84
|
+
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [])).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("uses the dropped start and spaces multiple files by duration on the same track", () => {
|
|
88
|
+
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [1.2, 1.6, 1.1])).toEqual([
|
|
89
|
+
{ start: 1.5, track: 2 },
|
|
90
|
+
{ start: 2.7, track: 2 },
|
|
91
|
+
{ start: 4.3, track: 2 },
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("uses fallback spacing when a duration is unavailable", () => {
|
|
96
|
+
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [1.2, 0, 1.1])).toEqual([
|
|
83
97
|
{ start: 1.5, track: 2 },
|
|
84
|
-
{ start:
|
|
85
|
-
{ start:
|
|
98
|
+
{ start: 2.7, track: 2 },
|
|
99
|
+
{ start: 7.7, track: 2 },
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("moves the spaced sequence to a clear track when the dropped row is occupied", () => {
|
|
104
|
+
expect(
|
|
105
|
+
buildTimelineFileDropPlacements(
|
|
106
|
+
{ start: 1.5, track: 2 },
|
|
107
|
+
[1.2, 1.6, 1.1],
|
|
108
|
+
[
|
|
109
|
+
{ start: 0, duration: 8, track: 2 },
|
|
110
|
+
{ start: 0, duration: 4, track: 5 },
|
|
111
|
+
],
|
|
112
|
+
),
|
|
113
|
+
).toEqual([
|
|
114
|
+
{ start: 1.5, track: 6 },
|
|
115
|
+
{ start: 2.7, track: 6 },
|
|
116
|
+
{ start: 4.3, track: 6 },
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("keeps a requested track above occupied rows when that track is clear", () => {
|
|
121
|
+
expect(
|
|
122
|
+
buildTimelineFileDropPlacements(
|
|
123
|
+
{ start: 1.5, track: 8 },
|
|
124
|
+
[1.2, 1.6],
|
|
125
|
+
[
|
|
126
|
+
{ start: 0, duration: 8, track: 2 },
|
|
127
|
+
{ start: 0, duration: 4, track: 5 },
|
|
128
|
+
],
|
|
129
|
+
),
|
|
130
|
+
).toEqual([
|
|
131
|
+
{ start: 1.5, track: 8 },
|
|
132
|
+
{ start: 2.7, track: 8 },
|
|
133
|
+
]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("moves a default-track drop to a clear row when track 0 is occupied at time 0", () => {
|
|
137
|
+
expect(
|
|
138
|
+
buildTimelineFileDropPlacements(
|
|
139
|
+
{ start: 0, track: 0 },
|
|
140
|
+
[1.2, 1.6],
|
|
141
|
+
[{ start: 0, duration: 8, track: 0 }],
|
|
142
|
+
),
|
|
143
|
+
).toEqual([
|
|
144
|
+
{ start: 0, track: 1 },
|
|
145
|
+
{ start: 1.2, track: 1 },
|
|
86
146
|
]);
|
|
87
147
|
});
|
|
88
148
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT } from "./mediaTypes";
|
|
2
2
|
|
|
3
3
|
export const TIMELINE_ASSET_MIME = "application/x-hyperframes-asset";
|
|
4
|
+
const FALLBACK_TIMELINE_FILE_DROP_DURATION = 5;
|
|
4
5
|
|
|
5
6
|
export type TimelineAssetKind = "image" | "video" | "audio";
|
|
6
7
|
|
|
@@ -46,12 +47,33 @@ export function resolveTimelineAssetSrc(targetPath: string, assetPath: string):
|
|
|
46
47
|
|
|
47
48
|
export function buildTimelineFileDropPlacements(
|
|
48
49
|
placement: { start: number; track: number },
|
|
49
|
-
|
|
50
|
+
durations: number[],
|
|
51
|
+
occupiedClips: Array<{ start: number; duration: number; track: number }> = [],
|
|
50
52
|
): Array<{ start: number; track: number }> {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
let nextStart = Math.round(Math.max(0, placement.start) * 100) / 100;
|
|
54
|
+
const sequenceStart = nextStart;
|
|
55
|
+
const resolvedDurations = durations.map((duration) =>
|
|
56
|
+
Number.isFinite(duration) && duration > 0 ? duration : FALLBACK_TIMELINE_FILE_DROP_DURATION,
|
|
57
|
+
);
|
|
58
|
+
const sequenceEnd = resolvedDurations.reduce(
|
|
59
|
+
(end, duration) => Math.round((end + duration) * 100) / 100,
|
|
60
|
+
sequenceStart,
|
|
61
|
+
);
|
|
62
|
+
const overlapsDropTrack = occupiedClips.some((clip) => {
|
|
63
|
+
if (clip.track !== placement.track) return false;
|
|
64
|
+
const clipStart = Math.max(0, clip.start);
|
|
65
|
+
const clipEnd = clipStart + Math.max(0, clip.duration);
|
|
66
|
+
return sequenceStart < clipEnd && sequenceEnd > clipStart;
|
|
67
|
+
});
|
|
68
|
+
const track = overlapsDropTrack
|
|
69
|
+
? Math.max(placement.track, ...occupiedClips.map((clip) => clip.track)) + 1
|
|
70
|
+
: placement.track;
|
|
71
|
+
|
|
72
|
+
return resolvedDurations.map((duration) => {
|
|
73
|
+
const start = nextStart;
|
|
74
|
+
nextStart = Math.round((nextStart + duration) * 100) / 100;
|
|
75
|
+
return { start, track };
|
|
76
|
+
});
|
|
55
77
|
}
|
|
56
78
|
|
|
57
79
|
export function resolveTimelineAssetInitialGeometry(source: string): {
|