@hyperframes/studio 0.5.0-alpha.10 → 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 +362 -144
- package/src/components/nle/NLELayout.tsx +43 -8
- 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/PlayerControls.tsx +3 -44
- package/src/player/components/timelineTheme.ts +3 -3
- package/src/player/hooks/useTimelinePlayer.ts +2 -2
- package/src/player/lib/time.test.ts +11 -1
- package/src/player/lib/time.ts +6 -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/dist/assets/index-DKaNgV2Z.css +0 -1
- package/dist/assets/index-peNJzL-4.js +0 -105
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
buildEditHistoryEntry,
|
|
4
|
+
createEmptyEditHistory,
|
|
5
|
+
hashEditHistoryContent,
|
|
6
|
+
pushEditHistoryEntry,
|
|
7
|
+
redoEditHistory,
|
|
8
|
+
undoEditHistory,
|
|
9
|
+
type BuildEditHistoryEntryInput,
|
|
10
|
+
type EditHistoryKind,
|
|
11
|
+
type EditHistoryState,
|
|
12
|
+
} from "../utils/editHistory";
|
|
13
|
+
import {
|
|
14
|
+
createIndexedDbEditHistoryStorage,
|
|
15
|
+
loadEditHistoryState,
|
|
16
|
+
saveEditHistoryState,
|
|
17
|
+
type EditHistoryStorageAdapter,
|
|
18
|
+
} from "../utils/editHistoryStorage";
|
|
19
|
+
|
|
20
|
+
interface RecordEditInput {
|
|
21
|
+
label: string;
|
|
22
|
+
kind: EditHistoryKind;
|
|
23
|
+
coalesceKey?: string;
|
|
24
|
+
files: BuildEditHistoryEntryInput["files"];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ApplyCallbacks {
|
|
28
|
+
readFile: (path: string) => Promise<string>;
|
|
29
|
+
writeFile: (path: string, content: string) => Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface UsePersistentEditHistoryOptions {
|
|
33
|
+
projectId: string | null;
|
|
34
|
+
storage?: EditHistoryStorageAdapter;
|
|
35
|
+
now?: () => number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ApplyResult {
|
|
39
|
+
ok: boolean;
|
|
40
|
+
reason?: "empty" | "content-mismatch";
|
|
41
|
+
label?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface PersistentEditHistoryStoreOptions {
|
|
45
|
+
projectId: string;
|
|
46
|
+
storage: EditHistoryStorageAdapter;
|
|
47
|
+
initialState: EditHistoryState;
|
|
48
|
+
now?: () => number;
|
|
49
|
+
onChange: (state: EditHistoryState) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type EditHistoryMutation<T> = (state: EditHistoryState) => Promise<{
|
|
53
|
+
state: EditHistoryState;
|
|
54
|
+
result: T;
|
|
55
|
+
}>;
|
|
56
|
+
|
|
57
|
+
function createEntryId(now: number): string {
|
|
58
|
+
return `edit-${now.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function snapshotEditHistoryState(state: EditHistoryState) {
|
|
62
|
+
const undoEntry = state.undo[state.undo.length - 1] ?? null;
|
|
63
|
+
const redoEntry = state.redo[state.redo.length - 1] ?? null;
|
|
64
|
+
return {
|
|
65
|
+
canUndo: Boolean(undoEntry),
|
|
66
|
+
canRedo: Boolean(redoEntry),
|
|
67
|
+
undoLabel: undoEntry?.label ?? null,
|
|
68
|
+
redoLabel: redoEntry?.label ?? null,
|
|
69
|
+
undoPaths: undoEntry ? Object.keys(undoEntry.files) : [],
|
|
70
|
+
redoPaths: redoEntry ? Object.keys(redoEntry.files) : [],
|
|
71
|
+
state,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readCurrentFileHashes(
|
|
76
|
+
paths: string[],
|
|
77
|
+
readFile: (path: string) => Promise<string>,
|
|
78
|
+
): Promise<{
|
|
79
|
+
currentFiles: Record<string, string>;
|
|
80
|
+
currentHashes: Record<string, string>;
|
|
81
|
+
}> {
|
|
82
|
+
const currentFiles: Record<string, string> = {};
|
|
83
|
+
const currentHashes: Record<string, string> = {};
|
|
84
|
+
for (const path of paths) {
|
|
85
|
+
const content = await readFile(path);
|
|
86
|
+
currentFiles[path] = content;
|
|
87
|
+
currentHashes[path] = hashEditHistoryContent(content);
|
|
88
|
+
}
|
|
89
|
+
return { currentFiles, currentHashes };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function writeFilesWithRollback({
|
|
93
|
+
files,
|
|
94
|
+
rollbackFiles,
|
|
95
|
+
writeFile,
|
|
96
|
+
}: {
|
|
97
|
+
files: Record<string, string>;
|
|
98
|
+
rollbackFiles: Record<string, string>;
|
|
99
|
+
writeFile: (path: string, content: string) => Promise<void>;
|
|
100
|
+
}): Promise<void> {
|
|
101
|
+
const writtenPaths: string[] = [];
|
|
102
|
+
try {
|
|
103
|
+
for (const [path, content] of Object.entries(files)) {
|
|
104
|
+
await writeFile(path, content);
|
|
105
|
+
writtenPaths.push(path);
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
try {
|
|
109
|
+
for (const path of writtenPaths.reverse()) {
|
|
110
|
+
await writeFile(path, rollbackFiles[path]);
|
|
111
|
+
}
|
|
112
|
+
} catch (rollbackError) {
|
|
113
|
+
throw new AggregateError(
|
|
114
|
+
[error, rollbackError],
|
|
115
|
+
"Failed to apply edit history and rollback did not complete",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createPersistentEditHistoryStore({
|
|
123
|
+
projectId,
|
|
124
|
+
storage,
|
|
125
|
+
initialState,
|
|
126
|
+
now = Date.now,
|
|
127
|
+
onChange,
|
|
128
|
+
}: PersistentEditHistoryStoreOptions) {
|
|
129
|
+
let state = initialState;
|
|
130
|
+
let queue = Promise.resolve();
|
|
131
|
+
|
|
132
|
+
const save = async (nextState: EditHistoryState) => {
|
|
133
|
+
state = nextState;
|
|
134
|
+
onChange(nextState);
|
|
135
|
+
try {
|
|
136
|
+
await saveEditHistoryState(storage, projectId, nextState);
|
|
137
|
+
} catch {
|
|
138
|
+
// Keep in-memory history usable when IndexedDB is unavailable.
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const mutate = async <T>(mutation: EditHistoryMutation<T>): Promise<T> => {
|
|
143
|
+
const run = queue.then(async () => {
|
|
144
|
+
const { state: nextState, result } = await mutation(state);
|
|
145
|
+
if (nextState !== state) await save(nextState);
|
|
146
|
+
return result;
|
|
147
|
+
});
|
|
148
|
+
queue = run.then(
|
|
149
|
+
() => undefined,
|
|
150
|
+
() => undefined,
|
|
151
|
+
);
|
|
152
|
+
return run;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
snapshot: () => snapshotEditHistoryState(state),
|
|
157
|
+
async recordEdit(input: RecordEditInput) {
|
|
158
|
+
await mutate<void>(async (currentState) => {
|
|
159
|
+
const timestamp = now();
|
|
160
|
+
const entry = buildEditHistoryEntry({
|
|
161
|
+
...input,
|
|
162
|
+
id: createEntryId(timestamp),
|
|
163
|
+
projectId,
|
|
164
|
+
now: timestamp,
|
|
165
|
+
});
|
|
166
|
+
return {
|
|
167
|
+
state: pushEditHistoryEntry(currentState, entry),
|
|
168
|
+
result: undefined,
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
async undo(callbacks: ApplyCallbacks): Promise<ApplyResult> {
|
|
173
|
+
return mutate<ApplyResult>(async (currentState) => {
|
|
174
|
+
const entry = currentState.undo[currentState.undo.length - 1];
|
|
175
|
+
if (!entry) {
|
|
176
|
+
return {
|
|
177
|
+
state: currentState,
|
|
178
|
+
result: { ok: false, reason: "empty" },
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const { currentFiles, currentHashes } = await readCurrentFileHashes(
|
|
182
|
+
Object.keys(entry.files),
|
|
183
|
+
callbacks.readFile,
|
|
184
|
+
);
|
|
185
|
+
const result = undoEditHistory(currentState, currentHashes, now());
|
|
186
|
+
if (!result.ok) {
|
|
187
|
+
return {
|
|
188
|
+
state: currentState,
|
|
189
|
+
result: { ok: false, reason: result.reason },
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
await writeFilesWithRollback({
|
|
193
|
+
files: result.filesToWrite,
|
|
194
|
+
rollbackFiles: currentFiles,
|
|
195
|
+
writeFile: callbacks.writeFile,
|
|
196
|
+
});
|
|
197
|
+
return {
|
|
198
|
+
state: result.state,
|
|
199
|
+
result: { ok: true, label: result.entry.label },
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
},
|
|
203
|
+
async redo(callbacks: ApplyCallbacks): Promise<ApplyResult> {
|
|
204
|
+
return mutate<ApplyResult>(async (currentState) => {
|
|
205
|
+
const entry = currentState.redo[currentState.redo.length - 1];
|
|
206
|
+
if (!entry) {
|
|
207
|
+
return {
|
|
208
|
+
state: currentState,
|
|
209
|
+
result: { ok: false, reason: "empty" },
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const { currentFiles, currentHashes } = await readCurrentFileHashes(
|
|
213
|
+
Object.keys(entry.files),
|
|
214
|
+
callbacks.readFile,
|
|
215
|
+
);
|
|
216
|
+
const result = redoEditHistory(currentState, currentHashes, now());
|
|
217
|
+
if (!result.ok) {
|
|
218
|
+
return {
|
|
219
|
+
state: currentState,
|
|
220
|
+
result: { ok: false, reason: result.reason },
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
await writeFilesWithRollback({
|
|
224
|
+
files: result.filesToWrite,
|
|
225
|
+
rollbackFiles: currentFiles,
|
|
226
|
+
writeFile: callbacks.writeFile,
|
|
227
|
+
});
|
|
228
|
+
return {
|
|
229
|
+
state: result.state,
|
|
230
|
+
result: { ok: true, label: result.entry.label },
|
|
231
|
+
};
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function createPersistentEditHistoryController({
|
|
238
|
+
projectId,
|
|
239
|
+
storage,
|
|
240
|
+
now = Date.now,
|
|
241
|
+
onChange,
|
|
242
|
+
}: {
|
|
243
|
+
projectId: string;
|
|
244
|
+
storage: EditHistoryStorageAdapter;
|
|
245
|
+
now?: () => number;
|
|
246
|
+
onChange: (state: EditHistoryState) => void;
|
|
247
|
+
}) {
|
|
248
|
+
let state = await loadEditHistoryState(storage, projectId);
|
|
249
|
+
const store = createPersistentEditHistoryStore({
|
|
250
|
+
projectId,
|
|
251
|
+
storage,
|
|
252
|
+
initialState: state,
|
|
253
|
+
now,
|
|
254
|
+
onChange: (nextState) => {
|
|
255
|
+
state = nextState;
|
|
256
|
+
onChange(nextState);
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
return store;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function usePersistentEditHistory(options: UsePersistentEditHistoryOptions) {
|
|
264
|
+
const storage = useMemo(
|
|
265
|
+
() => options.storage ?? createIndexedDbEditHistoryStorage(),
|
|
266
|
+
[options.storage],
|
|
267
|
+
);
|
|
268
|
+
const now = options.now ?? Date.now;
|
|
269
|
+
const [state, setState] = useState<EditHistoryState>(() => createEmptyEditHistory());
|
|
270
|
+
const [loaded, setLoaded] = useState(false);
|
|
271
|
+
const projectId = options.projectId;
|
|
272
|
+
const storeRef = useRef<ReturnType<typeof createPersistentEditHistoryStore> | null>(null);
|
|
273
|
+
|
|
274
|
+
useEffect(() => {
|
|
275
|
+
let cancelled = false;
|
|
276
|
+
const emptyState = createEmptyEditHistory();
|
|
277
|
+
storeRef.current = null;
|
|
278
|
+
setState(emptyState);
|
|
279
|
+
setLoaded(false);
|
|
280
|
+
if (!projectId) {
|
|
281
|
+
setLoaded(true);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
loadEditHistoryState(storage, projectId)
|
|
286
|
+
.then((loadedState) => {
|
|
287
|
+
if (cancelled) return;
|
|
288
|
+
storeRef.current = createPersistentEditHistoryStore({
|
|
289
|
+
projectId,
|
|
290
|
+
storage,
|
|
291
|
+
initialState: loadedState,
|
|
292
|
+
now,
|
|
293
|
+
onChange: setState,
|
|
294
|
+
});
|
|
295
|
+
setState(loadedState);
|
|
296
|
+
})
|
|
297
|
+
.catch(() => {
|
|
298
|
+
if (cancelled) return;
|
|
299
|
+
storeRef.current = createPersistentEditHistoryStore({
|
|
300
|
+
projectId,
|
|
301
|
+
storage,
|
|
302
|
+
initialState: emptyState,
|
|
303
|
+
now,
|
|
304
|
+
onChange: setState,
|
|
305
|
+
});
|
|
306
|
+
setState(emptyState);
|
|
307
|
+
})
|
|
308
|
+
.finally(() => {
|
|
309
|
+
if (!cancelled) setLoaded(true);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
return () => {
|
|
313
|
+
cancelled = true;
|
|
314
|
+
};
|
|
315
|
+
}, [now, projectId, storage]);
|
|
316
|
+
|
|
317
|
+
const recordEdit = useCallback(async (input: RecordEditInput) => {
|
|
318
|
+
await storeRef.current?.recordEdit(input);
|
|
319
|
+
}, []);
|
|
320
|
+
|
|
321
|
+
const undo = useCallback(async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
|
|
322
|
+
return storeRef.current?.undo(callbacks) ?? { ok: false, reason: "empty" };
|
|
323
|
+
}, []);
|
|
324
|
+
|
|
325
|
+
const redo = useCallback(async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
|
|
326
|
+
return storeRef.current?.redo(callbacks) ?? { ok: false, reason: "empty" };
|
|
327
|
+
}, []);
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
loaded,
|
|
331
|
+
...snapshotEditHistoryState(state),
|
|
332
|
+
recordEdit,
|
|
333
|
+
undo,
|
|
334
|
+
redo,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
@@ -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);
|
|
@@ -1,10 +1,6 @@
|
|
|
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 { formatFrameTime, frameToSeconds, 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;
|
|
@@ -30,15 +26,11 @@ export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth:
|
|
|
30
26
|
interface PlayerControlsProps {
|
|
31
27
|
onTogglePlay: () => void;
|
|
32
28
|
onSeek: (time: number) => void;
|
|
33
|
-
timelineVisible?: boolean;
|
|
34
|
-
onToggleTimeline?: () => void;
|
|
35
29
|
}
|
|
36
30
|
|
|
37
31
|
export const PlayerControls = memo(function PlayerControls({
|
|
38
32
|
onTogglePlay,
|
|
39
33
|
onSeek,
|
|
40
|
-
timelineVisible,
|
|
41
|
-
onToggleTimeline,
|
|
42
34
|
}: PlayerControlsProps) {
|
|
43
35
|
// Subscribe to only the fields we render — each selector prevents cascading re-renders
|
|
44
36
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
|
@@ -216,10 +208,10 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
216
208
|
const step = e.shiftKey ? 10 : 1;
|
|
217
209
|
if (e.key === "ArrowLeft") {
|
|
218
210
|
e.preventDefault();
|
|
219
|
-
onSeek(
|
|
211
|
+
onSeek(stepFrameTime(currentTimeRef.current, -step));
|
|
220
212
|
} else if (e.key === "ArrowRight") {
|
|
221
213
|
e.preventDefault();
|
|
222
|
-
onSeek(Math.min(duration, currentTimeRef.current
|
|
214
|
+
onSeek(Math.min(duration, stepFrameTime(currentTimeRef.current, step)));
|
|
223
215
|
}
|
|
224
216
|
},
|
|
225
217
|
[timelineReady, duration, onSeek],
|
|
@@ -437,39 +429,6 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
437
429
|
</span>
|
|
438
430
|
))}
|
|
439
431
|
</div>
|
|
440
|
-
|
|
441
|
-
{/* Timeline toggle */}
|
|
442
|
-
{onToggleTimeline !== undefined && (
|
|
443
|
-
<button
|
|
444
|
-
type="button"
|
|
445
|
-
onClick={onToggleTimeline}
|
|
446
|
-
className={`h-7 flex items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-medium transition-colors ${
|
|
447
|
-
timelineVisible
|
|
448
|
-
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
449
|
-
: "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
|
|
450
|
-
}`}
|
|
451
|
-
title={getTimelineToggleTitle(Boolean(timelineVisible))}
|
|
452
|
-
aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
|
|
453
|
-
>
|
|
454
|
-
<svg
|
|
455
|
-
width="13"
|
|
456
|
-
height="13"
|
|
457
|
-
viewBox="0 0 24 24"
|
|
458
|
-
fill="none"
|
|
459
|
-
stroke="currentColor"
|
|
460
|
-
strokeWidth="2"
|
|
461
|
-
strokeLinecap="round"
|
|
462
|
-
>
|
|
463
|
-
<rect x="3" y="13" width="18" height="8" rx="1" />
|
|
464
|
-
<line x1="3" y1="9" x2="21" y2="9" />
|
|
465
|
-
<line x1="3" y1="5" x2="21" y2="5" />
|
|
466
|
-
</svg>
|
|
467
|
-
<span>Timeline</span>
|
|
468
|
-
<span className="hidden md:inline rounded bg-black/20 px-1 py-0.5 text-[9px] font-mono opacity-70">
|
|
469
|
-
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
|
|
470
|
-
</span>
|
|
471
|
-
</button>
|
|
472
|
-
)}
|
|
473
432
|
</div>
|
|
474
433
|
);
|
|
475
434
|
});
|
|
@@ -63,12 +63,12 @@ const TRACK_STYLES: Record<string, TimelineTrackStyle> = {
|
|
|
63
63
|
const DEFAULT_TRACK_STYLE: TimelineTrackStyle = createTrackStyle();
|
|
64
64
|
|
|
65
65
|
export const defaultTimelineTheme: TimelineTheme = {
|
|
66
|
-
shellBackground: "#
|
|
66
|
+
shellBackground: "#0A0A0B",
|
|
67
67
|
shellBorder: "rgba(255,255,255,0.05)",
|
|
68
68
|
rulerBorder: "rgba(255,255,255,0.045)",
|
|
69
|
-
rowBackground: "#
|
|
69
|
+
rowBackground: "#0A0A0B",
|
|
70
70
|
rowBorder: "rgba(255,255,255,0.05)",
|
|
71
|
-
gutterBackground: "#
|
|
71
|
+
gutterBackground: "#0A0A0B",
|
|
72
72
|
gutterBorder: "rgba(255,255,255,0.05)",
|
|
73
73
|
textPrimary: "#E8EDF5",
|
|
74
74
|
textSecondary: "#8391A8",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useRef, useCallback } from "react";
|
|
2
2
|
import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore";
|
|
3
3
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
|
-
import {
|
|
4
|
+
import { stepFrameTime, STUDIO_PREVIEW_FPS } from "../lib/time";
|
|
5
5
|
import { useCaptionStore } from "../../captions/store";
|
|
6
6
|
|
|
7
7
|
interface PlaybackAdapter {
|
|
@@ -743,7 +743,7 @@ export function useTimelinePlayer() {
|
|
|
743
743
|
(deltaFrames: number) => {
|
|
744
744
|
const adapter = getAdapter();
|
|
745
745
|
const currentTime = adapter?.getTime() ?? usePlayerStore.getState().currentTime;
|
|
746
|
-
seek(currentTime
|
|
746
|
+
seek(stepFrameTime(currentTime, deltaFrames, STUDIO_PREVIEW_FPS));
|
|
747
747
|
},
|
|
748
748
|
[getAdapter, seek],
|
|
749
749
|
);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { formatFrameTime, frameToSeconds, secondsToFrame, formatTime } from "./time";
|
|
2
|
+
import { formatFrameTime, frameToSeconds, secondsToFrame, stepFrameTime, formatTime } from "./time";
|
|
3
3
|
|
|
4
4
|
describe("formatTime", () => {
|
|
5
5
|
it("formats zero seconds", () => {
|
|
@@ -72,4 +72,14 @@ describe("frame helpers", () => {
|
|
|
72
72
|
it("formats current and total frame display", () => {
|
|
73
73
|
expect(formatFrameTime(1, 5)).toBe("30f / 150f");
|
|
74
74
|
});
|
|
75
|
+
|
|
76
|
+
it("steps from a truncated runtime time by integer frame index", () => {
|
|
77
|
+
expect(stepFrameTime(0.0333333, 1)).toBe(2 / 30);
|
|
78
|
+
expect(stepFrameTime(0.0666666, 1)).toBe(3 / 30);
|
|
79
|
+
expect(stepFrameTime(0.0666666, -1)).toBe(1 / 30);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("clamps frame stepping at zero", () => {
|
|
83
|
+
expect(stepFrameTime(0, -1)).toBe(0);
|
|
84
|
+
});
|
|
75
85
|
});
|
package/src/player/lib/time.ts
CHANGED
|
@@ -19,6 +19,12 @@ export function frameToSeconds(frame: number, fps = STUDIO_PREVIEW_FPS): number
|
|
|
19
19
|
return frame / fps;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export function stepFrameTime(time: number, deltaFrames: number, fps = STUDIO_PREVIEW_FPS): number {
|
|
23
|
+
const currentFrame = secondsToFrame(time, fps);
|
|
24
|
+
const nextFrame = Math.max(0, currentFrame + deltaFrames);
|
|
25
|
+
return frameToSeconds(nextFrame, fps);
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
export function formatFrameTime(time: number, duration: number, fps = STUDIO_PREVIEW_FPS): string {
|
|
23
29
|
const currentFrame = secondsToFrame(time, fps);
|
|
24
30
|
const totalFrames = secondsToFrame(duration, fps);
|