@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.
@@ -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(Math.max(0, currentTimeRef.current - frameToSeconds(step)));
211
+ onSeek(stepFrameTime(currentTimeRef.current, -step));
220
212
  } else if (e.key === "ArrowRight") {
221
213
  e.preventDefault();
222
- onSeek(Math.min(duration, currentTimeRef.current + frameToSeconds(step)));
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: "#0A0E15",
66
+ shellBackground: "#0A0A0B",
67
67
  shellBorder: "rgba(255,255,255,0.05)",
68
68
  rulerBorder: "rgba(255,255,255,0.045)",
69
- rowBackground: "#0A0E15",
69
+ rowBackground: "#0A0A0B",
70
70
  rowBorder: "rgba(255,255,255,0.05)",
71
- gutterBackground: "#0D121B",
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 { frameToSeconds, STUDIO_PREVIEW_FPS } from "../lib/time";
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 + frameToSeconds(deltaFrames, STUDIO_PREVIEW_FPS));
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
  });
@@ -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);