@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.
- package/dist/assets/{hyperframes-player-PTEnAHhg.js → hyperframes-player-DKWM-Ons.js} +1 -1
- package/dist/assets/{index-BnXZuIJD.css → index-DrHQAuGO.css} +1 -1
- package/dist/assets/{index-CNyviK_l.js → index-HfP3sap2.js} +200 -200
- package/dist/assets/{index-BjslOS9g.js → index-UvW6BEwf.js} +1 -1
- package/dist/assets/{index--myXp8ui.js → index-Y4OnpTEM.js} +1 -1
- package/dist/index.d.ts +11 -406
- package/dist/index.html +2 -2
- package/dist/index.js +7570 -7709
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -0
- package/package.json +8 -7
- package/src/components/StudioHeader.tsx +1 -1
- package/src/components/nle/TimelinePane.tsx +0 -7
- package/src/components/renders/RenderQueue.test.tsx +133 -25
- package/src/components/renders/RenderQueue.tsx +99 -92
- package/src/components/renders/RenderQueueItem.tsx +69 -64
- package/src/components/ui/Select.tsx +4 -0
- package/src/headerExports.test.tsx +23 -0
- package/src/hooks/useFileManager.ts +123 -21
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/useRightPanelIntents.ts +2 -1
- package/src/hooks/useTimelineDeleteOps.test.tsx +0 -111
- package/src/hooks/useTimelineEditing.test.tsx +3 -322
- package/src/hooks/useTimelineEditing.ts +16 -103
- package/src/hooks/useTimelineEditingTypes.ts +0 -3
- package/src/index.ts +2 -37
- package/src/main.tsx +26 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +5 -26
- package/src/player/components/TimelineTrackHeader.tsx +1 -1
- package/src/player/components/timelineTheme.test.ts +4 -4
- package/src/player/components/timelineTheme.ts +30 -30
- package/src/styles/hex-baseline.json +2 -2
- package/src/styles/theme.css +0 -27
- package/src/hooks/timelineEditPermission.ts +0 -31
- package/src/hooks/useProjectFileWriter.ts +0 -150
- package/src/hooks/useTrackPendingTimelineEdit.test.tsx +0 -62
- package/src/hooks/useTrackPendingTimelineEdit.ts +0 -22
- 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
|
-
|
|
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
|
|
487
|
-
handleTimelineElementResize
|
|
488
|
-
handleToggleTrackHidden
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
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) {
|