@hyperframes/studio 0.8.53 → 0.8.55

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 (43) hide show
  1. package/dist/assets/{hyperframes-player-DKWM-Ons.js → hyperframes-player-BjAGc2ID.js} +1 -1
  2. package/dist/assets/{index-Y4OnpTEM.js → index-BEIRytvV.js} +1 -1
  3. package/dist/assets/{index-UvW6BEwf.js → index-DaNpNFJC.js} +1 -1
  4. package/dist/assets/{index-HfP3sap2.js → index-TcBZyzNA.js} +191 -191
  5. package/dist/index.d.ts +440 -10
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +1891 -1544
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/nle/TimelinePane.tsx +11 -1
  11. package/src/hooks/gsapDragCommit.ts +1 -1
  12. package/src/hooks/gsapEditOutcome.test.ts +115 -0
  13. package/src/hooks/gsapEditOutcome.ts +39 -10
  14. package/src/hooks/gsapResizeIntercept.test.ts +42 -3
  15. package/src/hooks/gsapResizeIntercept.ts +12 -2
  16. package/src/hooks/gsapRuntimeBridge.test.ts +10 -2
  17. package/src/hooks/gsapRuntimeBridge.ts +14 -3
  18. package/src/hooks/timelineAudioGroupCreate.ts +17 -1
  19. package/src/hooks/timelineAudioGroupVolume.ts +35 -2
  20. package/src/hooks/timelineEditPermission.ts +31 -0
  21. package/src/hooks/timelineElementFxAttribute.ts +34 -2
  22. package/src/hooks/useFileManager.ts +21 -123
  23. package/src/hooks/usePersistentEditHistory.ts +1 -1
  24. package/src/hooks/useProjectFileWriter.ts +150 -0
  25. package/src/hooks/useTimelineAssetDropOps.test.tsx +62 -4
  26. package/src/hooks/useTimelineAssetDropOps.ts +40 -3
  27. package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
  28. package/src/hooks/useTimelineEditing.test.tsx +481 -4
  29. package/src/hooks/useTimelineEditing.ts +151 -27
  30. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  31. package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
  32. package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
  33. package/src/hostMount.test.tsx +222 -0
  34. package/src/index.ts +40 -2
  35. package/src/player/components/Timeline.tsx +6 -6
  36. package/src/player/components/TimelineTypes.ts +3 -0
  37. package/src/player/components/usePublishRangeSelection.test.tsx +60 -0
  38. package/src/player/components/usePublishRangeSelection.ts +42 -0
  39. package/src/player/index.ts +1 -0
  40. package/src/player/store/playerStore.ts +8 -51
  41. package/src/player/store/rangeSelectionSlice.ts +13 -0
  42. package/src/player/store/timelineResetState.ts +51 -0
  43. package/src/utils/studioSaveDiagnostics.ts +12 -0
@@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import type { TimelineElement } from "../player";
6
6
  import { applyRippleShifts, useTimelineDeleteOps } from "./useTimelineDeleteOps";
7
7
  import { installReactActEnvironment, mountReactHarness } from "./domSelectionTestHarness";
8
+ import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
9
+ import { flushStudioPendingEdits } from "../utils/studioPendingEdits";
8
10
 
9
11
  installReactActEnvironment();
10
12
 
@@ -117,3 +119,112 @@ describe("useTimelineDeleteOps: ripple undo label", () => {
117
119
  );
118
120
  });
119
121
  });
122
+
123
+ // Regression: a delete's write can reach disk before its history entry is
124
+ // recorded, so an Undo fired in that window found nothing to restore.
125
+ // Fails without useTrackPendingTimelineEdit's wrap (see that file).
126
+ describe("useTimelineDeleteOps: undo race", () => {
127
+ // data-composition-id (readRootCompositionDuration's key) plus a
128
+ // remove-element mock that actually drops hf-c: needed for a real diff.
129
+ const html = `<!DOCTYPE html><html data-composition-variables='[]'><body>
130
+ <div data-hf-id="hf-stage" data-composition-id="main" data-duration="6">
131
+ <div data-hf-id="hf-a" data-start="0" data-duration="2"></div>
132
+ <div data-hf-id="hf-b" data-start="2" data-duration="2"></div>
133
+ <div data-hf-id="hf-c" data-start="4" data-duration="2"></div>
134
+ </div>
135
+ </body></html>`;
136
+ const htmlAfterDeletingC = `<!DOCTYPE html><html data-composition-variables='[]'><body>
137
+ <div data-hf-id="hf-stage" data-composition-id="main" data-duration="6">
138
+ <div data-hf-id="hf-a" data-start="0" data-duration="2"></div>
139
+ <div data-hf-id="hf-b" data-start="2" data-duration="2"></div>
140
+ </div>
141
+ </body></html>`;
142
+
143
+ beforeEach(() => {
144
+ vi.stubGlobal(
145
+ "fetch",
146
+ vi.fn(async (input: RequestInfo | URL) => {
147
+ const url = String(input);
148
+ if (url.includes("/file-mutations/remove-element/")) {
149
+ return new Response(JSON.stringify({ changed: true, content: htmlAfterDeletingC }), {
150
+ status: 200,
151
+ });
152
+ }
153
+ return new Response(JSON.stringify({ content: html }), { status: 200 });
154
+ }),
155
+ );
156
+ });
157
+
158
+ afterEach(() => {
159
+ document.body.innerHTML = "";
160
+ vi.unstubAllGlobals();
161
+ });
162
+
163
+ // A manually-resolved gate stands in for the delete's write landing — a
164
+ // real timer raced the fix's own microtasks unpredictably (verified: it
165
+ // kept passing even with the fix's tracking call deleted).
166
+ function deferred<T>() {
167
+ let resolve!: (value: T) => void;
168
+ const promise = new Promise<T>((r) => (resolve = r));
169
+ return { promise, resolve };
170
+ }
171
+
172
+ function mountRaceHarness() {
173
+ const elements = [el("hf-a", 0, 2), el("hf-b", 2, 2), el("hf-c", 4, 2)];
174
+ const gate = deferred<void>();
175
+ const recordEdit = vi.fn(() => gate.promise);
176
+ let tracked: ((elements: TimelineElement[]) => Promise<void>) | null = null;
177
+ function Harness() {
178
+ const track = useTrackPendingTimelineEdit();
179
+ const hook = useTimelineDeleteOps({
180
+ projectIdRef: { current: "test-project" },
181
+ activeCompPath: "index.html",
182
+ timelineElements: elements,
183
+ showToast: vi.fn(),
184
+ writeProjectFile: vi.fn().mockResolvedValue(undefined),
185
+ recordEdit,
186
+ reloadPreview: vi.fn(),
187
+ previewIframeRef: { current: null },
188
+ handleTimelineGroupMove: vi.fn().mockResolvedValue(undefined),
189
+ });
190
+ tracked = track(hook.handleTimelineElementsDelete);
191
+ return null;
192
+ }
193
+ mountReactHarness(<Harness />);
194
+ // Deletes the LAST clip (not the middle one): only this shrinks the
195
+ // composition's furthest-clip-end duration, so the write actually
196
+ // differs from the original content and recordEdit is reached — the
197
+ // middle clip's delete leaves duration unchanged and is a no-op write.
198
+ return { gate, recordEdit, deleteSelection: () => tracked!([elements[2]]) };
199
+ }
200
+
201
+ it("the pending-edit flush waits while the delete's history write is still landing", async () => {
202
+ const { gate, recordEdit, deleteSelection } = mountRaceHarness();
203
+ const order: string[] = [];
204
+ const deletePromise = deleteSelection().then(() => order.push("deleted"));
205
+ // Let the delete run up to (and call) recordEdit, which is now gated —
206
+ // this is the exact window a fast Undo used to race into.
207
+ await vi.waitFor(() => expect(recordEdit).toHaveBeenCalled());
208
+ // No React state changes happen inside the flush itself here (no
209
+ // pending-edit-flush listeners are registered in this harness), so it
210
+ // does not need act()'s wrapping — only awaiting the delete's own
211
+ // resolution below does.
212
+ const flushPromise = flushStudioPendingEdits().then(() => order.push("flushed"));
213
+ await Promise.resolve();
214
+ await Promise.resolve();
215
+ expect(order).toEqual([]);
216
+ gate.resolve();
217
+ await deletePromise;
218
+ await flushPromise;
219
+ expect(order).toEqual(["deleted", "flushed"]);
220
+ });
221
+
222
+ it("the pending-edit flush resolves immediately once the delete has already finished", async () => {
223
+ const { gate, deleteSelection } = mountRaceHarness();
224
+ gate.resolve();
225
+ await deleteSelection();
226
+ await act(async () => {
227
+ await flushStudioPendingEdits();
228
+ });
229
+ });
230
+ });
@@ -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";
@@ -122,12 +122,16 @@ 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"]>;
125
126
  }): {
126
127
  move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
127
128
  resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"];
128
129
  groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"];
129
130
  groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"];
130
131
  del: ReturnType<typeof useTimelineEditing>["handleTimelineElementDelete"];
132
+ elementsDelete: ReturnType<typeof useTimelineEditing>["handleTimelineElementsDelete"];
133
+ handleAutoGroupCarveSources: ReturnType<typeof useTimelineEditing>["handleAutoGroupCarveSources"];
134
+ setAudioGroupAttribute: ReturnType<typeof useTimelineEditing>["setAudioGroupAttribute"];
131
135
  unmount: () => void;
132
136
  } {
133
137
  let move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"] | null = null;
@@ -135,6 +139,14 @@ function renderTimelineEditingHook(input: {
135
139
  let groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"] | null = null;
136
140
  let groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"] | null = null;
137
141
  let del: ReturnType<typeof useTimelineEditing>["handleTimelineElementDelete"] | null = null;
142
+ let elementsDelete: ReturnType<typeof useTimelineEditing>["handleTimelineElementsDelete"] | null =
143
+ null;
144
+ let handleAutoGroupCarveSources:
145
+ | ReturnType<typeof useTimelineEditing>["handleAutoGroupCarveSources"]
146
+ | null = null;
147
+ let setAudioGroupAttribute:
148
+ | ReturnType<typeof useTimelineEditing>["setAudioGroupAttribute"]
149
+ | null = null;
138
150
 
139
151
  function Harness() {
140
152
  const commitRef = useRef(input.onZIndexCommit);
@@ -155,12 +167,16 @@ function renderTimelineEditingHook(input: {
155
167
  forceReloadSdkSession: input.forceReloadSdkSession,
156
168
  invalidateGsapCache: input.invalidateGsapCache,
157
169
  handleDomZIndexReorderCommitRef: commitRef,
170
+ canEdit: input.canEdit,
158
171
  });
159
172
  move = hook.handleTimelineElementMove;
160
173
  resize = hook.handleTimelineElementResize;
161
174
  groupMove = hook.handleTimelineGroupMove;
162
175
  groupResize = hook.handleTimelineGroupResize;
163
176
  del = hook.handleTimelineElementDelete;
177
+ elementsDelete = hook.handleTimelineElementsDelete;
178
+ handleAutoGroupCarveSources = hook.handleAutoGroupCarveSources;
179
+ setAudioGroupAttribute = hook.setAudioGroupAttribute;
164
180
  return null;
165
181
  }
166
182
 
@@ -170,7 +186,20 @@ function renderTimelineEditingHook(input: {
170
186
  if (!groupMove) throw new Error("Expected hook to expose group move handler");
171
187
  if (!groupResize) throw new Error("Expected hook to expose group resize handler");
172
188
  if (!del) throw new Error("Expected hook to expose delete handler");
173
- return { move, resize, groupMove, groupResize, del, unmount };
189
+ if (!elementsDelete) throw new Error("Expected hook to expose elements-delete handler");
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
+ };
174
203
  }
175
204
 
176
205
  type TimelineRecordEdit = NonNullable<
@@ -289,6 +318,7 @@ function setupSingleClipHarness(options?: {
289
318
  source?: string;
290
319
  clipStyle?: string;
291
320
  onZIndexCommit?: (entries: ZIndexEntry[]) => Promise<void>;
321
+ canEdit?: NonNullable<Parameters<typeof useTimelineEditing>[0]["canEdit"]>;
292
322
  }) {
293
323
  const iframe = createPreviewIframe([{ id: "clip", track: 0, style: options?.clipStyle }]);
294
324
  const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
@@ -300,16 +330,30 @@ function setupSingleClipHarness(options?: {
300
330
  const fetchMock = stubProjectFetch(
301
331
  options?.source ?? '<div id="clip" data-start="0" data-track-index="0"></div>',
302
332
  );
333
+ const recordEdit = vi.fn(async () => {});
334
+ const showToast = vi.fn();
303
335
  const hook = renderTimelineEditingHook({
304
336
  timelineElements: [clip],
305
337
  iframe,
306
338
  onZIndexCommit: commit,
307
339
  projectId: "p1",
308
340
  writeProjectFile,
309
- recordEdit: vi.fn(async () => {}),
341
+ recordEdit,
310
342
  reloadPreview,
343
+ showToast,
344
+ canEdit: options?.canEdit,
311
345
  });
312
- return { iframe, clip, commit, writeProjectFile, reloadPreview, fetchMock, ...hook };
346
+ return {
347
+ iframe,
348
+ clip,
349
+ commit,
350
+ writeProjectFile,
351
+ recordEdit,
352
+ showToast,
353
+ reloadPreview,
354
+ fetchMock,
355
+ ...hook,
356
+ };
313
357
  }
314
358
 
315
359
  const SDK_KEYFRAMED_SOURCE = [
@@ -1431,3 +1475,436 @@ describe("useTimelineEditing duration rollback on failed persist", () => {
1431
1475
  succeeding.unmount();
1432
1476
  });
1433
1477
  });
1478
+
1479
+ // Blocked means no fetch write, no recordEdit, and the host's reason
1480
+ // toasted. Absent canEdit behaves exactly as before (asserted above).
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
+
1529
+ it("refuses a move with the host's reason, writing nothing", async () => {
1530
+ const { clip, move, writeProjectFile, recordEdit, showToast, unmount } = setupSingleClipHarness(
1531
+ {
1532
+ canEdit: () => ({ blocked: true, reason: "Reserved by an agent" }),
1533
+ },
1534
+ );
1535
+
1536
+ await act(async () => {
1537
+ await move(clip, { start: 3, track: clip.track });
1538
+ await flushAsyncWork();
1539
+ });
1540
+
1541
+ expect(writeProjectFile).not.toHaveBeenCalled();
1542
+ expect(recordEdit).not.toHaveBeenCalled();
1543
+ expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
1544
+ unmount();
1545
+ });
1546
+
1547
+ it("refuses a delete (single and multi) with nothing written", async () => {
1548
+ const { clip, del, elementsDelete, writeProjectFile, recordEdit, unmount } =
1549
+ setupSingleClipHarness({
1550
+ canEdit: () => ({ blocked: true, reason: "Reserved by an agent" }),
1551
+ });
1552
+
1553
+ await act(async () => {
1554
+ await del(clip);
1555
+ await elementsDelete([clip]);
1556
+ await flushAsyncWork();
1557
+ });
1558
+
1559
+ expect(writeProjectFile).not.toHaveBeenCalled();
1560
+ expect(recordEdit).not.toHaveBeenCalled();
1561
+ unmount();
1562
+ });
1563
+
1564
+ it("resolves toggle-track-hidden against the expanded rows, not the raw timelineElements prop", async () => {
1565
+ // Regression: canEdit must resolve against the expanded rows
1566
+ // (timelineTrackVisibility.ts's own invariant), not the raw
1567
+ // timelineElements prop — proven by making the two disagree below.
1568
+ const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
1569
+ const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
1570
+ usePlayerStore.getState().setElements([clip]);
1571
+ const showToast = vi.fn();
1572
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
1573
+ let hook: ReturnType<typeof useTimelineEditing> | null = null;
1574
+ function Harness() {
1575
+ hook = useTimelineEditing({
1576
+ projectId: "p1",
1577
+ activeCompPath: "index.html",
1578
+ timelineElements: [], // deliberately does not contain `clip`
1579
+ showToast,
1580
+ writeProjectFile,
1581
+ recordEdit: vi.fn(),
1582
+ reloadPreview: vi.fn(),
1583
+ previewIframeRef: { current: iframe },
1584
+ pendingTimelineEditPathRef: { current: new Set<string>() },
1585
+ uploadProjectFiles: vi.fn(),
1586
+ canEdit: (element) =>
1587
+ element.id === "clip" ? { blocked: true, reason: "Reserved by an agent" } : true,
1588
+ });
1589
+ return null;
1590
+ }
1591
+ const { unmount } = mountHarness(<Harness />);
1592
+ if (!hook) throw new Error("Expected hook to mount");
1593
+
1594
+ await act(async () => {
1595
+ await hook!.handleToggleTrackHidden(0, true);
1596
+ await flushAsyncWork();
1597
+ });
1598
+
1599
+ expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
1600
+ expect(writeProjectFile).not.toHaveBeenCalled();
1601
+ unmount();
1602
+ });
1603
+
1604
+ it("lets an unblocked element through while a blocked one is refused", async () => {
1605
+ const iframe = createPreviewIframe([
1606
+ { id: "free", track: 0 },
1607
+ { id: "locked", track: 1 },
1608
+ ]);
1609
+ const free = timelineElement({ id: "free", track: 0, zIndex: 0 });
1610
+ const locked = timelineElement({ id: "locked", track: 1, zIndex: 0 });
1611
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
1612
+ const recordEdit = vi.fn(async () => {});
1613
+ stubProjectFetch(
1614
+ '<div id="free" data-start="0" data-track-index="0"></div>' +
1615
+ '<div id="locked" data-start="0" data-track-index="1"></div>',
1616
+ );
1617
+ const hook = renderTimelineEditingHook({
1618
+ timelineElements: [free, locked],
1619
+ iframe,
1620
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
1621
+ projectId: "p1",
1622
+ writeProjectFile,
1623
+ recordEdit,
1624
+ canEdit: (element) =>
1625
+ element.id === "locked" ? { blocked: true, reason: "Reserved by an agent" } : true,
1626
+ });
1627
+
1628
+ await act(async () => {
1629
+ await hook.move(locked, { start: 3, track: locked.track });
1630
+ await flushAsyncWork();
1631
+ });
1632
+ expect(writeProjectFile).not.toHaveBeenCalled();
1633
+
1634
+ await act(async () => {
1635
+ await hook.move(free, { start: 3, track: free.track });
1636
+ await flushAsyncWork();
1637
+ });
1638
+ expect(writeProjectFile).toHaveBeenCalled();
1639
+ hook.unmount();
1640
+ });
1641
+
1642
+ it("refuses razor-split-all when it would split a blocked clip, writing nothing", async () => {
1643
+ const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
1644
+ const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 2 });
1645
+ usePlayerStore.getState().setElements([clip]);
1646
+ const showToast = vi.fn();
1647
+ const fetchMock = vi.fn(async () => {
1648
+ throw new Error("must not be called: canEdit should have refused the split");
1649
+ });
1650
+ vi.stubGlobal("fetch", fetchMock);
1651
+ let hook: ReturnType<typeof useTimelineEditing> | null = null;
1652
+ function Harness() {
1653
+ hook = useTimelineEditing({
1654
+ projectId: "p1",
1655
+ activeCompPath: "index.html",
1656
+ timelineElements: [clip],
1657
+ showToast,
1658
+ writeProjectFile: vi.fn(),
1659
+ recordEdit: vi.fn(),
1660
+ reloadPreview: vi.fn(),
1661
+ previewIframeRef: { current: iframe },
1662
+ pendingTimelineEditPathRef: { current: new Set<string>() },
1663
+ uploadProjectFiles: vi.fn(),
1664
+ canEdit: () => ({ blocked: true, reason: "Reserved by an agent" }),
1665
+ });
1666
+ return null;
1667
+ }
1668
+ const { unmount } = mountHarness(<Harness />);
1669
+ if (!hook) throw new Error("Expected hook to mount");
1670
+
1671
+ await act(async () => {
1672
+ await hook!.handleRazorSplitAll(1);
1673
+ await flushAsyncWork();
1674
+ });
1675
+
1676
+ expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
1677
+ expect(fetchMock).not.toHaveBeenCalled();
1678
+ unmount();
1679
+ });
1680
+
1681
+ it("refuses a blocked group's audio attribute write, writing nothing", async () => {
1682
+ const iframe = createPreviewIframe([{ id: "member", track: 0 }]);
1683
+ const member = timelineElement({ id: "member", track: 0, zIndex: 0 });
1684
+ usePlayerStore.getState().setElements([{ ...member, audioGroup: "hf-group" }]);
1685
+ const showToast = vi.fn();
1686
+ const fetchMock = vi.fn(async () => {
1687
+ throw new Error("must not be called: canEdit should have refused the write");
1688
+ });
1689
+ vi.stubGlobal("fetch", fetchMock);
1690
+ let hook: ReturnType<typeof useTimelineEditing> | null = null;
1691
+ function Harness() {
1692
+ hook = useTimelineEditing({
1693
+ projectId: "p1",
1694
+ activeCompPath: "index.html",
1695
+ timelineElements: [member],
1696
+ showToast,
1697
+ writeProjectFile: vi.fn(),
1698
+ recordEdit: vi.fn(),
1699
+ reloadPreview: vi.fn(),
1700
+ previewIframeRef: { current: iframe },
1701
+ pendingTimelineEditPathRef: { current: new Set<string>() },
1702
+ uploadProjectFiles: vi.fn(),
1703
+ canEdit: (element) =>
1704
+ element.id === "member" ? { blocked: true, reason: "Reserved by an agent" } : true,
1705
+ });
1706
+ return null;
1707
+ }
1708
+ const { unmount } = mountHarness(<Harness />);
1709
+ if (!hook) throw new Error("Expected hook to mount");
1710
+
1711
+ await act(async () => {
1712
+ await hook!.setAudioGroupAttribute.setQuiet("hf-group", "data-volume", "0.5", "Set volume");
1713
+ await flushAsyncWork();
1714
+ });
1715
+
1716
+ expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
1717
+ expect(fetchMock).not.toHaveBeenCalled();
1718
+ unmount();
1719
+ });
1720
+
1721
+ it("refuses a group write when its members live only inside a sub-composition", async () => {
1722
+ // syncStoredGroupAttribute mirrors into domClipChildren for a group with
1723
+ // no flat twin (timelineAudioGroupVolume.ts) — the resolver must check
1724
+ // that array too, or a sub-comp-only group's write goes ungated.
1725
+ const iframe = createPreviewIframe([]);
1726
+ usePlayerStore.getState().setElements([]);
1727
+ usePlayerStore.getState().setDomClipChildren([
1728
+ {
1729
+ id: "sub-member",
1730
+ parentId: "host",
1731
+ hostId: "host",
1732
+ label: "Sub member",
1733
+ stackingContextId: "root",
1734
+ audioGroup: "hf-group",
1735
+ },
1736
+ ]);
1737
+ const showToast = vi.fn();
1738
+ const fetchMock = vi.fn(async () => {
1739
+ throw new Error("must not be called: canEdit should have refused the write");
1740
+ });
1741
+ vi.stubGlobal("fetch", fetchMock);
1742
+ let hook: ReturnType<typeof useTimelineEditing> | null = null;
1743
+ function Harness() {
1744
+ hook = useTimelineEditing({
1745
+ projectId: "p1",
1746
+ activeCompPath: "index.html",
1747
+ timelineElements: [],
1748
+ showToast,
1749
+ writeProjectFile: vi.fn(),
1750
+ recordEdit: vi.fn(),
1751
+ reloadPreview: vi.fn(),
1752
+ previewIframeRef: { current: iframe },
1753
+ pendingTimelineEditPathRef: { current: new Set<string>() },
1754
+ uploadProjectFiles: vi.fn(),
1755
+ canEdit: (element) =>
1756
+ element.id === "sub-member" ? { blocked: true, reason: "Reserved by an agent" } : true,
1757
+ });
1758
+ return null;
1759
+ }
1760
+ const { unmount } = mountHarness(<Harness />);
1761
+ if (!hook) throw new Error("Expected hook to mount");
1762
+
1763
+ await act(async () => {
1764
+ await hook!.setAudioGroupAttribute.setQuiet("hf-group", "data-volume", "0.5", "Set volume");
1765
+ await flushAsyncWork();
1766
+ });
1767
+
1768
+ expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
1769
+ expect(fetchMock).not.toHaveBeenCalled();
1770
+ unmount();
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
+ });
1861
+ });
1862
+
1863
+ // Regression: track()/guard() must return the same wrapped handler across
1864
+ // renders for the same fn, or a consumer using it as a memo/effect
1865
+ // dependency re-runs on every render for nothing.
1866
+ describe("useTimelineEditing: handler identity is stable across renders", () => {
1867
+ it("returns the same handleTimelineElementMove reference on a re-render", () => {
1868
+ const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
1869
+ const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
1870
+ stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
1871
+ // Every option below is hoisted (created once), matching a real caller's
1872
+ // stable useCallback/selector inputs — a fresh vi.fn() per render would
1873
+ // change handleTimelineElementMove's own identity regardless of the fix.
1874
+ const timelineElements = [clip];
1875
+ const showToast = vi.fn();
1876
+ const writeProjectFile = vi.fn();
1877
+ const recordEdit = vi.fn();
1878
+ const reloadPreview = vi.fn();
1879
+ const uploadProjectFiles = vi.fn();
1880
+ const previewIframeRef = { current: iframe };
1881
+ const pendingTimelineEditPathRef = { current: new Set<string>() };
1882
+ const seen: unknown[] = [];
1883
+ let bumpTick = 0;
1884
+ let bump = () => {};
1885
+ function Harness() {
1886
+ const [, setTick] = React.useState(0);
1887
+ bump = () => setTick((t) => t + 1);
1888
+ bumpTick += 1;
1889
+ const hook = useTimelineEditing({
1890
+ projectId: "p1",
1891
+ activeCompPath: "index.html",
1892
+ timelineElements,
1893
+ showToast,
1894
+ writeProjectFile,
1895
+ recordEdit,
1896
+ reloadPreview,
1897
+ previewIframeRef,
1898
+ pendingTimelineEditPathRef,
1899
+ uploadProjectFiles,
1900
+ });
1901
+ seen.push(hook.handleTimelineElementMove);
1902
+ return null;
1903
+ }
1904
+ const { unmount } = mountHarness(<Harness />);
1905
+ act(() => bump());
1906
+ expect(bumpTick).toBeGreaterThan(1);
1907
+ expect(seen[0]).toBe(seen[1]);
1908
+ unmount();
1909
+ });
1910
+ });