@hyperframes/studio 0.8.53-desktop.0 → 0.8.54

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.
Files changed (42) hide show
  1. package/dist/assets/{hyperframes-player-PTEnAHhg.js → hyperframes-player-Cy_AwUa8.js} +1 -1
  2. package/dist/assets/{index-BjslOS9g.js → index-CTa-6Kso.js} +1 -1
  3. package/dist/assets/{index-CNyviK_l.js → index-DPUJoO8n.js} +203 -203
  4. package/dist/assets/{index--myXp8ui.js → index-DkAzKuPZ.js} +1 -1
  5. package/dist/assets/{index-BnXZuIJD.css → index-DrHQAuGO.css} +1 -1
  6. package/dist/index.d.ts +43 -8
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7896 -7717
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -0
  11. package/package.json +8 -7
  12. package/src/components/StudioHeader.tsx +1 -1
  13. package/src/components/nle/TimelinePane.tsx +4 -1
  14. package/src/components/renders/RenderQueue.test.tsx +133 -25
  15. package/src/components/renders/RenderQueue.tsx +99 -92
  16. package/src/components/renders/RenderQueueItem.tsx +69 -64
  17. package/src/components/ui/Select.tsx +4 -0
  18. package/src/headerExports.test.tsx +23 -0
  19. package/src/hooks/timelineAudioGroupCreate.ts +17 -1
  20. package/src/hooks/timelineAudioGroupVolume.ts +35 -2
  21. package/src/hooks/timelineElementFxAttribute.ts +34 -2
  22. package/src/hooks/useRightPanelIntents.ts +2 -1
  23. package/src/hooks/useTimelineAssetDropOps.test.tsx +62 -4
  24. package/src/hooks/useTimelineAssetDropOps.ts +40 -3
  25. package/src/hooks/useTimelineEditing.test.tsx +160 -2
  26. package/src/hooks/useTimelineEditing.ts +76 -39
  27. package/src/index.ts +5 -2
  28. package/src/main.tsx +26 -0
  29. package/src/player/components/Timeline.tsx +6 -6
  30. package/src/player/components/TimelineTrackHeader.test.tsx +5 -26
  31. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  32. package/src/player/components/TimelineTypes.ts +3 -0
  33. package/src/player/components/timelineTheme.test.ts +4 -4
  34. package/src/player/components/timelineTheme.ts +30 -30
  35. package/src/player/components/usePublishRangeSelection.test.tsx +60 -0
  36. package/src/player/components/usePublishRangeSelection.ts +42 -0
  37. package/src/player/index.ts +1 -0
  38. package/src/player/store/playerStore.ts +8 -51
  39. package/src/player/store/rangeSelectionSlice.ts +13 -0
  40. package/src/player/store/timelineResetState.ts +51 -0
  41. package/src/styles/hex-baseline.json +2 -2
  42. package/src/styles/theme.css +0 -27
@@ -38,6 +38,20 @@ function fileDropPlacement(
38
38
  return index === 0 ? { ...dropped, ...next } : { ...next, track: landedTrack ?? next.track };
39
39
  }
40
40
 
41
+ function timelineDropTarget(
42
+ sourceFile: string,
43
+ placement: Pick<TimelineElement, "start" | "track">,
44
+ ): TimelineElement {
45
+ return {
46
+ id: "timeline-drop",
47
+ tag: "div",
48
+ start: placement.start,
49
+ duration: 0,
50
+ track: placement.track,
51
+ sourceFile,
52
+ };
53
+ }
54
+
41
55
  interface UseTimelineAssetDropOpsOptions {
42
56
  projectIdRef: MutableRefObject<string | null>;
43
57
  activeCompPath: string | null;
@@ -50,6 +64,7 @@ interface UseTimelineAssetDropOpsOptions {
50
64
  isRecordingRef?: RefObject<boolean>;
51
65
  forceReloadSdkSession?: () => void;
52
66
  observeProjectFileVersion?: (path: string, version: string | null) => void;
67
+ checkEditable?: (targets: readonly TimelineElement[]) => boolean;
53
68
  }
54
69
 
55
70
  export function useTimelineAssetDropOps({
@@ -64,6 +79,7 @@ export function useTimelineAssetDropOps({
64
79
  isRecordingRef,
65
80
  forceReloadSdkSession,
66
81
  observeProjectFileVersion,
82
+ checkEditable,
67
83
  }: UseTimelineAssetDropOpsOptions) {
68
84
  // fallow-ignore-next-line complexity
69
85
  const dropAssetAt = useCallback(
@@ -77,6 +93,10 @@ export function useTimelineAssetDropOps({
77
93
  showToast("Cannot edit timeline while recording", "error");
78
94
  return undefined;
79
95
  }
96
+ const targetPath = activeCompPath || "index.html";
97
+ if (checkEditable && !checkEditable([timelineDropTarget(targetPath, placement)])) {
98
+ return undefined;
99
+ }
80
100
  const pid = projectIdRef.current;
81
101
  if (!pid) throw new Error("No active project");
82
102
 
@@ -86,7 +106,6 @@ export function useTimelineAssetDropOps({
86
106
  return undefined;
87
107
  }
88
108
 
89
- const targetPath = activeCompPath || "index.html";
90
109
  try {
91
110
  const originalContent = await readFileContent(pid, targetPath);
92
111
 
@@ -172,6 +191,7 @@ export function useTimelineAssetDropOps({
172
191
  reloadPreview,
173
192
  isRecordingRef,
174
193
  forceReloadSdkSession,
194
+ checkEditable,
175
195
  ],
176
196
  );
177
197
 
@@ -190,6 +210,11 @@ export function useTimelineAssetDropOps({
190
210
  showToast("Cannot edit timeline while recording", "error");
191
211
  return;
192
212
  }
213
+ const targetPath = activeCompPath || "index.html";
214
+ const initialPlacement = placement ?? { start: 0, track: 0 };
215
+ if (checkEditable && !checkEditable([timelineDropTarget(targetPath, initialPlacement)])) {
216
+ return;
217
+ }
193
218
  const pid = projectIdRef.current;
194
219
  if (!pid) return;
195
220
  const uploaded = await uploadProjectFiles(files);
@@ -215,7 +240,15 @@ export function useTimelineAssetDropOps({
215
240
  if (index === 0) landedTrack = track;
216
241
  }
217
242
  },
218
- [dropAssetAt, projectIdRef, uploadProjectFiles, isRecordingRef, showToast],
243
+ [
244
+ activeCompPath,
245
+ checkEditable,
246
+ dropAssetAt,
247
+ projectIdRef,
248
+ uploadProjectFiles,
249
+ isRecordingRef,
250
+ showToast,
251
+ ],
219
252
  );
220
253
 
221
254
  const handleTimelineCompositionDrop = useCallback(
@@ -224,9 +257,12 @@ export function useTimelineAssetDropOps({
224
257
  showToast("Cannot edit timeline while recording", "error");
225
258
  return;
226
259
  }
260
+ const targetPath = activeCompPath || "index.html";
261
+ if (checkEditable && !checkEditable([timelineDropTarget(targetPath, placement)])) {
262
+ return;
263
+ }
227
264
  const pid = projectIdRef.current;
228
265
  if (!pid) throw new Error("No active project");
229
- const targetPath = activeCompPath || "index.html";
230
266
  try {
231
267
  await commitTimelineCompositionInsertion({
232
268
  projectId: pid,
@@ -251,6 +287,7 @@ export function useTimelineAssetDropOps({
251
287
  },
252
288
  [
253
289
  activeCompPath,
290
+ checkEditable,
254
291
  forceReloadSdkSession,
255
292
  isRecordingRef,
256
293
  observeProjectFileVersion,
@@ -1,7 +1,7 @@
1
1
  // fallow-ignore-file code-duplication
2
2
  // @vitest-environment happy-dom
3
3
 
4
- import React, { act, useRef } from "react";
4
+ import React, { act, useRef, useState } from "react";
5
5
  import { createRoot } from "react-dom/client";
6
6
  import { openComposition } from "@hyperframes/sdk";
7
7
  import { afterEach, describe, expect, it, vi } from "vitest";
@@ -130,6 +130,8 @@ function renderTimelineEditingHook(input: {
130
130
  groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"];
131
131
  del: ReturnType<typeof useTimelineEditing>["handleTimelineElementDelete"];
132
132
  elementsDelete: ReturnType<typeof useTimelineEditing>["handleTimelineElementsDelete"];
133
+ handleAutoGroupCarveSources: ReturnType<typeof useTimelineEditing>["handleAutoGroupCarveSources"];
134
+ setAudioGroupAttribute: ReturnType<typeof useTimelineEditing>["setAudioGroupAttribute"];
133
135
  unmount: () => void;
134
136
  } {
135
137
  let move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"] | null = null;
@@ -139,6 +141,12 @@ function renderTimelineEditingHook(input: {
139
141
  let del: ReturnType<typeof useTimelineEditing>["handleTimelineElementDelete"] | null = null;
140
142
  let elementsDelete: ReturnType<typeof useTimelineEditing>["handleTimelineElementsDelete"] | null =
141
143
  null;
144
+ let handleAutoGroupCarveSources:
145
+ | ReturnType<typeof useTimelineEditing>["handleAutoGroupCarveSources"]
146
+ | null = null;
147
+ let setAudioGroupAttribute:
148
+ | ReturnType<typeof useTimelineEditing>["setAudioGroupAttribute"]
149
+ | null = null;
142
150
 
143
151
  function Harness() {
144
152
  const commitRef = useRef(input.onZIndexCommit);
@@ -167,6 +175,8 @@ function renderTimelineEditingHook(input: {
167
175
  groupResize = hook.handleTimelineGroupResize;
168
176
  del = hook.handleTimelineElementDelete;
169
177
  elementsDelete = hook.handleTimelineElementsDelete;
178
+ handleAutoGroupCarveSources = hook.handleAutoGroupCarveSources;
179
+ setAudioGroupAttribute = hook.setAudioGroupAttribute;
170
180
  return null;
171
181
  }
172
182
 
@@ -177,7 +187,19 @@ function renderTimelineEditingHook(input: {
177
187
  if (!groupResize) throw new Error("Expected hook to expose group resize handler");
178
188
  if (!del) throw new Error("Expected hook to expose delete handler");
179
189
  if (!elementsDelete) throw new Error("Expected hook to expose elements-delete handler");
180
- return { move, resize, groupMove, groupResize, del, elementsDelete, unmount };
190
+ if (!handleAutoGroupCarveSources) throw new Error("Expected hook to expose group handler");
191
+ if (!setAudioGroupAttribute) throw new Error("Expected hook to expose audio group handler");
192
+ return {
193
+ move,
194
+ resize,
195
+ groupMove,
196
+ groupResize,
197
+ del,
198
+ elementsDelete,
199
+ handleAutoGroupCarveSources,
200
+ setAudioGroupAttribute,
201
+ unmount,
202
+ };
181
203
  }
182
204
 
183
205
  type TimelineRecordEdit = NonNullable<
@@ -1457,6 +1479,53 @@ describe("useTimelineEditing duration rollback on failed persist", () => {
1457
1479
  // Blocked means no fetch write, no recordEdit, and the host's reason
1458
1480
  // toasted. Absent canEdit behaves exactly as before (asserted above).
1459
1481
  describe("useTimelineEditing: canEdit gate", () => {
1482
+ it("re-reads canEdit after the host changes its verdict", async () => {
1483
+ const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
1484
+ const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
1485
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
1486
+ const showToast = vi.fn();
1487
+ const recordEdit = vi.fn(async () => {});
1488
+ const pendingTimelineEditPathRef = { current: new Set<string>() };
1489
+ const previewIframeRef = { current: iframe };
1490
+ const uploadProjectFiles = vi.fn();
1491
+ const reloadPreview = vi.fn();
1492
+ let setBlocked = () => {};
1493
+ let hook: ReturnType<typeof useTimelineEditing> | null = null;
1494
+
1495
+ function Harness() {
1496
+ const [blocked, updateBlocked] = useState(false);
1497
+ setBlocked = () => updateBlocked(true);
1498
+ hook = useTimelineEditing({
1499
+ projectId: "p1",
1500
+ activeCompPath: "index.html",
1501
+ timelineElements: [clip],
1502
+ showToast,
1503
+ writeProjectFile,
1504
+ recordEdit,
1505
+ reloadPreview,
1506
+ previewIframeRef,
1507
+ pendingTimelineEditPathRef,
1508
+ uploadProjectFiles,
1509
+ canEdit: () => (blocked ? { blocked: true, reason: "Reserved by an agent" } : true),
1510
+ });
1511
+ return null;
1512
+ }
1513
+
1514
+ const { unmount } = mountHarness(<Harness />);
1515
+ if (!hook) throw new Error("Expected hook to mount");
1516
+ act(() => setBlocked());
1517
+
1518
+ await act(async () => {
1519
+ await hook!.handleTimelineElementMove(clip, { start: 3, track: clip.track });
1520
+ await flushAsyncWork();
1521
+ });
1522
+
1523
+ expect(writeProjectFile).not.toHaveBeenCalled();
1524
+ expect(recordEdit).not.toHaveBeenCalled();
1525
+ expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
1526
+ unmount();
1527
+ });
1528
+
1460
1529
  it("refuses a move with the host's reason, writing nothing", async () => {
1461
1530
  const { clip, move, writeProjectFile, recordEdit, showToast, unmount } = setupSingleClipHarness(
1462
1531
  {
@@ -1700,6 +1769,95 @@ describe("useTimelineEditing: canEdit gate", () => {
1700
1769
  expect(fetchMock).not.toHaveBeenCalled();
1701
1770
  unmount();
1702
1771
  });
1772
+
1773
+ it("refuses auto-grouping before it patches existing clips", async () => {
1774
+ const iframe = document.createElement("iframe");
1775
+ document.body.append(iframe);
1776
+ const doc = iframe.contentDocument;
1777
+ if (!doc) throw new Error("Expected iframe document");
1778
+ doc.body.innerHTML =
1779
+ '<audio id="voice-1" data-start="0" data-duration="5"></audio>' +
1780
+ '<audio id="voice-2" data-start="5" data-duration="5"></audio>';
1781
+ const voice1 = timelineElement({ id: "voice-1", tag: "audio", track: 0, zIndex: 0 });
1782
+ const voice2 = timelineElement({ id: "voice-2", tag: "audio", track: 1, zIndex: 0 });
1783
+ usePlayerStore.getState().setElements([voice1, voice2]);
1784
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
1785
+ const recordEdit = vi.fn(async () => {});
1786
+ const showToast = vi.fn();
1787
+ const fetchMock = stubProjectFetch(
1788
+ '<audio id="voice-1" data-start="0" data-duration="5"></audio>' +
1789
+ '<audio id="voice-2" data-start="5" data-duration="5"></audio>',
1790
+ );
1791
+ const hook = renderTimelineEditingHook({
1792
+ timelineElements: [voice1, voice2],
1793
+ iframe,
1794
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
1795
+ projectId: "p1",
1796
+ writeProjectFile,
1797
+ recordEdit,
1798
+ showToast,
1799
+ canEdit: (element) =>
1800
+ element.id === "voice-2" ? { blocked: true, reason: "Reserved by an agent" } : true,
1801
+ });
1802
+
1803
+ await expect(
1804
+ hook.handleAutoGroupCarveSources(["voice-1", "voice-2"], "voiceover"),
1805
+ ).rejects.toThrow("Timeline edit blocked");
1806
+
1807
+ expect(fetchMock).not.toHaveBeenCalled();
1808
+ expect(writeProjectFile).not.toHaveBeenCalled();
1809
+ expect(recordEdit).not.toHaveBeenCalled();
1810
+ expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
1811
+ expect(doc.getElementById("voice-1")?.getAttribute("data-audio-group")).toBeNull();
1812
+ expect(doc.getElementById("voiceover")).toBeNull();
1813
+ hook.unmount();
1814
+ });
1815
+
1816
+ it("reverts an optimistic group live value when the commit is refused", async () => {
1817
+ const iframe = document.createElement("iframe");
1818
+ document.body.append(iframe);
1819
+ const doc = iframe.contentDocument;
1820
+ if (!doc) throw new Error("Expected iframe document");
1821
+ doc.body.innerHTML =
1822
+ '<hf-audio-group id="voiceover" data-volume="1"></hf-audio-group>' +
1823
+ '<audio id="voice-1" data-start="0" data-duration="5"></audio>';
1824
+ const member = {
1825
+ ...timelineElement({ id: "voice-1", tag: "audio", track: 0, zIndex: 0 }),
1826
+ audioGroup: "voiceover",
1827
+ audioGroupVolume: 1,
1828
+ };
1829
+ usePlayerStore.getState().setElements([member]);
1830
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
1831
+ const recordEdit = vi.fn(async () => {});
1832
+ const showToast = vi.fn();
1833
+ const hook = renderTimelineEditingHook({
1834
+ timelineElements: [member],
1835
+ iframe,
1836
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
1837
+ projectId: "p1",
1838
+ writeProjectFile,
1839
+ recordEdit,
1840
+ showToast,
1841
+ canEdit: () => ({ blocked: true, reason: "Reserved by an agent" }),
1842
+ });
1843
+
1844
+ await act(async () => {
1845
+ hook.setAudioGroupAttribute.setLive("voiceover", "data-volume", "0.4");
1846
+ });
1847
+ expect(doc.getElementById("voiceover")?.getAttribute("data-volume")).toBe("0.4");
1848
+ expect(usePlayerStore.getState().elements[0]?.audioGroupVolume).toBe(0.4);
1849
+
1850
+ await act(async () => {
1851
+ await hook.setAudioGroupAttribute.setQuiet("voiceover", "data-volume", "0.4", "Set volume");
1852
+ });
1853
+
1854
+ expect(doc.getElementById("voiceover")?.getAttribute("data-volume")).toBe("1");
1855
+ expect(usePlayerStore.getState().elements[0]?.audioGroupVolume).toBe(1);
1856
+ expect(writeProjectFile).not.toHaveBeenCalled();
1857
+ expect(recordEdit).not.toHaveBeenCalled();
1858
+ expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
1859
+ hook.unmount();
1860
+ });
1703
1861
  });
1704
1862
 
1705
1863
  // Regression: track()/guard() must return the same wrapped handler across
@@ -40,6 +40,16 @@ import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
40
40
  import type { TimelineMoveUpdates, UseTimelineEditingOptions } from "./useTimelineEditingTypes";
41
41
  import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
42
42
 
43
+ type GuardedTimelineHandler = (...args: never[]) => Promise<void>;
44
+ type GuardedTimelineResolver = (...args: never[]) => readonly TimelineElement[];
45
+ type GuardedTimelineRefusal = (...args: never[]) => void;
46
+
47
+ interface GuardedTimelineEntry {
48
+ resolveTargets: GuardedTimelineResolver;
49
+ onRefused?: GuardedTimelineRefusal;
50
+ wrapped: GuardedTimelineHandler;
51
+ }
52
+
43
53
  export function useTimelineEditing({
44
54
  projectId,
45
55
  activeCompPath,
@@ -65,13 +75,13 @@ export function useTimelineEditing({
65
75
  const editQueueRef = useRef(Promise.resolve());
66
76
  const track = useTrackPendingTimelineEdit();
67
77
  const checkEditable = useTimelineEditGate(canEdit, showToast);
78
+ const checkEditableRef = useRef(checkEditable);
79
+ checkEditableRef.current = checkEditable;
68
80
  // Same expanded-row source the hide handlers themselves resolve against
69
81
  // (timelineTrackVisibility.ts) — a virtual sub-comp child's track/key
70
82
  // only exists here, not in the raw store list canEdit would otherwise miss.
71
83
  const timelineRowElements = useTimelineRowElements();
72
- const guardedRef = useRef(
73
- new WeakMap<(...args: never[]) => Promise<void>, (...args: never[]) => Promise<void>>(),
74
- );
84
+ const guardedRef = useRef(new WeakMap<GuardedTimelineHandler, GuardedTimelineEntry>());
75
85
  // Refuses (no call, no write, no history entry) when any target is
76
86
  // blocked; otherwise runs fn as before. Cached by fn identity — like
77
87
  // track() — so a fresh closure here doesn't defeat track's own cache.
@@ -79,16 +89,29 @@ export function useTimelineEditing({
79
89
  <H extends (...args: never[]) => Promise<void>>(
80
90
  resolveTargets: (...args: Parameters<H>) => readonly TimelineElement[],
81
91
  fn: H,
92
+ onRefused?: (...args: Parameters<H>) => void,
82
93
  ): H => {
83
- const key = fn as unknown as (...args: never[]) => Promise<void>;
94
+ const key = fn as unknown as GuardedTimelineHandler;
84
95
  const cached = guardedRef.current.get(key);
85
- if (cached) return cached as H;
86
- const wrapped = ((...args: Parameters<H>) =>
87
- checkEditable(resolveTargets(...args)) ? fn(...args) : Promise.resolve()) as H;
88
- guardedRef.current.set(key, wrapped as unknown as (...args: never[]) => Promise<void>);
89
- return wrapped;
96
+ if (cached) {
97
+ cached.resolveTargets = resolveTargets as unknown as GuardedTimelineResolver;
98
+ cached.onRefused = onRefused as unknown as GuardedTimelineRefusal | undefined;
99
+ return cached.wrapped as H;
100
+ }
101
+ const entry = {} as GuardedTimelineEntry;
102
+ entry.resolveTargets = resolveTargets as unknown as GuardedTimelineResolver;
103
+ entry.onRefused = onRefused as unknown as GuardedTimelineRefusal | undefined;
104
+ entry.wrapped = ((...args: Parameters<H>) => {
105
+ if (!checkEditableRef.current(entry.resolveTargets(...(args as never[])))) {
106
+ entry.onRefused?.(...(args as never[]));
107
+ return Promise.resolve();
108
+ }
109
+ return fn(...args);
110
+ }) as H as unknown as GuardedTimelineHandler;
111
+ guardedRef.current.set(key, entry);
112
+ return entry.wrapped as H;
90
113
  },
91
- [checkEditable],
114
+ [],
92
115
  );
93
116
 
94
117
  const enqueueEdit = useCallback(
@@ -411,9 +434,10 @@ export function useTimelineEditing({
411
434
  previewIframeRef,
412
435
  pendingTimelineEditPathRef,
413
436
  isRecordingRef,
437
+ checkEditable,
414
438
  });
415
439
 
416
- const setElementFxAttribute = useSetElementAttribute({
440
+ const { revertLive: revertElementFxLive, ...setElementFxAttribute } = useSetElementAttribute({
417
441
  projectIdRef,
418
442
  activeCompPath,
419
443
  showToast,
@@ -424,16 +448,18 @@ export function useTimelineEditing({
424
448
  isRecordingRef,
425
449
  });
426
450
 
427
- const setAudioGroupAttribute = useSetAudioGroupAttribute({
428
- projectIdRef,
429
- activeCompPath,
430
- showToast,
431
- writeProjectFile,
432
- recordEdit,
433
- previewIframeRef,
434
- pendingTimelineEditPathRef,
435
- isRecordingRef,
436
- });
451
+ const { revertLive: revertAudioGroupLive, ...setAudioGroupAttribute } = useSetAudioGroupAttribute(
452
+ {
453
+ projectIdRef,
454
+ activeCompPath,
455
+ showToast,
456
+ writeProjectFile,
457
+ recordEdit,
458
+ previewIframeRef,
459
+ pendingTimelineEditPathRef,
460
+ isRecordingRef,
461
+ },
462
+ );
437
463
 
438
464
  const { handleTimelineElementsDelete, handleTimelineElementDelete } = useTimelineDeleteOps({
439
465
  projectIdRef,
@@ -462,6 +488,7 @@ export function useTimelineEditing({
462
488
  isRecordingRef,
463
489
  forceReloadSdkSession,
464
490
  observeProjectFileVersion,
491
+ checkEditable,
465
492
  });
466
493
 
467
494
  const handleBlockedTimelineEdit = useBlockedTimelineEditToast(showToast);
@@ -504,28 +531,38 @@ export function useTimelineEditing({
504
531
  // (timelineAudioGroupVolume.ts): a sub-composition's group members have
505
532
  // no flat twin, only a domClipChildren entry, so both are checked.
506
533
  setQuiet: track(
507
- guard((groupId) => {
508
- const state = usePlayerStore.getState();
509
- const flatMembers = state.elements.filter((el) => el.audioGroup === groupId);
510
- const domMembers = state.domClipChildren
511
- .filter((child) => child.audioGroup === groupId)
512
- .map(
513
- (child): TimelineElement => ({
514
- id: child.id,
515
- domId: child.id,
516
- tag: "div",
517
- start: 0,
518
- duration: 0,
519
- track: -1,
520
- }),
521
- );
522
- return [...flatMembers, ...domMembers];
523
- }, setAudioGroupAttribute.setQuiet),
534
+ guard(
535
+ (groupId) => {
536
+ const state = usePlayerStore.getState();
537
+ const flatMembers = state.elements.filter((el) => el.audioGroup === groupId);
538
+ const domMembers = state.domClipChildren
539
+ .filter((child) => child.audioGroup === groupId)
540
+ .map(
541
+ (child): TimelineElement => ({
542
+ id: child.id,
543
+ domId: child.id,
544
+ tag: "div",
545
+ start: 0,
546
+ duration: 0,
547
+ track: -1,
548
+ }),
549
+ );
550
+ return [...flatMembers, ...domMembers];
551
+ },
552
+ setAudioGroupAttribute.setQuiet,
553
+ (groupId, attr) => revertAudioGroupLive(groupId, attr),
554
+ ),
524
555
  ),
525
556
  },
526
557
  setElementFxAttribute: {
527
558
  ...setElementFxAttribute,
528
- setQuiet: track(guard((element) => [element], setElementFxAttribute.setQuiet)),
559
+ setQuiet: track(
560
+ guard(
561
+ (element) => [element],
562
+ setElementFxAttribute.setQuiet,
563
+ (element, attr) => revertElementFxLive(element, attr),
564
+ ),
565
+ ),
529
566
  },
530
567
  handleTimelineElementDelete: track(guard((element) => [element], handleTimelineElementDelete)),
531
568
  handleTimelineElementsDelete: track(
package/src/index.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  // UI primitives
2
- export { Button, buttonBase, buttonVariants } from "./components/ui/Button";
2
+ export { Button, buttonBase, buttonSizes, buttonVariants } from "./components/ui/Button";
3
3
  export type { ButtonSize, ButtonVariant, PreviewState } from "./components/ui/Button";
4
+ export { HyperframesLogo } from "./components/StudioHeader";
4
5
  export { IconButton } from "./components/ui/IconButton";
5
6
  export { Tab, TabPanel, Tabs, TabsList } from "./components/ui/Tabs";
7
+ export { HyperframesLoader } from "./components/ui/HyperframesLoader";
8
+ export type { HyperframesLoaderProps } from "./components/ui/HyperframesLoader";
6
9
  export { Tooltip } from "./components/ui/Tooltip";
7
10
  export { cn } from "./components/ui/cn";
8
11
  export {
@@ -48,7 +51,7 @@ export {
48
51
  liveTime,
49
52
  formatTime,
50
53
  } from "./player";
51
- export type { TimelineElement } from "./player";
54
+ export type { TimelineElement, TimelineTimeRange } from "./player";
52
55
 
53
56
  // Host overlays: draw over the preview in composition coordinates (see EditorShellProps.gestureOverlay)
54
57
  export { usePreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
package/src/main.tsx CHANGED
@@ -65,6 +65,10 @@ window.addEventListener("error", (event) => {
65
65
  });
66
66
 
67
67
  let filteredAssetErrorCount = 0;
68
+ let filteredAbortCount = 0;
69
+ // Bounded so a hostile extension cannot grow it; a novel abort message still surfaces once.
70
+ const seenAbortMessages = new Set<string>();
71
+ const MAX_SEEN_ABORT_MESSAGES = 20;
68
72
 
69
73
  // fallow-ignore-next-line complexity
70
74
  window.addEventListener("unhandledrejection", (event) => {
@@ -80,6 +84,28 @@ window.addEventListener("unhandledrejection", (event) => {
80
84
  }
81
85
  return;
82
86
  }
87
+ // An AbortError rejection is a cancellation we asked for, not a failure. Our own
88
+ // fetch chains handle it; the unhandled copies come from browser extensions that
89
+ // wrap window.fetch and derive a promise from each request without a rejection
90
+ // handler, so they reject with our abort reason whenever a thumbnail lease is
91
+ // released mid-fetch. Filtered before the cap so a scroll burst cannot exhaust
92
+ // ERROR_CAP and silence the session's real rejections. Sampled per distinct
93
+ // message, so an abort we have not seen before (e.g. our own "Aborted") is
94
+ // recorded once instead of drowning in the extension flood.
95
+ if (props.error_name === "AbortError") {
96
+ filteredAbortCount++;
97
+ const message = props.error_message.slice(0, 200);
98
+ const novel =
99
+ !seenAbortMessages.has(message) && seenAbortMessages.size < MAX_SEEN_ABORT_MESSAGES;
100
+ if (novel) seenAbortMessages.add(message);
101
+ if (novel || filteredAbortCount % 100 === 0) {
102
+ trackStudioEvent("abort_rejection_filtered", {
103
+ error_message: message,
104
+ total_filtered: filteredAbortCount,
105
+ });
106
+ }
107
+ return;
108
+ }
83
109
 
84
110
  rejectionCount++;
85
111
  if (rejectionCount > ERROR_CAP) {
@@ -4,6 +4,7 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
4
4
  import { useTimelineRowElements } from "../hooks/useTimelineRowElements";
5
5
  import { defaultTimelineTheme } from "./timelineTheme";
6
6
  import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
7
+ import { usePublishRangeSelection } from "./usePublishRangeSelection";
7
8
  import { useTimelinePlayhead } from "./useTimelinePlayhead";
8
9
  import { useTimelineZoom } from "./useTimelineZoom";
9
10
  import { useTimelineAssetDrop } from "./timelineDragDrop";
@@ -80,6 +81,7 @@ export const Timeline = memo(function Timeline({
80
81
  onBlockedEditAttempt: onBlockedEditAttemptOverride,
81
82
  onSplitElement: onSplitElementOverride,
82
83
  onSelectElement,
84
+ onRangeSelect,
83
85
  onCopyClip,
84
86
  onPasteClip,
85
87
  onDuplicateClip,
@@ -167,10 +169,10 @@ export const Timeline = memo(function Timeline({
167
169
  selectedElementIds,
168
170
  );
169
171
  const expandedElementsRef = useRef(expandedElements);
170
- expandedElementsRef.current = expandedElements;
172
+ expandedElementsRef.current = expandedElements; // oxlint-disable-line react/refs -- event handlers read the latest elements
171
173
  const ppsRef = useRef(100);
172
174
  const durationRef = useRef(effectiveDuration);
173
- durationRef.current = effectiveDuration;
175
+ durationRef.current = effectiveDuration; // oxlint-disable-line react/refs -- event handlers read the latest duration
174
176
  const fitPpsRef = useRef(100);
175
177
  const {
176
178
  pinZoomBeforeEdit,
@@ -214,7 +216,6 @@ export const Timeline = memo(function Timeline({
214
216
  onMoveElements: pinnedOnMoveElements,
215
217
  });
216
218
  const onContextMenuClip = useClipContextMenu(onSelectElement, dismissGapMenu, setClipContextMenu);
217
-
218
219
  const {
219
220
  draggedClip,
220
221
  setDraggedClip,
@@ -241,7 +242,6 @@ export const Timeline = memo(function Timeline({
241
242
  refreshAfterLaneMove,
242
243
  sessionEpoch,
243
244
  });
244
-
245
245
  const assetDrop = useTimelineAssetDrop({
246
246
  scrollRef,
247
247
  ppsRef,
@@ -320,7 +320,6 @@ export const Timeline = memo(function Timeline({
320
320
  setKfContextMenu,
321
321
  toggleSelectedKeyframe,
322
322
  });
323
-
324
323
  const { clipIndex, renderTimeRange, visibleTimeRange, pinnedClipIdentities } =
325
324
  useTimelineClipRenderWindow({
326
325
  tracks,
@@ -415,7 +414,8 @@ export const Timeline = memo(function Timeline({
415
414
  contentOrigin,
416
415
  sessionEpoch,
417
416
  });
418
- setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag
417
+ usePublishRangeSelection(rangeSelection, onRangeSelect);
418
+ setRangeSelectionRef.current = setRangeSelection; // oxlint-disable-line react/refs -- stable ref consumed by useTimelineClipDrag
419
419
 
420
420
  useTimelineSelectionLifecycle(expandedElements, selectedElementId, setShowPopover, () =>
421
421
  setRangeSelection(null),
@@ -5,7 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
5
5
  import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
6
6
  import { afterEach, describe, expect, it, vi } from "vitest";
7
7
  import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
8
- import { TimelineTrackHeader, gutterFill } from "./TimelineTrackHeader";
8
+ import { TimelineTrackHeader } from "./TimelineTrackHeader";
9
9
  import { defaultTimelineTheme } from "./timelineTheme";
10
10
  import { type TimelineElement } from "../store/playerStore";
11
11
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
@@ -794,6 +794,10 @@ describe("TimelineTrackHeader", () => {
794
794
  });
795
795
  expect(header()?.style.paddingLeft).toBe("14px");
796
796
  expect(header()?.style.borderLeft).toContain("2px");
797
+ // And a lighter gutter, so the row reads as sitting INSIDE its group
798
+ // rather than beside it. Overlaid on the theme's own fill rather than a
799
+ // hard-coded colour, so it follows whatever the gutter is.
800
+ expect(header()?.style.background).toContain("linear-gradient");
797
801
  act(() => view.root.unmount());
798
802
  });
799
803
 
@@ -908,28 +912,3 @@ describe("TimelineTrackHeader", () => {
908
912
  });
909
913
  });
910
914
  });
911
-
912
- // AD147: a host themes the timeline by overriding --timeline-* on theme.css,
913
- // so the rendered gutter must carry the CSS variable, not a baked-in colour.
914
- describe("theming", () => {
915
- it("reads the gutter background and border from timeline theme tokens", () => {
916
- const view = renderHeader({});
917
- const header = view.host.querySelector<HTMLElement>('[role="rowheader"]');
918
- expect(header?.style.background).toBe(defaultTimelineTheme.gutterBackground);
919
- expect(header?.style.background).toContain("var(--timeline-gutter-bg)");
920
- expect(header?.style.borderRight).toContain(defaultTimelineTheme.gutterBorder);
921
- act(() => view.root.unmount());
922
- });
923
-
924
- // happy-dom's `background` shorthand garbles a `var()` layer next to
925
- // `linear-gradient(...)` (verified), so this checks the same value through
926
- // the pure function instead of the broken CSSOM roundtrip.
927
- it("overlays the group-member tint on the themed gutter, not a hard-coded colour", () => {
928
- const filled = gutterFill(defaultTimelineTheme.gutterBackground, true);
929
- expect(filled).toContain("linear-gradient");
930
- expect(filled.endsWith(defaultTimelineTheme.gutterBackground)).toBe(true);
931
- expect(gutterFill(defaultTimelineTheme.gutterBackground, false)).toBe(
932
- defaultTimelineTheme.gutterBackground,
933
- );
934
- });
935
- });