@hyperframes/studio 0.8.46 → 0.8.48
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-CtVX6jL_.js +464 -0
- package/dist/assets/{index-CbchRscN.js → index-B3-orIjW.js} +1 -1
- package/dist/assets/index-B9LfWNyM.css +1 -0
- package/dist/assets/index-C9gJKpqQ.js +441 -0
- package/dist/assets/{index-BywY2OM3.js → index-D_j-0PD3.js} +1 -1
- package/dist/index.d.ts +83 -26
- package/dist/index.html +2 -2
- package/dist/index.js +1118 -661
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +17 -26
- package/src/components/AskAgentModal.tsx +9 -7
- package/src/components/TimelineToolbar.tsx +26 -1
- package/src/components/editor/PropertyPanelFlat.tsx +8 -8
- package/src/components/editor/domEditing.ts +7 -1
- package/src/components/editor/domEditingAgentPrompt.test.ts +199 -0
- package/src/components/editor/domEditingAgentPrompt.ts +136 -34
- package/src/components/editor/manualEditingAvailability.test.ts +19 -0
- package/src/components/editor/manualEditingAvailability.ts +9 -5
- package/src/components/editor/propertyPanelAutomation.test.ts +82 -1
- package/src/components/editor/propertyPanelAutomation.ts +15 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
- package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
- package/src/components/editor/useVolumeAutomation.ts +23 -2
- package/src/components/panels/VariablesPanel.tsx +5 -8
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
- package/src/components/sidebar/CompositionsTab.tsx +15 -0
- package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
- package/src/components/sidebar/LeftSidebar.tsx +7 -0
- package/src/contexts/FileManagerContext.tsx +0 -3
- package/src/hooks/appHotkeysDispatch.ts +256 -0
- package/src/hooks/domEditDeleteMembers.test.ts +30 -0
- package/src/hooks/domEditDeleteMembers.ts +19 -0
- package/src/hooks/useActiveComposition.ts +69 -0
- package/src/hooks/useAppHotkeys.test.ts +23 -1
- package/src/hooks/useAppHotkeys.ts +6 -239
- package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
- package/src/hooks/useAutoOpenRootComposition.ts +33 -0
- package/src/hooks/useDomEditCommits.ts +13 -15
- package/src/hooks/useDomEditSession.test.tsx +90 -1
- package/src/hooks/useDomEditSession.ts +31 -34
- package/src/hooks/useDomSelectionTypes.ts +7 -2
- package/src/hooks/useElementLifecycleOps.ts +0 -8
- package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
- package/src/hooks/useFileManager.ts +0 -2
- package/src/hooks/useFileTree.test.tsx +214 -0
- package/src/hooks/useFileTree.ts +66 -24
- package/src/hooks/useGroupCommits.test.ts +98 -0
- package/src/hooks/useGroupCommits.ts +66 -9
- package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
- package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
- package/src/hooks/useSdkSession.lifecycle.test.tsx +76 -0
- package/src/hooks/useSdkSession.ts +21 -3
- package/src/hooks/useTimelineAssetDropOps.test.tsx +72 -0
- package/src/hooks/useTimelineAssetDropOps.ts +20 -16
- package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
- package/src/hooks/useTimelineDeleteOps.ts +103 -9
- package/src/hooks/useTimelineEditing.ts +1 -0
- package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
- package/src/hooks/useTimelineGroupEditing.ts +19 -5
- package/src/index.ts +10 -0
- package/src/player/components/Timeline.test.ts +0 -4
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
- package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
- package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/automationLaneData.test.ts +11 -3
- package/src/player/components/automationLaneData.ts +5 -4
- package/src/player/components/automationLaneGeometry.ts +15 -0
- package/src/player/components/timelineDragDrop.test.tsx +21 -4
- package/src/player/components/timelineDragDrop.ts +9 -25
- package/src/player/components/timelineGapCommit.test.ts +96 -0
- package/src/player/components/timelineGapCommit.ts +56 -11
- package/src/player/components/timelineGaps.ts +2 -2
- package/src/player/components/timelineLayout.test.ts +0 -1
- package/src/player/components/timelineLayout.ts +1 -6
- package/src/player/components/timelineZones.test.ts +20 -1
- package/src/player/components/timelineZones.ts +10 -3
- package/src/player/components/useAutomationLaneGestures.ts +2 -10
- package/src/player/components/useTimelineTrackLayout.ts +7 -6
- package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
- package/src/player/hooks/useTimelinePlayer.ts +9 -4
- package/src/player/hooks/useTimelineSyncCallbacks.ts +7 -4
- package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.test.ts +42 -0
- package/src/player/store/playerStore.ts +25 -28
- package/src/player/store/readinessSlice.ts +42 -0
- package/src/utils/razorSplitTransaction.test.ts +10 -0
- package/src/utils/razorSplitTransaction.ts +5 -0
- package/src/utils/sdkCutover.gate.test.ts +5 -4
- package/src/utils/sdkCutover.test.ts +93 -0
- package/src/utils/sdkCutover.ts +46 -25
- package/src/utils/sdkCutoverEligibility.ts +21 -0
- package/src/utils/sdkCutoverPolicy.test.ts +11 -2
- package/src/utils/sdkCutoverPolicy.ts +5 -3
- package/src/utils/sdkEditTransaction.decline.test.ts +46 -0
- package/src/utils/sdkEditTransaction.ts +22 -1
- package/src/utils/sdkResolverShadow.ts +10 -1
- package/src/utils/studioHelpers.test.ts +18 -0
- package/src/utils/studioHelpers.ts +41 -5
- package/src/utils/studioUiPreferences.test.ts +14 -0
- package/src/utils/studioUiPreferences.ts +6 -0
- package/src/utils/studioUrlState.ts +15 -7
- package/src/utils/timelineInspector.ts +10 -0
- package/dist/assets/hyperframes-player-CsV2rywT.js +0 -464
- package/dist/assets/index-DCblmTX3.js +0 -441
- package/dist/assets/index-DnRfAiK2.css +0 -1
|
@@ -26,6 +26,14 @@ interface UsePlaybackKeyboardParams {
|
|
|
26
26
|
seek: (time: number, options?: { keepPlaying?: boolean }) => void;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// The razor tool owns plain "a" (return to select) while it's armed; this
|
|
30
|
+
// can't rely on defaultPrevented since both listeners register on window and
|
|
31
|
+
// fire in an order this hook doesn't control. Shift+A and Alt+A are left for
|
|
32
|
+
// the in-point seek below, since only plain A means "exit the razor".
|
|
33
|
+
function shouldRazorClaimPlainA(e: KeyboardEvent): boolean {
|
|
34
|
+
return !e.shiftKey && !e.altKey && usePlayerStore.getState().activeTool === "razor";
|
|
35
|
+
}
|
|
36
|
+
|
|
29
37
|
export function usePlaybackKeyboard({
|
|
30
38
|
iframeRef,
|
|
31
39
|
shuttleDirectionRef,
|
|
@@ -79,6 +87,10 @@ export function usePlaybackKeyboard({
|
|
|
79
87
|
}
|
|
80
88
|
}, [play, pause]);
|
|
81
89
|
|
|
90
|
+
// Pre-existing dispatcher, already over the complexity gate before this
|
|
91
|
+
// branch's one-line "a" fix touched it. A real fix is a dispatch-table
|
|
92
|
+
// rewrite, out of scope here; the branch this PR changed is its own function.
|
|
93
|
+
// fallow-ignore-next-line complexity
|
|
82
94
|
const handlePlaybackKeyDown = useCallback(
|
|
83
95
|
(e: KeyboardEvent) => {
|
|
84
96
|
if (e.defaultPrevented) return;
|
|
@@ -161,6 +173,7 @@ export function usePlaybackKeyboard({
|
|
|
161
173
|
return;
|
|
162
174
|
}
|
|
163
175
|
if (key === "a") {
|
|
176
|
+
if (shouldRazorClaimPlainA(e)) return;
|
|
164
177
|
e.preventDefault();
|
|
165
178
|
seek(usePlayerStore.getState().inPoint ?? 0, { keepPlaying: true });
|
|
166
179
|
return;
|
|
@@ -45,6 +45,7 @@ import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../li
|
|
|
45
45
|
import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
|
|
46
46
|
import { createPreviewMessageHandler } from "./previewMessageRouter";
|
|
47
47
|
import { timelineElementsChanged } from "./timelinePlayerSync";
|
|
48
|
+
import { safeContentDocument } from "./timelineSyncHydration";
|
|
48
49
|
|
|
49
50
|
export function useTimelinePlayer() {
|
|
50
51
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
@@ -60,7 +61,7 @@ export function useTimelinePlayer() {
|
|
|
60
61
|
const staticSeekAdapterRef = useRef<StaticSeekCacheEntry | null>(null);
|
|
61
62
|
const staticSeekWarnedRef = useRef(false);
|
|
62
63
|
|
|
63
|
-
const { setIsPlaying, setCurrentTime, setDuration,
|
|
64
|
+
const { setIsPlaying, setCurrentTime, setDuration, requestTimelineReady, setElements } =
|
|
64
65
|
usePlayerStore.getState();
|
|
65
66
|
|
|
66
67
|
// The fixture lease belongs at this shared synchronization boundary so every
|
|
@@ -101,7 +102,11 @@ export function useTimelinePlayer() {
|
|
|
101
102
|
setDuration(nextDuration ?? 0);
|
|
102
103
|
}
|
|
103
104
|
if (!state.timelineReady) {
|
|
104
|
-
|
|
105
|
+
// Same gate as initializeAdapter's own readiness wait: this is the
|
|
106
|
+
// message-based fallback for compositions that report their duration
|
|
107
|
+
// via clip manifest rather than the direct adapter, and it must not
|
|
108
|
+
// enable Play any earlier than that path does.
|
|
109
|
+
requestTimelineReady(safeContentDocument(iframeRef.current));
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
// Asynchronously enrich media elements still missing sourceDuration
|
|
@@ -120,7 +125,7 @@ export function useTimelinePlayer() {
|
|
|
120
125
|
},
|
|
121
126
|
);
|
|
122
127
|
},
|
|
123
|
-
[setElements,
|
|
128
|
+
[setElements, requestTimelineReady, setDuration],
|
|
124
129
|
);
|
|
125
130
|
|
|
126
131
|
// Pre-existing dispatcher complexity — surfaced by this PR's line shifts, not new logic.
|
|
@@ -425,7 +430,7 @@ export function useTimelinePlayer() {
|
|
|
425
430
|
syncTimelineElements,
|
|
426
431
|
setDuration,
|
|
427
432
|
setCurrentTime,
|
|
428
|
-
|
|
433
|
+
requestTimelineReady,
|
|
429
434
|
setIsPlaying,
|
|
430
435
|
attachIframeShortcutListeners,
|
|
431
436
|
applyPreviewAudioState,
|
|
@@ -42,7 +42,7 @@ interface UseTimelineSyncCallbacksParams {
|
|
|
42
42
|
syncTimelineElements: (elements: TimelineElement[], nextDuration?: number) => void;
|
|
43
43
|
setDuration: (v: number) => void;
|
|
44
44
|
setCurrentTime: (v: number) => void;
|
|
45
|
-
|
|
45
|
+
requestTimelineReady: (doc: Document | null) => void;
|
|
46
46
|
setIsPlaying: (v: boolean) => void;
|
|
47
47
|
attachIframeShortcutListeners: () => void;
|
|
48
48
|
applyPreviewAudioState: () => void;
|
|
@@ -106,7 +106,7 @@ export function useTimelineSyncCallbacks({
|
|
|
106
106
|
syncTimelineElements,
|
|
107
107
|
setDuration,
|
|
108
108
|
setCurrentTime,
|
|
109
|
-
|
|
109
|
+
requestTimelineReady,
|
|
110
110
|
setIsPlaying,
|
|
111
111
|
attachIframeShortcutListeners,
|
|
112
112
|
applyPreviewAudioState,
|
|
@@ -204,7 +204,10 @@ export function useTimelineSyncCallbacks({
|
|
|
204
204
|
syncAdapterDuration(adapter, setDuration);
|
|
205
205
|
setCurrentTime(startTime);
|
|
206
206
|
if (!isRefreshingRef.current) {
|
|
207
|
-
|
|
207
|
+
// Enables Play from actual play-readiness, not just a known duration —
|
|
208
|
+
// a click before this resolves used to start the timeline with media,
|
|
209
|
+
// images or fonts still loading and never recover.
|
|
210
|
+
requestTimelineReady(safeContentDocument(iframeRef.current));
|
|
208
211
|
}
|
|
209
212
|
isRefreshingRef.current = false;
|
|
210
213
|
setIsPlaying(false);
|
|
@@ -223,7 +226,7 @@ export function useTimelineSyncCallbacks({
|
|
|
223
226
|
getAdapter,
|
|
224
227
|
setDuration,
|
|
225
228
|
setCurrentTime,
|
|
226
|
-
|
|
229
|
+
requestTimelineReady,
|
|
227
230
|
setIsPlaying,
|
|
228
231
|
processTimelineMessage,
|
|
229
232
|
enrichMissingCompositions,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
+
// fallow-ignore-file code-duplication
|
|
2
3
|
import { describe, expect, it, vi } from "vitest";
|
|
3
4
|
import {
|
|
4
5
|
applyPreviewAudioFlags,
|
|
@@ -54,6 +55,27 @@ describe("buildMissingCompositionElements — hfId (R7)", () => {
|
|
|
54
55
|
expect(entry).toBeDefined();
|
|
55
56
|
expect(entry?.hfId).toBeUndefined();
|
|
56
57
|
});
|
|
58
|
+
|
|
59
|
+
it("carries the resolved track onto authoredTrack, so splitting a recovered composition host can't drift to a new row", () => {
|
|
60
|
+
const doc = makeDoc(`
|
|
61
|
+
<div data-composition-id="root">
|
|
62
|
+
<div
|
|
63
|
+
data-composition-id="scene-c"
|
|
64
|
+
data-composition-src="scenes/c.html"
|
|
65
|
+
data-track-index="2"
|
|
66
|
+
data-start="0"
|
|
67
|
+
data-duration="5"
|
|
68
|
+
></div>
|
|
69
|
+
</div>
|
|
70
|
+
`);
|
|
71
|
+
|
|
72
|
+
const { missing } = buildMissingCompositionElements(doc, window as IframeWindow, [], 10);
|
|
73
|
+
const entry = missing[0];
|
|
74
|
+
|
|
75
|
+
expect(entry).toBeDefined();
|
|
76
|
+
expect(entry?.track).toBe(2);
|
|
77
|
+
expect(entry?.authoredTrack).toBe(2);
|
|
78
|
+
});
|
|
57
79
|
});
|
|
58
80
|
|
|
59
81
|
describe("setPreviewMediaVolume", () => {
|
|
@@ -452,6 +452,7 @@ function buildMissingCompositionEntry(params: {
|
|
|
452
452
|
start: window.start,
|
|
453
453
|
duration: window.duration,
|
|
454
454
|
track: timing.trackIndex,
|
|
455
|
+
authoredTrack: timing.trackIndex,
|
|
455
456
|
domId: optionalNonEmpty(element.id),
|
|
456
457
|
hfId: optionalNonEmpty(element.getAttribute("data-hf-id")),
|
|
457
458
|
selector,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
1
2
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
3
|
import { usePlayerStore, liveTime, type TimelineElement } from "./playerStore";
|
|
3
4
|
|
|
@@ -322,6 +323,47 @@ describe("usePlayerStore", () => {
|
|
|
322
323
|
});
|
|
323
324
|
});
|
|
324
325
|
|
|
326
|
+
describe("requestTimelineReady", () => {
|
|
327
|
+
function stalledVideoDoc(): { doc: Document; video: HTMLVideoElement } {
|
|
328
|
+
const doc = document.implementation.createHTMLDocument("composition");
|
|
329
|
+
const video = doc.createElement("video");
|
|
330
|
+
Object.defineProperty(video, "readyState", { value: 0, configurable: true });
|
|
331
|
+
doc.body.appendChild(video);
|
|
332
|
+
return { doc, video };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
it("resolves timelineReady once the doc's media settles", async () => {
|
|
336
|
+
const { doc, video } = stalledVideoDoc();
|
|
337
|
+
usePlayerStore.getState().requestTimelineReady(doc);
|
|
338
|
+
expect(usePlayerStore.getState().timelineReady).toBe(false);
|
|
339
|
+
|
|
340
|
+
video.dispatchEvent(new Event("canplay"));
|
|
341
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
342
|
+
|
|
343
|
+
expect(usePlayerStore.getState().timelineReady).toBe(true);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("a stale wait from a project switched away from never marks the new project ready", async () => {
|
|
347
|
+
// beginTimelineSession no-ops when projectId is unchanged, so pin a
|
|
348
|
+
// known starting project first rather than relying on reset()'s
|
|
349
|
+
// leftover timelineProjectId from whatever test ran before this one.
|
|
350
|
+
usePlayerStore.getState().beginTimelineSession("readiness-test-project-a");
|
|
351
|
+
const { doc: docA, video: videoA } = stalledVideoDoc();
|
|
352
|
+
usePlayerStore.getState().requestTimelineReady(docA);
|
|
353
|
+
|
|
354
|
+
// Switches projects mid-wait — beginTimelineSession resets timelineReady
|
|
355
|
+
// to false for project B, whose own media has not been checked at all.
|
|
356
|
+
usePlayerStore.getState().beginTimelineSession("readiness-test-project-b");
|
|
357
|
+
expect(usePlayerStore.getState().timelineReady).toBe(false);
|
|
358
|
+
|
|
359
|
+
// A's wait settles late; it must not resolve into B's readiness.
|
|
360
|
+
videoA.dispatchEvent(new Event("canplay"));
|
|
361
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
362
|
+
|
|
363
|
+
expect(usePlayerStore.getState().timelineReady).toBe(false);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
325
367
|
describe("setElements", () => {
|
|
326
368
|
it("sets the elements array", () => {
|
|
327
369
|
const elements: TimelineElement[] = [
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { createEditingModeSlice, type EditingModeSlice } from "./editingModeSlice";
|
|
20
20
|
import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState";
|
|
21
21
|
import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice";
|
|
22
|
+
import { createPlaybackReadinessSlice, resetPlaybackReadinessState } from "./readinessSlice";
|
|
22
23
|
|
|
23
24
|
export type { KeyframeCacheEntry } from "./keyframeSlice";
|
|
24
25
|
export { liveTime } from "./liveTime";
|
|
@@ -54,12 +55,15 @@ function resolveElementSelection(
|
|
|
54
55
|
};
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
type PlayerStoreSlices = KeyframeSlice &
|
|
59
|
+
AutomationSelectionSlice &
|
|
60
|
+
ThumbnailSlice &
|
|
61
|
+
EditingModeSlice &
|
|
62
|
+
ReturnType<typeof createPlaybackReadinessSlice>;
|
|
63
|
+
interface PlayerState extends PlayerStoreSlices {
|
|
59
64
|
isPlaying: boolean;
|
|
60
65
|
currentTime: number;
|
|
61
66
|
duration: number;
|
|
62
|
-
timelineReady: boolean;
|
|
63
67
|
/** Increments exactly once when the Studio switches to a different project. */
|
|
64
68
|
timelineSessionEpoch: number;
|
|
65
69
|
/** Project owning the current timeline session; null outside a project-scoped reset. */
|
|
@@ -104,14 +108,14 @@ interface PlayerState
|
|
|
104
108
|
/** Timeline magnet toggle — when false, clip drags/trims/drops never snap. */
|
|
105
109
|
timelineSnapEnabled: boolean;
|
|
106
110
|
setTimelineSnapEnabled: (enabled: boolean) => void;
|
|
111
|
+
/** Keeps the main track gapless on delete; distinct from the magnet above. */
|
|
112
|
+
rippleEditEnabled: boolean;
|
|
113
|
+
setRippleEditEnabled: (enabled: boolean) => void;
|
|
107
114
|
/** Transport + ruler readout: timecode ("time") or frame number ("frame"). */
|
|
108
115
|
timeDisplayMode: TimelineTimeDisplayMode;
|
|
109
116
|
setTimeDisplayMode: (mode: TimelineTimeDisplayMode) => void;
|
|
110
|
-
/**
|
|
111
|
-
*
|
|
112
|
-
* edit, so a subsequent duration change (which recomputes fit-pps) stops
|
|
113
|
-
* rescaling every clip. No-op once already pinned (mode is "manual").
|
|
114
|
-
*/
|
|
117
|
+
/** Pin the timeline zoom to its current scale before a duration change, so
|
|
118
|
+
* it stops rescaling every clip. No-op once already pinned. */
|
|
115
119
|
pinTimelineZoom: (currentPixelsPerSecond: number, fitPixelsPerSecond: number) => void;
|
|
116
120
|
/** The timeline's live pixels-per-second + fit basis, published by <Timeline>. */
|
|
117
121
|
timelinePps: number;
|
|
@@ -129,7 +133,6 @@ interface PlayerState
|
|
|
129
133
|
setAudioMuted: (muted: boolean) => void;
|
|
130
134
|
setAudioVolume: (volume: number) => void;
|
|
131
135
|
setLoopEnabled: (enabled: boolean) => void;
|
|
132
|
-
setTimelineReady: (ready: boolean) => void;
|
|
133
136
|
setBeatDragging: (dragging: boolean) => void;
|
|
134
137
|
setElements: (elements: TimelineElement[]) => void;
|
|
135
138
|
setSelectedElementId: (id: string | null, options?: SelectElementOptions) => void;
|
|
@@ -146,26 +149,16 @@ interface PlayerState
|
|
|
146
149
|
/** Clears project data without creating a new hard-project session. */
|
|
147
150
|
reset: () => void;
|
|
148
151
|
|
|
149
|
-
/**
|
|
150
|
-
*
|
|
151
|
-
* useTimelinePlayer subscribes and calls adapter.seek() + liveTime.notify().
|
|
152
|
-
*/
|
|
152
|
+
/** Request a seek from outside the player loop (e.g. Layers panel);
|
|
153
|
+
* useTimelinePlayer subscribes and calls adapter.seek() + liveTime.notify(). */
|
|
153
154
|
requestedSeekTime: number | null;
|
|
154
155
|
requestSeek: (time: number) => void;
|
|
155
156
|
clearSeekRequest: () => void;
|
|
156
157
|
|
|
157
|
-
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* silent while the transport is paused — so hovering one has to start
|
|
162
|
-
* playback, and leaving has to put the playhead back where it was. Hovering is
|
|
163
|
-
* not an edit and must not cost the author their place.
|
|
164
|
-
*
|
|
165
|
-
* A nonce rather than a bare boolean: two hovers in a row both want play, and
|
|
166
|
-
* without it the second request is indistinguishable from the first having
|
|
167
|
-
* already been served.
|
|
168
|
-
*/
|
|
158
|
+
/** Request the transport start or stop from outside the player loop: the FX
|
|
159
|
+
* rack starts playback to audition a preset (silent while paused) and
|
|
160
|
+
* restores the playhead on leave, without costing the author their place.
|
|
161
|
+
* A nonce, not a bare boolean, so two hovers in a row both register. */
|
|
169
162
|
playbackRequest: { playing: boolean; returnTo: number | null; nonce: number } | null;
|
|
170
163
|
requestPlayback: (playing: boolean, returnTo?: number | null) => void;
|
|
171
164
|
clearPlaybackRequest: () => void;
|
|
@@ -260,7 +253,7 @@ export function createTimelineResetState() {
|
|
|
260
253
|
isPlaying: false,
|
|
261
254
|
currentTime: 0,
|
|
262
255
|
duration: 0,
|
|
263
|
-
|
|
256
|
+
...resetPlaybackReadinessState(),
|
|
264
257
|
beatDragging: false,
|
|
265
258
|
elements: [],
|
|
266
259
|
selectedElementId: null,
|
|
@@ -304,7 +297,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
304
297
|
isPlaying: false,
|
|
305
298
|
currentTime: 0,
|
|
306
299
|
duration: 0,
|
|
307
|
-
timelineReady: false,
|
|
308
300
|
timelineSessionEpoch: 0,
|
|
309
301
|
timelineProjectId: null,
|
|
310
302
|
beatDragging: false,
|
|
@@ -333,6 +325,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
333
325
|
|
|
334
326
|
...createAutomationSelectionSlice(set),
|
|
335
327
|
...createEditingModeSlice(set),
|
|
328
|
+
...createPlaybackReadinessSlice(set),
|
|
336
329
|
|
|
337
330
|
activeKeyframePct: null,
|
|
338
331
|
setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
|
|
@@ -472,6 +465,11 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
472
465
|
writeStudioUiPreferences({ timelineSnapEnabled: enabled });
|
|
473
466
|
set({ timelineSnapEnabled: enabled });
|
|
474
467
|
},
|
|
468
|
+
rippleEditEnabled: readStudioUiPreferences().rippleEditEnabled ?? true, // default on
|
|
469
|
+
setRippleEditEnabled: (enabled) => {
|
|
470
|
+
writeStudioUiPreferences({ rippleEditEnabled: enabled });
|
|
471
|
+
set({ rippleEditEnabled: enabled });
|
|
472
|
+
},
|
|
475
473
|
timeDisplayMode: readStudioUiPreferences().timeDisplayMode ?? "time",
|
|
476
474
|
setTimeDisplayMode: (mode) => {
|
|
477
475
|
writeStudioUiPreferences({ timeDisplayMode: mode });
|
|
@@ -521,7 +519,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
521
519
|
bumpZEditVersion: () => set((state) => ({ zEditVersion: state.zEditVersion + 1 })),
|
|
522
520
|
setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }),
|
|
523
521
|
setDuration: (duration) => set({ duration: Number.isFinite(duration) ? duration : 0 }),
|
|
524
|
-
setTimelineReady: (ready) => set({ timelineReady: ready }),
|
|
525
522
|
setBeatDragging: (dragging) => set({ beatDragging: dragging }),
|
|
526
523
|
setElements: (elements) => set({ elements }),
|
|
527
524
|
// A genuine single selection: always collapse the set to just this element. User
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Gates `timelineReady` on the composition's declared readiness inputs
|
|
2
|
+
* (media, for now) instead of just a known duration. The generation counter
|
|
3
|
+
* is module-scope, not store state: it guards an in-flight settlement, not
|
|
4
|
+
* something a component reads, so bumping it shouldn't trigger a render. */
|
|
5
|
+
import type { StoreApi } from "zustand";
|
|
6
|
+
import { settleCompositionReadiness } from "@hyperframes/core/composition-readiness";
|
|
7
|
+
|
|
8
|
+
export interface PlaybackReadinessSlice {
|
|
9
|
+
timelineReady: boolean;
|
|
10
|
+
setTimelineReady: (ready: boolean) => void;
|
|
11
|
+
/** Sets timelineReady once doc's readiness inputs settle, or immediately
|
|
12
|
+
* if doc is null. A wait a later call supersedes never wins the race. */
|
|
13
|
+
requestTimelineReady: (doc: Document | null) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let timelineReadyGeneration = 0;
|
|
17
|
+
|
|
18
|
+
/** For a full timeline reset: bumps the generation so any requestTimelineReady
|
|
19
|
+
* wait in flight can never resolve into what replaced it. */
|
|
20
|
+
export function resetPlaybackReadinessState(): Pick<PlaybackReadinessSlice, "timelineReady"> {
|
|
21
|
+
timelineReadyGeneration++;
|
|
22
|
+
return { timelineReady: false };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createPlaybackReadinessSlice(
|
|
26
|
+
set: StoreApi<PlaybackReadinessSlice>["setState"],
|
|
27
|
+
): PlaybackReadinessSlice {
|
|
28
|
+
return {
|
|
29
|
+
timelineReady: false,
|
|
30
|
+
setTimelineReady: (ready) => {
|
|
31
|
+
timelineReadyGeneration++;
|
|
32
|
+
set({ timelineReady: ready });
|
|
33
|
+
},
|
|
34
|
+
requestTimelineReady: (doc) => {
|
|
35
|
+
const generation = ++timelineReadyGeneration;
|
|
36
|
+
if (!doc) return set({ timelineReady: true });
|
|
37
|
+
settleCompositionReadiness(doc, () => {
|
|
38
|
+
if (generation === timelineReadyGeneration) set({ timelineReady: true });
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -37,6 +37,16 @@ describe("buildAtomicCutIntents", () => {
|
|
|
37
37
|
expect(intents[0].targets.map((target) => target.originalId)).toEqual(["host-a", "host-b"]);
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
+
it("carries the element's authored track onto the cut target, so both split halves stay pinned to it server-side", () => {
|
|
41
|
+
const intents = buildAtomicCutIntents([element({ authoredTrack: 2 })], 2, "index.html");
|
|
42
|
+
expect(intents[0].targets[0].track).toBe(2);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("falls back to the resolved track when the element has no authoredTrack", () => {
|
|
46
|
+
const intents = buildAtomicCutIntents([element({ track: 3 })], 2, "index.html");
|
|
47
|
+
expect(intents[0].targets[0].track).toBe(3);
|
|
48
|
+
});
|
|
49
|
+
|
|
40
50
|
it("rebases each nested target into its own source-file coordinates", () => {
|
|
41
51
|
const intents = buildAtomicCutIntents(
|
|
42
52
|
[element({ start: 8, duration: 4, expandedParentStart: 6, sourceFile: "scene.html" })],
|
|
@@ -4,6 +4,7 @@ import { buildPatchTarget } from "./timelineElementSplit";
|
|
|
4
4
|
import { serializeStudioFileMutations } from "./studioFileMutationCoordinator";
|
|
5
5
|
import { buildProjectApiPath } from "./projectRouting";
|
|
6
6
|
import { markStudioWriteToken } from "./studioFileVersion";
|
|
7
|
+
import { resolveElementTrack } from "./studioHelpers";
|
|
7
8
|
|
|
8
9
|
type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
9
10
|
|
|
@@ -16,6 +17,7 @@ interface CutTarget {
|
|
|
16
17
|
playbackStart?: number;
|
|
17
18
|
playbackRate?: number;
|
|
18
19
|
isComposition?: boolean;
|
|
20
|
+
track?: number;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
interface CutFileIntent {
|
|
@@ -69,6 +71,9 @@ function buildCutTarget(
|
|
|
69
71
|
...(element.playbackStart != null ? { playbackStart: element.playbackStart } : {}),
|
|
70
72
|
...(element.playbackRate != null ? { playbackRate: element.playbackRate } : {}),
|
|
71
73
|
...(element.kind === "composition" ? { isComposition: true } : {}),
|
|
74
|
+
// Pin both halves to the current track: unstamped, the runtime's positional
|
|
75
|
+
// fallback (parseAuthoredTrack) renumbers the new sibling onto a new row.
|
|
76
|
+
track: resolveElementTrack(element),
|
|
72
77
|
};
|
|
73
78
|
}
|
|
74
79
|
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
-
//
|
|
3
|
+
// Kill-switch contract: with VITE_STUDIO_SDK_CUTOVER_ENABLED=false, EVERY cutover
|
|
4
4
|
// persist chokepoint must explicitly decline so the caller takes the legacy server
|
|
5
5
|
// path — even when a valid SDK session exists (one always does, for
|
|
6
|
-
// shadow/selection).
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// shadow/selection). Default is ON (manualEditingAvailability.test.ts pins that);
|
|
7
|
+
// this file pins that the env override still turns it fully off. A future
|
|
8
|
+
// refactor of the gate guards that ignores the flag turns these red.
|
|
9
|
+
// (sdkCutover.test.ts mocks the flag TRUE; this is its sibling.)
|
|
9
10
|
vi.mock("../components/editor/manualEditingAvailability", () => ({
|
|
10
11
|
STUDIO_SDK_CUTOVER_ENABLED: false,
|
|
11
12
|
STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
|
|
@@ -23,6 +23,8 @@ vi.mock("./studioTelemetry", () => ({
|
|
|
23
23
|
trackStudioEvent: vi.fn(),
|
|
24
24
|
}));
|
|
25
25
|
|
|
26
|
+
import { trackStudioEvent } from "./studioTelemetry";
|
|
27
|
+
|
|
26
28
|
const styleOp = (property: string, value: string): PatchOperation => ({
|
|
27
29
|
type: "inline-style",
|
|
28
30
|
property,
|
|
@@ -202,6 +204,46 @@ describe("sdkCutoverPersist", () => {
|
|
|
202
204
|
expect(result.status).toBe("declined");
|
|
203
205
|
});
|
|
204
206
|
|
|
207
|
+
it("tags target_not_found with resolverDisagreement when dispatch could have resolved it", async () => {
|
|
208
|
+
// getElement is canonical-only for a bare id; resolveSnapshot mirrors what
|
|
209
|
+
// dispatch resolves (bare ids anywhere). An element present under a scoped
|
|
210
|
+
// id is dispatchable, so getElement refusing it is a resolver disagreement
|
|
211
|
+
// — and one the shadow event stays silent about.
|
|
212
|
+
const deps = makeDeps();
|
|
213
|
+
const session = makeSession(false);
|
|
214
|
+
(session as unknown as { getElements: () => unknown[] }).getElements = () => [
|
|
215
|
+
{ id: "hf-abc", scopedId: "host/hf-abc" },
|
|
216
|
+
];
|
|
217
|
+
const sel = { hfId: "hf-abc" } as never;
|
|
218
|
+
|
|
219
|
+
await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/path.html", session, deps);
|
|
220
|
+
|
|
221
|
+
expect(trackStudioEvent).toHaveBeenCalledWith(
|
|
222
|
+
"sdk_cutover_declined",
|
|
223
|
+
expect.objectContaining({ reason: "target_not_found", resolverDisagreement: true }),
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("does not tag resolverDisagreement when the element is genuinely absent", async () => {
|
|
228
|
+
const deps = makeDeps();
|
|
229
|
+
const session = makeSession(false);
|
|
230
|
+
(session as unknown as { getElements: () => unknown[] }).getElements = () => [
|
|
231
|
+
{ id: "hf-other", scopedId: "hf-other" },
|
|
232
|
+
];
|
|
233
|
+
const sel = { hfId: "hf-abc" } as never;
|
|
234
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
235
|
+
|
|
236
|
+
await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/path.html", session, deps);
|
|
237
|
+
|
|
238
|
+
expect(trackStudioEvent).toHaveBeenLastCalledWith(
|
|
239
|
+
"sdk_cutover_declined",
|
|
240
|
+
expect.objectContaining({ reason: "target_not_found" }),
|
|
241
|
+
);
|
|
242
|
+
expect(vi.mocked(trackStudioEvent).mock.lastCall?.[1]).not.toHaveProperty(
|
|
243
|
+
"resolverDisagreement",
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
205
247
|
it("dispatches setStyle for inline-style ops", async () => {
|
|
206
248
|
const deps = makeDeps();
|
|
207
249
|
const session = makeSession(true);
|
|
@@ -607,6 +649,57 @@ window.__timelines = { main: tl };</script></div>
|
|
|
607
649
|
for (const candidate of published) candidate.dispose();
|
|
608
650
|
});
|
|
609
651
|
|
|
652
|
+
it("does not resurrect an element a prior REST write already deleted, even with a stale live session", async () => {
|
|
653
|
+
// A delete persists via the server REST path, then a same-gesture ripple
|
|
654
|
+
// move reaches an SDK-eligible batch persist while `live` was never
|
|
655
|
+
// reloaded. Prove the candidate rebases on the post-delete disk bytes.
|
|
656
|
+
const twoElementHtml = `<!DOCTYPE html><html data-composition-variables='[]'><body>
|
|
657
|
+
<div data-hf-id="hf-stage" data-hf-root>
|
|
658
|
+
<div data-hf-id="hf-a" data-start="0" data-duration="2"></div>
|
|
659
|
+
<div data-hf-id="hf-b" data-start="5" data-duration="2"></div>
|
|
660
|
+
</div>
|
|
661
|
+
</body></html>`;
|
|
662
|
+
const live = await openComposition(twoElementHtml, { history: false });
|
|
663
|
+
|
|
664
|
+
// Disk already reflects hf-b's deletion — a separate write that completed
|
|
665
|
+
// before this persist started, exactly like the delete's own REST call
|
|
666
|
+
// that `handleTimelineElementsDelete` awaits before the ripple begins.
|
|
667
|
+
const postDelete = await openComposition(twoElementHtml, { history: false });
|
|
668
|
+
postDelete.removeElement("hf-b");
|
|
669
|
+
let disk = postDelete.serialize();
|
|
670
|
+
postDelete.dispose();
|
|
671
|
+
expect(disk).not.toContain("hf-b");
|
|
672
|
+
|
|
673
|
+
const writeProjectFile = vi.fn(async (_path: string, content: string) => {
|
|
674
|
+
disk = content;
|
|
675
|
+
});
|
|
676
|
+
const deps = {
|
|
677
|
+
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
|
678
|
+
writeProjectFile,
|
|
679
|
+
readProjectFile: vi.fn(async () => disk),
|
|
680
|
+
reloadPreview: vi.fn(),
|
|
681
|
+
publishSession: vi.fn().mockReturnValue("published"),
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
// The ripple: only survivors move, mirroring resolveShiftedElements — this
|
|
685
|
+
// never touches hf-b, which `live` (unlike disk) still believes exists.
|
|
686
|
+
const result = await persistSdkCandidateMutation(
|
|
687
|
+
live,
|
|
688
|
+
"/comp.html",
|
|
689
|
+
twoElementHtml,
|
|
690
|
+
deps,
|
|
691
|
+
(candidate) => candidate.setTiming("hf-a", { start: 3 }),
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
expect(result.status).toBe("committed");
|
|
695
|
+
expect(disk).not.toContain("hf-b");
|
|
696
|
+
const written = await openComposition(disk, { history: false });
|
|
697
|
+
expect(written.getElement("hf-a")?.start).toBe(3);
|
|
698
|
+
expect(written.getElement("hf-b")).toBeNull();
|
|
699
|
+
written.dispose();
|
|
700
|
+
live.dispose();
|
|
701
|
+
});
|
|
702
|
+
|
|
610
703
|
it("fails instead of cloning stale bytes when the authoritative queued read rejects", async () => {
|
|
611
704
|
const live = await openComposition(html, { history: false });
|
|
612
705
|
let disk = html;
|