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