@hyperframes/studio 0.8.53-desktop.0 → 0.8.53

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 (38) hide show
  1. package/dist/assets/{hyperframes-player-PTEnAHhg.js → hyperframes-player-DKWM-Ons.js} +1 -1
  2. package/dist/assets/{index-BnXZuIJD.css → index-DrHQAuGO.css} +1 -1
  3. package/dist/assets/{index-CNyviK_l.js → index-HfP3sap2.js} +200 -200
  4. package/dist/assets/{index-BjslOS9g.js → index-UvW6BEwf.js} +1 -1
  5. package/dist/assets/{index--myXp8ui.js → index-Y4OnpTEM.js} +1 -1
  6. package/dist/index.d.ts +11 -406
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7570 -7709
  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 +0 -7
  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/useFileManager.ts +123 -21
  20. package/src/hooks/usePersistentEditHistory.ts +1 -1
  21. package/src/hooks/useRightPanelIntents.ts +2 -1
  22. package/src/hooks/useTimelineDeleteOps.test.tsx +0 -111
  23. package/src/hooks/useTimelineEditing.test.tsx +3 -322
  24. package/src/hooks/useTimelineEditing.ts +16 -103
  25. package/src/hooks/useTimelineEditingTypes.ts +0 -3
  26. package/src/index.ts +2 -37
  27. package/src/main.tsx +26 -0
  28. package/src/player/components/TimelineTrackHeader.test.tsx +5 -26
  29. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  30. package/src/player/components/timelineTheme.test.ts +4 -4
  31. package/src/player/components/timelineTheme.ts +30 -30
  32. package/src/styles/hex-baseline.json +2 -2
  33. package/src/styles/theme.css +0 -27
  34. package/src/hooks/timelineEditPermission.ts +0 -31
  35. package/src/hooks/useProjectFileWriter.ts +0 -150
  36. package/src/hooks/useTrackPendingTimelineEdit.test.tsx +0 -62
  37. package/src/hooks/useTrackPendingTimelineEdit.ts +0 -22
  38. package/src/hostMount.test.tsx +0 -222
@@ -122,14 +122,12 @@ function renderTimelineEditingHook(input: {
122
122
  forceReloadSdkSession?: () => void;
123
123
  invalidateGsapCache?: () => void;
124
124
  showToast?: (message: string, kind?: string) => void;
125
- canEdit?: NonNullable<Parameters<typeof useTimelineEditing>[0]["canEdit"]>;
126
125
  }): {
127
126
  move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
128
127
  resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"];
129
128
  groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"];
130
129
  groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"];
131
130
  del: ReturnType<typeof useTimelineEditing>["handleTimelineElementDelete"];
132
- elementsDelete: ReturnType<typeof useTimelineEditing>["handleTimelineElementsDelete"];
133
131
  unmount: () => void;
134
132
  } {
135
133
  let move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"] | null = null;
@@ -137,8 +135,6 @@ function renderTimelineEditingHook(input: {
137
135
  let groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"] | null = null;
138
136
  let groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"] | null = null;
139
137
  let del: ReturnType<typeof useTimelineEditing>["handleTimelineElementDelete"] | null = null;
140
- let elementsDelete: ReturnType<typeof useTimelineEditing>["handleTimelineElementsDelete"] | null =
141
- null;
142
138
 
143
139
  function Harness() {
144
140
  const commitRef = useRef(input.onZIndexCommit);
@@ -159,14 +155,12 @@ function renderTimelineEditingHook(input: {
159
155
  forceReloadSdkSession: input.forceReloadSdkSession,
160
156
  invalidateGsapCache: input.invalidateGsapCache,
161
157
  handleDomZIndexReorderCommitRef: commitRef,
162
- canEdit: input.canEdit,
163
158
  });
164
159
  move = hook.handleTimelineElementMove;
165
160
  resize = hook.handleTimelineElementResize;
166
161
  groupMove = hook.handleTimelineGroupMove;
167
162
  groupResize = hook.handleTimelineGroupResize;
168
163
  del = hook.handleTimelineElementDelete;
169
- elementsDelete = hook.handleTimelineElementsDelete;
170
164
  return null;
171
165
  }
172
166
 
@@ -176,8 +170,7 @@ function renderTimelineEditingHook(input: {
176
170
  if (!groupMove) throw new Error("Expected hook to expose group move handler");
177
171
  if (!groupResize) throw new Error("Expected hook to expose group resize handler");
178
172
  if (!del) throw new Error("Expected hook to expose delete handler");
179
- if (!elementsDelete) throw new Error("Expected hook to expose elements-delete handler");
180
- return { move, resize, groupMove, groupResize, del, elementsDelete, unmount };
173
+ return { move, resize, groupMove, groupResize, del, unmount };
181
174
  }
182
175
 
183
176
  type TimelineRecordEdit = NonNullable<
@@ -296,7 +289,6 @@ function setupSingleClipHarness(options?: {
296
289
  source?: string;
297
290
  clipStyle?: string;
298
291
  onZIndexCommit?: (entries: ZIndexEntry[]) => Promise<void>;
299
- canEdit?: NonNullable<Parameters<typeof useTimelineEditing>[0]["canEdit"]>;
300
292
  }) {
301
293
  const iframe = createPreviewIframe([{ id: "clip", track: 0, style: options?.clipStyle }]);
302
294
  const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
@@ -308,30 +300,16 @@ function setupSingleClipHarness(options?: {
308
300
  const fetchMock = stubProjectFetch(
309
301
  options?.source ?? '<div id="clip" data-start="0" data-track-index="0"></div>',
310
302
  );
311
- const recordEdit = vi.fn(async () => {});
312
- const showToast = vi.fn();
313
303
  const hook = renderTimelineEditingHook({
314
304
  timelineElements: [clip],
315
305
  iframe,
316
306
  onZIndexCommit: commit,
317
307
  projectId: "p1",
318
308
  writeProjectFile,
319
- recordEdit,
309
+ recordEdit: vi.fn(async () => {}),
320
310
  reloadPreview,
321
- showToast,
322
- canEdit: options?.canEdit,
323
311
  });
324
- return {
325
- iframe,
326
- clip,
327
- commit,
328
- writeProjectFile,
329
- recordEdit,
330
- showToast,
331
- reloadPreview,
332
- fetchMock,
333
- ...hook,
334
- };
312
+ return { iframe, clip, commit, writeProjectFile, reloadPreview, fetchMock, ...hook };
335
313
  }
336
314
 
337
315
  const SDK_KEYFRAMED_SOURCE = [
@@ -1453,300 +1431,3 @@ describe("useTimelineEditing duration rollback on failed persist", () => {
1453
1431
  succeeding.unmount();
1454
1432
  });
1455
1433
  });
1456
-
1457
- // Blocked means no fetch write, no recordEdit, and the host's reason
1458
- // toasted. Absent canEdit behaves exactly as before (asserted above).
1459
- describe("useTimelineEditing: canEdit gate", () => {
1460
- it("refuses a move with the host's reason, writing nothing", async () => {
1461
- const { clip, move, writeProjectFile, recordEdit, showToast, unmount } = setupSingleClipHarness(
1462
- {
1463
- canEdit: () => ({ blocked: true, reason: "Reserved by an agent" }),
1464
- },
1465
- );
1466
-
1467
- await act(async () => {
1468
- await move(clip, { start: 3, track: clip.track });
1469
- await flushAsyncWork();
1470
- });
1471
-
1472
- expect(writeProjectFile).not.toHaveBeenCalled();
1473
- expect(recordEdit).not.toHaveBeenCalled();
1474
- expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
1475
- unmount();
1476
- });
1477
-
1478
- it("refuses a delete (single and multi) with nothing written", async () => {
1479
- const { clip, del, elementsDelete, writeProjectFile, recordEdit, unmount } =
1480
- setupSingleClipHarness({
1481
- canEdit: () => ({ blocked: true, reason: "Reserved by an agent" }),
1482
- });
1483
-
1484
- await act(async () => {
1485
- await del(clip);
1486
- await elementsDelete([clip]);
1487
- await flushAsyncWork();
1488
- });
1489
-
1490
- expect(writeProjectFile).not.toHaveBeenCalled();
1491
- expect(recordEdit).not.toHaveBeenCalled();
1492
- unmount();
1493
- });
1494
-
1495
- it("resolves toggle-track-hidden against the expanded rows, not the raw timelineElements prop", async () => {
1496
- // Regression: canEdit must resolve against the expanded rows
1497
- // (timelineTrackVisibility.ts's own invariant), not the raw
1498
- // timelineElements prop — proven by making the two disagree below.
1499
- const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
1500
- const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
1501
- usePlayerStore.getState().setElements([clip]);
1502
- const showToast = vi.fn();
1503
- const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
1504
- let hook: ReturnType<typeof useTimelineEditing> | null = null;
1505
- function Harness() {
1506
- hook = useTimelineEditing({
1507
- projectId: "p1",
1508
- activeCompPath: "index.html",
1509
- timelineElements: [], // deliberately does not contain `clip`
1510
- showToast,
1511
- writeProjectFile,
1512
- recordEdit: vi.fn(),
1513
- reloadPreview: vi.fn(),
1514
- previewIframeRef: { current: iframe },
1515
- pendingTimelineEditPathRef: { current: new Set<string>() },
1516
- uploadProjectFiles: vi.fn(),
1517
- canEdit: (element) =>
1518
- element.id === "clip" ? { blocked: true, reason: "Reserved by an agent" } : true,
1519
- });
1520
- return null;
1521
- }
1522
- const { unmount } = mountHarness(<Harness />);
1523
- if (!hook) throw new Error("Expected hook to mount");
1524
-
1525
- await act(async () => {
1526
- await hook!.handleToggleTrackHidden(0, true);
1527
- await flushAsyncWork();
1528
- });
1529
-
1530
- expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
1531
- expect(writeProjectFile).not.toHaveBeenCalled();
1532
- unmount();
1533
- });
1534
-
1535
- it("lets an unblocked element through while a blocked one is refused", async () => {
1536
- const iframe = createPreviewIframe([
1537
- { id: "free", track: 0 },
1538
- { id: "locked", track: 1 },
1539
- ]);
1540
- const free = timelineElement({ id: "free", track: 0, zIndex: 0 });
1541
- const locked = timelineElement({ id: "locked", track: 1, zIndex: 0 });
1542
- const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
1543
- const recordEdit = vi.fn(async () => {});
1544
- stubProjectFetch(
1545
- '<div id="free" data-start="0" data-track-index="0"></div>' +
1546
- '<div id="locked" data-start="0" data-track-index="1"></div>',
1547
- );
1548
- const hook = renderTimelineEditingHook({
1549
- timelineElements: [free, locked],
1550
- iframe,
1551
- onZIndexCommit: vi.fn().mockResolvedValue(undefined),
1552
- projectId: "p1",
1553
- writeProjectFile,
1554
- recordEdit,
1555
- canEdit: (element) =>
1556
- element.id === "locked" ? { blocked: true, reason: "Reserved by an agent" } : true,
1557
- });
1558
-
1559
- await act(async () => {
1560
- await hook.move(locked, { start: 3, track: locked.track });
1561
- await flushAsyncWork();
1562
- });
1563
- expect(writeProjectFile).not.toHaveBeenCalled();
1564
-
1565
- await act(async () => {
1566
- await hook.move(free, { start: 3, track: free.track });
1567
- await flushAsyncWork();
1568
- });
1569
- expect(writeProjectFile).toHaveBeenCalled();
1570
- hook.unmount();
1571
- });
1572
-
1573
- it("refuses razor-split-all when it would split a blocked clip, writing nothing", async () => {
1574
- const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
1575
- const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 2 });
1576
- usePlayerStore.getState().setElements([clip]);
1577
- const showToast = vi.fn();
1578
- const fetchMock = vi.fn(async () => {
1579
- throw new Error("must not be called: canEdit should have refused the split");
1580
- });
1581
- vi.stubGlobal("fetch", fetchMock);
1582
- let hook: ReturnType<typeof useTimelineEditing> | null = null;
1583
- function Harness() {
1584
- hook = useTimelineEditing({
1585
- projectId: "p1",
1586
- activeCompPath: "index.html",
1587
- timelineElements: [clip],
1588
- showToast,
1589
- writeProjectFile: vi.fn(),
1590
- recordEdit: vi.fn(),
1591
- reloadPreview: vi.fn(),
1592
- previewIframeRef: { current: iframe },
1593
- pendingTimelineEditPathRef: { current: new Set<string>() },
1594
- uploadProjectFiles: vi.fn(),
1595
- canEdit: () => ({ blocked: true, reason: "Reserved by an agent" }),
1596
- });
1597
- return null;
1598
- }
1599
- const { unmount } = mountHarness(<Harness />);
1600
- if (!hook) throw new Error("Expected hook to mount");
1601
-
1602
- await act(async () => {
1603
- await hook!.handleRazorSplitAll(1);
1604
- await flushAsyncWork();
1605
- });
1606
-
1607
- expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
1608
- expect(fetchMock).not.toHaveBeenCalled();
1609
- unmount();
1610
- });
1611
-
1612
- it("refuses a blocked group's audio attribute write, writing nothing", async () => {
1613
- const iframe = createPreviewIframe([{ id: "member", track: 0 }]);
1614
- const member = timelineElement({ id: "member", track: 0, zIndex: 0 });
1615
- usePlayerStore.getState().setElements([{ ...member, audioGroup: "hf-group" }]);
1616
- const showToast = vi.fn();
1617
- const fetchMock = vi.fn(async () => {
1618
- throw new Error("must not be called: canEdit should have refused the write");
1619
- });
1620
- vi.stubGlobal("fetch", fetchMock);
1621
- let hook: ReturnType<typeof useTimelineEditing> | null = null;
1622
- function Harness() {
1623
- hook = useTimelineEditing({
1624
- projectId: "p1",
1625
- activeCompPath: "index.html",
1626
- timelineElements: [member],
1627
- showToast,
1628
- writeProjectFile: vi.fn(),
1629
- recordEdit: vi.fn(),
1630
- reloadPreview: vi.fn(),
1631
- previewIframeRef: { current: iframe },
1632
- pendingTimelineEditPathRef: { current: new Set<string>() },
1633
- uploadProjectFiles: vi.fn(),
1634
- canEdit: (element) =>
1635
- element.id === "member" ? { blocked: true, reason: "Reserved by an agent" } : true,
1636
- });
1637
- return null;
1638
- }
1639
- const { unmount } = mountHarness(<Harness />);
1640
- if (!hook) throw new Error("Expected hook to mount");
1641
-
1642
- await act(async () => {
1643
- await hook!.setAudioGroupAttribute.setQuiet("hf-group", "data-volume", "0.5", "Set volume");
1644
- await flushAsyncWork();
1645
- });
1646
-
1647
- expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
1648
- expect(fetchMock).not.toHaveBeenCalled();
1649
- unmount();
1650
- });
1651
-
1652
- it("refuses a group write when its members live only inside a sub-composition", async () => {
1653
- // syncStoredGroupAttribute mirrors into domClipChildren for a group with
1654
- // no flat twin (timelineAudioGroupVolume.ts) — the resolver must check
1655
- // that array too, or a sub-comp-only group's write goes ungated.
1656
- const iframe = createPreviewIframe([]);
1657
- usePlayerStore.getState().setElements([]);
1658
- usePlayerStore.getState().setDomClipChildren([
1659
- {
1660
- id: "sub-member",
1661
- parentId: "host",
1662
- hostId: "host",
1663
- label: "Sub member",
1664
- stackingContextId: "root",
1665
- audioGroup: "hf-group",
1666
- },
1667
- ]);
1668
- const showToast = vi.fn();
1669
- const fetchMock = vi.fn(async () => {
1670
- throw new Error("must not be called: canEdit should have refused the write");
1671
- });
1672
- vi.stubGlobal("fetch", fetchMock);
1673
- let hook: ReturnType<typeof useTimelineEditing> | null = null;
1674
- function Harness() {
1675
- hook = useTimelineEditing({
1676
- projectId: "p1",
1677
- activeCompPath: "index.html",
1678
- timelineElements: [],
1679
- showToast,
1680
- writeProjectFile: vi.fn(),
1681
- recordEdit: vi.fn(),
1682
- reloadPreview: vi.fn(),
1683
- previewIframeRef: { current: iframe },
1684
- pendingTimelineEditPathRef: { current: new Set<string>() },
1685
- uploadProjectFiles: vi.fn(),
1686
- canEdit: (element) =>
1687
- element.id === "sub-member" ? { blocked: true, reason: "Reserved by an agent" } : true,
1688
- });
1689
- return null;
1690
- }
1691
- const { unmount } = mountHarness(<Harness />);
1692
- if (!hook) throw new Error("Expected hook to mount");
1693
-
1694
- await act(async () => {
1695
- await hook!.setAudioGroupAttribute.setQuiet("hf-group", "data-volume", "0.5", "Set volume");
1696
- await flushAsyncWork();
1697
- });
1698
-
1699
- expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
1700
- expect(fetchMock).not.toHaveBeenCalled();
1701
- unmount();
1702
- });
1703
- });
1704
-
1705
- // Regression: track()/guard() must return the same wrapped handler across
1706
- // renders for the same fn, or a consumer using it as a memo/effect
1707
- // dependency re-runs on every render for nothing.
1708
- describe("useTimelineEditing: handler identity is stable across renders", () => {
1709
- it("returns the same handleTimelineElementMove reference on a re-render", () => {
1710
- const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
1711
- const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
1712
- stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
1713
- // Every option below is hoisted (created once), matching a real caller's
1714
- // stable useCallback/selector inputs — a fresh vi.fn() per render would
1715
- // change handleTimelineElementMove's own identity regardless of the fix.
1716
- const timelineElements = [clip];
1717
- const showToast = vi.fn();
1718
- const writeProjectFile = vi.fn();
1719
- const recordEdit = vi.fn();
1720
- const reloadPreview = vi.fn();
1721
- const uploadProjectFiles = vi.fn();
1722
- const previewIframeRef = { current: iframe };
1723
- const pendingTimelineEditPathRef = { current: new Set<string>() };
1724
- const seen: unknown[] = [];
1725
- let bumpTick = 0;
1726
- let bump = () => {};
1727
- function Harness() {
1728
- const [, setTick] = React.useState(0);
1729
- bump = () => setTick((t) => t + 1);
1730
- bumpTick += 1;
1731
- const hook = useTimelineEditing({
1732
- projectId: "p1",
1733
- activeCompPath: "index.html",
1734
- timelineElements,
1735
- showToast,
1736
- writeProjectFile,
1737
- recordEdit,
1738
- reloadPreview,
1739
- previewIframeRef,
1740
- pendingTimelineEditPathRef,
1741
- uploadProjectFiles,
1742
- });
1743
- seen.push(hook.handleTimelineElementMove);
1744
- return null;
1745
- }
1746
- const { unmount } = mountHarness(<Harness />);
1747
- act(() => bump());
1748
- expect(bumpTick).toBeGreaterThan(1);
1749
- expect(seen[0]).toBe(seen[1]);
1750
- unmount();
1751
- });
1752
- });
@@ -1,9 +1,7 @@
1
1
  // fallow-ignore-file complexity
2
2
  import { useCallback, useRef } from "react";
3
3
  import type { TimelineElement } from "../player";
4
- import { usePlayerStore } from "../player";
5
4
  import { useRazorSplit } from "./useRazorSplit";
6
- import { selectSplittableElements } from "../utils/timelineElementSplit";
7
5
  import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
8
6
  import {
9
7
  applyTimelineStackingReorder,
@@ -25,8 +23,6 @@ import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
25
23
  import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
26
24
  import { useSetElementAttribute } from "./timelineElementFxAttribute";
27
25
  import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
28
- import { useTimelineRowElements } from "../player/hooks/useTimelineRowElements";
29
- import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
30
26
  import { useAudioGroupCarveAssignment } from "./timelineAudioGroupCreate";
31
27
  import {
32
28
  useTimelineElementVisibilityEditing,
@@ -34,7 +30,6 @@ import {
34
30
  } from "./timelineTrackVisibility";
35
31
  import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
36
32
  import { useBlockedTimelineEditToast } from "./useBlockedTimelineEditToast";
37
- import { useTimelineEditGate } from "./timelineEditPermission";
38
33
  import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
39
34
  import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
40
35
  import type { TimelineMoveUpdates, UseTimelineEditingOptions } from "./useTimelineEditingTypes";
@@ -58,38 +53,10 @@ export function useTimelineEditing({
58
53
  forceReloadSdkSession,
59
54
  invalidateGsapCache,
60
55
  handleDomZIndexReorderCommitRef,
61
- canEdit,
62
56
  }: UseTimelineEditingOptions) {
63
57
  const projectIdRef = useRef(projectId);
64
58
  projectIdRef.current = projectId;
65
59
  const editQueueRef = useRef(Promise.resolve());
66
- const track = useTrackPendingTimelineEdit();
67
- const checkEditable = useTimelineEditGate(canEdit, showToast);
68
- // Same expanded-row source the hide handlers themselves resolve against
69
- // (timelineTrackVisibility.ts) — a virtual sub-comp child's track/key
70
- // only exists here, not in the raw store list canEdit would otherwise miss.
71
- const timelineRowElements = useTimelineRowElements();
72
- const guardedRef = useRef(
73
- new WeakMap<(...args: never[]) => Promise<void>, (...args: never[]) => Promise<void>>(),
74
- );
75
- // Refuses (no call, no write, no history entry) when any target is
76
- // blocked; otherwise runs fn as before. Cached by fn identity — like
77
- // track() — so a fresh closure here doesn't defeat track's own cache.
78
- const guard = useCallback(
79
- <H extends (...args: never[]) => Promise<void>>(
80
- resolveTargets: (...args: Parameters<H>) => readonly TimelineElement[],
81
- fn: H,
82
- ): H => {
83
- const key = fn as unknown as (...args: never[]) => Promise<void>;
84
- 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;
90
- },
91
- [checkEditable],
92
- );
93
60
 
94
61
  const enqueueEdit = useCallback(
95
62
  (
@@ -478,77 +445,23 @@ export function useTimelineEditing({
478
445
  forceReloadSdkSession,
479
446
  });
480
447
 
481
- // Every write-handler is tracked here, the one place all hand edits
482
- // converge, so undo never races a write; canEdit gates the same point.
483
- // Coverage boundary: see the PR body, not every kind resolves an element.
484
- const trackedRazorSplit = track(guard((element) => [element], handleRazorSplit));
485
448
  return {
486
- handleTimelineElementMove: track(guard((element) => [element], handleTimelineElementMove)),
487
- handleTimelineElementResize: track(guard((element) => [element], handleTimelineElementResize)),
488
- handleToggleTrackHidden: track(
489
- guard(
490
- (trackIndex) => timelineRowElements.filter((el) => el.track === trackIndex),
491
- handleToggleTrackHidden,
492
- ),
493
- ),
494
- handleToggleElementHidden: track(
495
- guard((elementKey) => {
496
- const keys = new Set(Array.isArray(elementKey) ? elementKey : [elementKey]);
497
- return timelineRowElements.filter((el) => keys.has(el.key ?? el.id));
498
- }, handleToggleElementHidden),
499
- ),
500
- handleAutoGroupCarveSources: track(handleAutoGroupCarveSources),
501
- setAudioGroupAttribute: {
502
- ...setAudioGroupAttribute,
503
- // Same two-array member lookup syncStoredGroupAttribute mirrors into
504
- // (timelineAudioGroupVolume.ts): a sub-composition's group members have
505
- // no flat twin, only a domClipChildren entry, so both are checked.
506
- 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),
524
- ),
525
- },
526
- setElementFxAttribute: {
527
- ...setElementFxAttribute,
528
- setQuiet: track(guard((element) => [element], setElementFxAttribute.setQuiet)),
529
- },
530
- handleTimelineElementDelete: track(guard((element) => [element], handleTimelineElementDelete)),
531
- handleTimelineElementsDelete: track(
532
- guard((elements) => elements, handleTimelineElementsDelete),
533
- ),
534
- handleTimelineElementSplit: trackedRazorSplit,
535
- handleRazorSplit: trackedRazorSplit,
536
- // Same selection the handler itself splits (useRazorSplit.ts).
537
- handleRazorSplitAll: track(
538
- guard(
539
- (splitTime) => selectSplittableElements(usePlayerStore.getState().elements, splitTime),
540
- handleRazorSplitAll,
541
- ),
542
- ),
543
- handleTimelineAssetDrop: track(handleTimelineAssetDrop),
544
- handleTimelineFileDrop: track(handleTimelineFileDrop),
545
- handleTimelineCompositionDrop: track(handleTimelineCompositionDrop),
449
+ handleTimelineElementMove,
450
+ handleTimelineElementResize,
451
+ handleToggleTrackHidden,
452
+ handleToggleElementHidden,
453
+ handleAutoGroupCarveSources,
454
+ setAudioGroupAttribute,
455
+ setElementFxAttribute,
456
+ handleTimelineElementDelete,
457
+ handleTimelineElementsDelete,
458
+ handleTimelineElementSplit: handleRazorSplit,
459
+ handleRazorSplit,
460
+ handleRazorSplitAll,
461
+ handleTimelineAssetDrop,
462
+ handleTimelineFileDrop,
463
+ handleTimelineCompositionDrop,
546
464
  handleBlockedTimelineEdit,
547
- handleTimelineGroupMove: track(
548
- guard((changes) => changes.map((c) => c.element), groupEditing.handleTimelineGroupMove),
549
- ),
550
- handleTimelineGroupResize: track(
551
- guard((changes) => changes.map((c) => c.element), groupEditing.handleTimelineGroupResize),
552
- ),
465
+ ...groupEditing,
553
466
  };
554
467
  }
@@ -4,7 +4,6 @@ import type { TimelineElement } from "../player";
4
4
  import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
5
5
  import type { EditHistoryKind } from "../utils/editHistory";
6
6
  import type { PublishSdkSession } from "../utils/sdkCutover";
7
- import type { CanEditTimelineElement } from "./timelineEditPermission";
8
7
 
9
8
  interface RecordEditInput {
10
9
  label: string;
@@ -50,8 +49,6 @@ export interface UseTimelineEditingOptions {
50
49
  /** Reparse authored animations after a timing rewrite changes their positions. */
51
50
  invalidateGsapCache?: () => void;
52
51
  handleDomZIndexReorderCommitRef?: MutableRefObject<TimelineZIndexReorderCommit | null>;
53
- /** Refuses a hand edit at the persist boundary when set; absent = Studio unchanged. */
54
- canEdit?: CanEditTimelineElement;
55
52
  }
56
53
 
57
54
  export type TimelineFileDropHandler = (
package/src/index.ts CHANGED
@@ -1,9 +1,10 @@
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
4
  export { IconButton } from "./components/ui/IconButton";
5
5
  export { Tab, TabPanel, Tabs, TabsList } from "./components/ui/Tabs";
6
6
  export { Tooltip } from "./components/ui/Tooltip";
7
+ export { HyperframesLogo } from "./components/StudioHeader";
7
8
  export { cn } from "./components/ui/cn";
8
9
  export {
9
10
  ContextMenu,
@@ -96,39 +97,3 @@ export type { PickedElement } from "./hooks/useElementPicker";
96
97
  export { resolveSourceFile, applyPatch } from "./utils/sourcePatcher";
97
98
  export type { PatchOperation } from "./utils/sourcePatcher";
98
99
  export { parseStyleString, mergeStyleIntoTag, findElementBlock } from "./utils/htmlEditor";
99
-
100
- // Timeline editing: Studio's own hand-edit path, undo/redo, the
101
- // etag-guarded writer and the conflict banner, for a host mounting the
102
- // timeline outside EditorShell.
103
- export { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
104
- export type { UsePersistentEditHistoryOptions } from "./hooks/usePersistentEditHistory";
105
- export { useTimelineEditing } from "./hooks/useTimelineEditing";
106
- export type { UseTimelineEditingOptions } from "./hooks/useTimelineEditingTypes";
107
- // A host's own waitForPendingDomEditSaves must also call this, or undo/redo
108
- // can race a write still in flight (see useTrackPendingTimelineEdit.ts).
109
- export { flushStudioPendingEdits } from "./utils/studioPendingEdits";
110
- export type { StudioPendingEditsDrainResult } from "./utils/studioPendingEdits";
111
- export type {
112
- CanEditTimelineElement,
113
- TimelineEditPermission,
114
- } from "./hooks/timelineEditPermission";
115
- export { useEditHistoryActions } from "./hooks/useEditHistoryActions";
116
- export type {
117
- EditHistoryHandle,
118
- UseEditHistoryActionsOptions,
119
- } from "./hooks/useEditHistoryActions";
120
- export { useProjectFileWriter } from "./hooks/useProjectFileWriter";
121
- export type { UseProjectFileWriterOptions } from "./hooks/useProjectFileWriter";
122
- // A host's writeProjectFile throws this on a 409; catch it to know when
123
- // to show ExternalFileConflictBanner.
124
- export { StudioFileConflictError } from "./utils/studioSaveDiagnostics";
125
- export { ExternalFileConflictBanner } from "./components/ExternalFileConflictBanner";
126
- export type {
127
- ExternalFileChangeCoordinatorHandle,
128
- ExternalFileChangeBlockedState,
129
- } from "./hooks/useExternalFileChangeCoordinator";
130
- export { TimelinePane } from "./components/nle/TimelinePane";
131
- export type { TimelinePaneProps } from "./components/nle/TimelinePane";
132
- export { TimelineEditProvider } from "./contexts/TimelineEditContext";
133
- export type { TimelineEditCallbacks } from "./player/components/timelineCallbacks";
134
- export type { BlockedTimelineEditIntent } from "./player/components/timelineEditing";
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) {