@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
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createEmptyEditHistory, type EditHistoryState } from "./editHistory";
|
|
2
|
+
|
|
3
|
+
export interface EditHistoryStorageAdapter {
|
|
4
|
+
get(projectId: string): Promise<EditHistoryState | null>;
|
|
5
|
+
set(projectId: string, state: EditHistoryState): Promise<void>;
|
|
6
|
+
delete(projectId: string): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const DB_NAME = "hyperframes-studio-edit-history";
|
|
10
|
+
const DB_VERSION = 1;
|
|
11
|
+
const STORE_NAME = "project-history";
|
|
12
|
+
|
|
13
|
+
export function createMemoryEditHistoryStorage(): EditHistoryStorageAdapter {
|
|
14
|
+
const states = new Map<string, EditHistoryState>();
|
|
15
|
+
return {
|
|
16
|
+
async get(projectId) {
|
|
17
|
+
return states.get(projectId) ?? null;
|
|
18
|
+
},
|
|
19
|
+
async set(projectId, state) {
|
|
20
|
+
states.set(projectId, structuredClone(state));
|
|
21
|
+
},
|
|
22
|
+
async delete(projectId) {
|
|
23
|
+
states.delete(projectId);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function openEditHistoryDb(): Promise<IDBDatabase> {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
if (!globalThis.indexedDB) {
|
|
31
|
+
reject(new Error("IndexedDB is not available"));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const request = globalThis.indexedDB.open(DB_NAME, DB_VERSION);
|
|
36
|
+
request.onupgradeneeded = () => {
|
|
37
|
+
const db = request.result;
|
|
38
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
39
|
+
db.createObjectStore(STORE_NAME);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
request.onerror = () => reject(request.error ?? new Error("Failed to open edit history db"));
|
|
43
|
+
request.onsuccess = () => resolve(request.result);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function withStore<T>(
|
|
48
|
+
mode: IDBTransactionMode,
|
|
49
|
+
callback: (store: IDBObjectStore) => IDBRequest<T>,
|
|
50
|
+
): Promise<T> {
|
|
51
|
+
return openEditHistoryDb().then(
|
|
52
|
+
(db) =>
|
|
53
|
+
new Promise<T>((resolve, reject) => {
|
|
54
|
+
const tx = db.transaction(STORE_NAME, mode);
|
|
55
|
+
const request = callback(tx.objectStore(STORE_NAME));
|
|
56
|
+
request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed"));
|
|
57
|
+
request.onsuccess = () => resolve(request.result);
|
|
58
|
+
tx.oncomplete = () => db.close();
|
|
59
|
+
tx.onerror = () => {
|
|
60
|
+
db.close();
|
|
61
|
+
reject(tx.error ?? new Error("IndexedDB transaction failed"));
|
|
62
|
+
};
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createIndexedDbEditHistoryStorage(): EditHistoryStorageAdapter {
|
|
68
|
+
return {
|
|
69
|
+
async get(projectId) {
|
|
70
|
+
return (
|
|
71
|
+
(await withStore<EditHistoryState | undefined>("readonly", (store) =>
|
|
72
|
+
store.get(projectId),
|
|
73
|
+
)) ?? null
|
|
74
|
+
);
|
|
75
|
+
},
|
|
76
|
+
async set(projectId, state) {
|
|
77
|
+
await withStore<IDBValidKey>("readwrite", (store) => store.put(state, projectId));
|
|
78
|
+
},
|
|
79
|
+
async delete(projectId) {
|
|
80
|
+
await withStore<undefined>("readwrite", (store) => store.delete(projectId));
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function loadEditHistoryState(
|
|
86
|
+
storage: EditHistoryStorageAdapter,
|
|
87
|
+
projectId: string,
|
|
88
|
+
): Promise<EditHistoryState> {
|
|
89
|
+
const state = await storage.get(projectId);
|
|
90
|
+
return state ?? createEmptyEditHistory();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function saveEditHistoryState(
|
|
94
|
+
storage: EditHistoryStorageAdapter,
|
|
95
|
+
projectId: string,
|
|
96
|
+
state: EditHistoryState,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
await storage.set(projectId, state);
|
|
99
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./frameCapture";
|
|
3
|
+
|
|
4
|
+
describe("frame capture utilities", () => {
|
|
5
|
+
it("builds a PNG capture URL for the master composition", () => {
|
|
6
|
+
vi.useFakeTimers();
|
|
7
|
+
vi.setSystemTime(new Date("2026-04-29T12:00:00Z"));
|
|
8
|
+
|
|
9
|
+
expect(
|
|
10
|
+
buildFrameCaptureUrl({
|
|
11
|
+
projectId: "demo project",
|
|
12
|
+
compositionPath: null,
|
|
13
|
+
currentTime: 1.23456,
|
|
14
|
+
origin: "http://localhost:5194",
|
|
15
|
+
}),
|
|
16
|
+
).toBe(
|
|
17
|
+
"http://localhost:5194/api/projects/demo%20project/thumbnail/index.html?t=1.235&format=png&v=1777464000000",
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
vi.useRealTimers();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("builds a safe filename from a nested composition path", () => {
|
|
24
|
+
expect(buildFrameCaptureFilename("compositions/intro.html", 2.5)).toBe("intro-2-500s.png");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface FrameCaptureRequest {
|
|
2
|
+
projectId: string;
|
|
3
|
+
compositionPath: string | null;
|
|
4
|
+
currentTime: number;
|
|
5
|
+
origin?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function normalizeCompositionPath(compositionPath: string | null): string {
|
|
9
|
+
return compositionPath && compositionPath !== "master" ? compositionPath : "index.html";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function buildFrameCaptureUrl({
|
|
13
|
+
projectId,
|
|
14
|
+
compositionPath,
|
|
15
|
+
currentTime,
|
|
16
|
+
origin = window.location.origin,
|
|
17
|
+
}: FrameCaptureRequest): string {
|
|
18
|
+
const compPath = normalizeCompositionPath(compositionPath);
|
|
19
|
+
const url = new URL(
|
|
20
|
+
`/api/projects/${encodeURIComponent(projectId)}/thumbnail/${encodeURIComponent(compPath)}`,
|
|
21
|
+
origin,
|
|
22
|
+
);
|
|
23
|
+
url.searchParams.set("t", Math.max(0, currentTime).toFixed(3));
|
|
24
|
+
url.searchParams.set("format", "png");
|
|
25
|
+
url.searchParams.set("v", String(Date.now()));
|
|
26
|
+
return url.toString();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildFrameCaptureFilename(compositionPath: string | null, currentTime: number) {
|
|
30
|
+
const compPath = normalizeCompositionPath(compositionPath);
|
|
31
|
+
const base =
|
|
32
|
+
compPath
|
|
33
|
+
.split("/")
|
|
34
|
+
.pop()
|
|
35
|
+
?.replace(/\.html$/i, "") || "frame";
|
|
36
|
+
const frameTime = Math.max(0, currentTime).toFixed(3).replace(".", "-");
|
|
37
|
+
return `${base}-${frameTime}s.png`;
|
|
38
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { saveProjectFilesWithHistory } from "./studioFileHistory";
|
|
3
|
+
|
|
4
|
+
describe("saveProjectFilesWithHistory", () => {
|
|
5
|
+
it("reads before content, writes after content, and records a history entry", async () => {
|
|
6
|
+
const reads: Record<string, string> = { "index.html": "before" };
|
|
7
|
+
const writes: Record<string, string> = {};
|
|
8
|
+
const recordEdit = vi.fn();
|
|
9
|
+
|
|
10
|
+
await saveProjectFilesWithHistory({
|
|
11
|
+
projectId: "project-1",
|
|
12
|
+
label: "Move layer",
|
|
13
|
+
kind: "manual",
|
|
14
|
+
files: { "index.html": "after" },
|
|
15
|
+
readFile: async (path) => reads[path],
|
|
16
|
+
writeFile: async (path, content) => {
|
|
17
|
+
writes[path] = content;
|
|
18
|
+
},
|
|
19
|
+
recordEdit,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(writes).toEqual({ "index.html": "after" });
|
|
23
|
+
expect(recordEdit).toHaveBeenCalledWith({
|
|
24
|
+
label: "Move layer",
|
|
25
|
+
kind: "manual",
|
|
26
|
+
coalesceKey: undefined,
|
|
27
|
+
files: { "index.html": { before: "before", after: "after" } },
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("skips writes and history for unchanged content", async () => {
|
|
32
|
+
const writeFile = vi.fn();
|
|
33
|
+
const recordEdit = vi.fn();
|
|
34
|
+
|
|
35
|
+
const changedPaths = await saveProjectFilesWithHistory({
|
|
36
|
+
projectId: "project-1",
|
|
37
|
+
label: "Edit layer",
|
|
38
|
+
kind: "manual",
|
|
39
|
+
files: { "index.html": "same" },
|
|
40
|
+
readFile: async () => "same",
|
|
41
|
+
writeFile,
|
|
42
|
+
recordEdit,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(changedPaths).toEqual([]);
|
|
46
|
+
expect(writeFile).not.toHaveBeenCalled();
|
|
47
|
+
expect(recordEdit).not.toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("rolls back files already written when a later file write fails", async () => {
|
|
51
|
+
const reads: Record<string, string> = {
|
|
52
|
+
"index.html": "index-before",
|
|
53
|
+
"scene.html": "scene-before",
|
|
54
|
+
};
|
|
55
|
+
const writes: Array<[string, string]> = [];
|
|
56
|
+
const recordEdit = vi.fn();
|
|
57
|
+
|
|
58
|
+
await expect(
|
|
59
|
+
saveProjectFilesWithHistory({
|
|
60
|
+
projectId: "project-1",
|
|
61
|
+
label: "Move layer",
|
|
62
|
+
kind: "manual",
|
|
63
|
+
files: {
|
|
64
|
+
"index.html": "index-after",
|
|
65
|
+
"scene.html": "scene-after",
|
|
66
|
+
},
|
|
67
|
+
readFile: async (path) => reads[path],
|
|
68
|
+
writeFile: async (path, content) => {
|
|
69
|
+
writes.push([path, content]);
|
|
70
|
+
if (path === "scene.html") {
|
|
71
|
+
throw new Error("disk full");
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
recordEdit,
|
|
75
|
+
}),
|
|
76
|
+
).rejects.toThrow("disk full");
|
|
77
|
+
|
|
78
|
+
expect(writes).toEqual([
|
|
79
|
+
["index.html", "index-after"],
|
|
80
|
+
["scene.html", "scene-after"],
|
|
81
|
+
["index.html", "index-before"],
|
|
82
|
+
]);
|
|
83
|
+
expect(recordEdit).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("rolls back written files when the injected history recorder throws", async () => {
|
|
87
|
+
const reads: Record<string, string> = {
|
|
88
|
+
"index.html": "index-before",
|
|
89
|
+
"scene.html": "scene-before",
|
|
90
|
+
};
|
|
91
|
+
const writes: Array<[string, string]> = [];
|
|
92
|
+
|
|
93
|
+
await expect(
|
|
94
|
+
saveProjectFilesWithHistory({
|
|
95
|
+
projectId: "project-1",
|
|
96
|
+
label: "Move layer",
|
|
97
|
+
kind: "manual",
|
|
98
|
+
files: {
|
|
99
|
+
"index.html": "index-after",
|
|
100
|
+
"scene.html": "scene-after",
|
|
101
|
+
},
|
|
102
|
+
readFile: async (path) => reads[path],
|
|
103
|
+
writeFile: async (path, content) => {
|
|
104
|
+
writes.push([path, content]);
|
|
105
|
+
},
|
|
106
|
+
recordEdit: async () => {
|
|
107
|
+
throw new Error("history unavailable");
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
).rejects.toThrow("history unavailable");
|
|
111
|
+
|
|
112
|
+
expect(writes).toEqual([
|
|
113
|
+
["index.html", "index-after"],
|
|
114
|
+
["scene.html", "scene-after"],
|
|
115
|
+
["scene.html", "scene-before"],
|
|
116
|
+
["index.html", "index-before"],
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("reports rollback failure with the original write failure", async () => {
|
|
121
|
+
const reads: Record<string, string> = {
|
|
122
|
+
"index.html": "index-before",
|
|
123
|
+
"scene.html": "scene-before",
|
|
124
|
+
};
|
|
125
|
+
const writes: Array<[string, string]> = [];
|
|
126
|
+
|
|
127
|
+
await expect(
|
|
128
|
+
saveProjectFilesWithHistory({
|
|
129
|
+
projectId: "project-1",
|
|
130
|
+
label: "Move layer",
|
|
131
|
+
kind: "manual",
|
|
132
|
+
files: {
|
|
133
|
+
"index.html": "index-after",
|
|
134
|
+
"scene.html": "scene-after",
|
|
135
|
+
},
|
|
136
|
+
readFile: async (path) => reads[path],
|
|
137
|
+
writeFile: async (path, content) => {
|
|
138
|
+
writes.push([path, content]);
|
|
139
|
+
if (path === "scene.html" && content === "scene-after") {
|
|
140
|
+
throw new Error("write denied");
|
|
141
|
+
}
|
|
142
|
+
if (path === "index.html" && content === "index-before") {
|
|
143
|
+
throw new Error("rollback denied");
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
recordEdit: vi.fn(),
|
|
147
|
+
}),
|
|
148
|
+
).rejects.toThrow("rollback did not complete");
|
|
149
|
+
|
|
150
|
+
expect(writes).toEqual([
|
|
151
|
+
["index.html", "index-after"],
|
|
152
|
+
["scene.html", "scene-after"],
|
|
153
|
+
["index.html", "index-before"],
|
|
154
|
+
]);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { EditHistoryKind } from "./editHistory";
|
|
2
|
+
|
|
3
|
+
interface SaveProjectFilesWithHistoryInput {
|
|
4
|
+
projectId: string;
|
|
5
|
+
label: string;
|
|
6
|
+
kind: EditHistoryKind;
|
|
7
|
+
coalesceKey?: string;
|
|
8
|
+
files: Record<string, string>;
|
|
9
|
+
readFile: (path: string) => Promise<string>;
|
|
10
|
+
writeFile: (path: string, content: string) => Promise<void>;
|
|
11
|
+
recordEdit: (entry: {
|
|
12
|
+
label: string;
|
|
13
|
+
kind: EditHistoryKind;
|
|
14
|
+
coalesceKey?: string;
|
|
15
|
+
files: Record<string, { before: string; after: string }>;
|
|
16
|
+
}) => Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function saveProjectFilesWithHistory({
|
|
20
|
+
label,
|
|
21
|
+
kind,
|
|
22
|
+
coalesceKey,
|
|
23
|
+
files,
|
|
24
|
+
readFile,
|
|
25
|
+
writeFile,
|
|
26
|
+
recordEdit,
|
|
27
|
+
}: SaveProjectFilesWithHistoryInput): Promise<string[]> {
|
|
28
|
+
const snapshots: Record<string, { before: string; after: string }> = {};
|
|
29
|
+
for (const [path, after] of Object.entries(files)) {
|
|
30
|
+
const before = await readFile(path);
|
|
31
|
+
if (before !== after) {
|
|
32
|
+
snapshots[path] = { before, after };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const changedPaths = Object.keys(snapshots);
|
|
37
|
+
if (changedPaths.length === 0) return [];
|
|
38
|
+
|
|
39
|
+
const writtenPaths: string[] = [];
|
|
40
|
+
try {
|
|
41
|
+
for (const path of changedPaths) {
|
|
42
|
+
await writeFile(path, snapshots[path].after);
|
|
43
|
+
writtenPaths.push(path);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await recordEdit({ label, kind, coalesceKey, files: snapshots });
|
|
47
|
+
} catch (error) {
|
|
48
|
+
try {
|
|
49
|
+
for (const path of writtenPaths.reverse()) {
|
|
50
|
+
await writeFile(path, snapshots[path].before);
|
|
51
|
+
}
|
|
52
|
+
} catch (rollbackError) {
|
|
53
|
+
throw new AggregateError(
|
|
54
|
+
[error, rollbackError],
|
|
55
|
+
"Failed to save project files and rollback did not complete",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
return changedPaths;
|
|
61
|
+
}
|
|
@@ -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): {
|