@hyperframes/studio 0.8.79 → 0.8.81
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-C68UawR3.js +464 -0
- package/dist/assets/{index-ORZXYB0k.js → index-CMzPGv9P.js} +1 -1
- package/dist/assets/{index-CJP92F4-.js → index-CS5SImRT.js} +1 -1
- package/dist/assets/{index-DBKySIo7.js → index-hNVM9Pd2.js} +200 -197
- package/dist/index.d.ts +18 -1
- package/dist/index.html +1 -1
- package/dist/index.js +1673 -1348
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +13 -10
- package/src/components/StudioRightPanels.tsx +4 -48
- package/src/components/editor/TimelineFxPopover.test.tsx +1 -1
- package/src/components/editor/TimelineFxPopover.tsx +1 -1
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsParsing.ts +4 -0
- package/src/components/editor/useFxAudition.ts +3 -3
- package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
- package/src/components/panels/VariablesOtherCompositions.tsx +4 -4
- package/src/components/panels/VariablesPanel.test.tsx +86 -0
- package/src/components/panels/VariablesPanel.tsx +3 -3
- package/src/components/sidebar/SidebarLintButton.test.tsx +29 -0
- package/src/components/sidebar/SidebarLintButton.tsx +4 -0
- package/src/components/studioColorGradingScope.ts +5 -7
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/TimelineEditContext.tsx +2 -0
- package/src/hooks/liveLanes.ts +147 -0
- package/src/hooks/timelineAudioGroupVolume.ts +81 -47
- package/src/hooks/timelineEditPermission.ts +9 -1
- package/src/hooks/timelineEditingHelpers.test.ts +16 -0
- package/src/hooks/timelineEditingHelpers.ts +69 -43
- package/src/hooks/timelineElementFxAttribute.ts +82 -36
- package/src/hooks/useAppHotkeys.ts +1 -1
- package/src/hooks/useApplyColorGradingScope.test.tsx +53 -0
- package/src/hooks/useApplyColorGradingScope.ts +54 -0
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +125 -21
- package/src/hooks/useAutomationSelectionKeyboard.ts +21 -25
- package/src/hooks/useBlockHandlers.test.tsx +65 -0
- package/src/hooks/useBlockHandlers.ts +7 -4
- package/src/hooks/useDomEditAttributeCommits.ts +3 -17
- package/src/hooks/useDomEditSession.ts +1 -0
- package/src/hooks/useEditHistoryActions.ts +4 -3
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +49 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +15 -6
- package/src/hooks/useLintModal.test.tsx +15 -0
- package/src/hooks/useLintModal.ts +5 -2
- package/src/hooks/useProjectCompositionVariables.ts +3 -3
- package/src/hooks/useTimelineEditing.ts +19 -16
- package/src/hooks/useTimelineGroupEditing.ts +2 -1
- package/src/hooks/useToast.ts +2 -1
- package/src/player/components/ImageThumbnail.tsx +3 -12
- package/src/player/components/Player.test.ts +23 -0
- package/src/player/components/Player.tsx +4 -2
- package/src/player/components/TimelineAutomationLane.test.tsx +38 -0
- package/src/player/components/TimelineAutomationLane.tsx +18 -13
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +0 -2
- package/src/player/components/TimelineClipFades.test.tsx +24 -3
- package/src/player/components/TimelineClipFades.tsx +5 -2
- package/src/player/components/TimelineFxButton.tsx +1 -1
- package/src/player/components/TimelineGroupHeader.tsx +1 -1
- package/src/player/components/TimelineGroupRow.test.tsx +0 -1
- package/src/player/components/TimelineGroupRow.tsx +17 -11
- package/src/player/components/TimelineTrackHeader.tsx +21 -6
- package/src/player/components/groupAutomationElement.ts +9 -1
- package/src/player/components/timelineCallbacks.ts +2 -0
- package/src/player/components/timelineClipDragPreview.test.ts +41 -0
- package/src/player/components/timelineClipDragPreview.ts +15 -24
- package/src/player/components/timelineEditing.test.ts +99 -1
- package/src/player/components/timelineEditing.ts +5 -8
- package/src/player/components/timelineGroupEditing.test.ts +27 -0
- package/src/player/components/timelineGroupEditing.ts +18 -15
- package/src/player/components/timelineStackingSync.ts +5 -5
- package/src/player/components/timelineZones.ts +2 -3
- package/src/player/components/useAutomationEdgeStretch.ts +4 -4
- package/src/player/components/useAutomationLaneGestures.ts +1 -1
- package/src/player/components/useAutomationLanes.save.test.tsx +1406 -0
- package/src/player/components/useAutomationLanes.ts +58 -74
- package/src/player/lib/automationStoreSync.ts +30 -3
- package/src/player/lib/thumbnailImageDecoder.test.ts +82 -0
- package/src/player/lib/thumbnailImageDecoder.ts +73 -0
- package/src/player/lib/timelineDOM.test.ts +28 -8
- package/src/player/lib/timelineElementHelpers.ts +3 -4
- package/src/player/lib/timelineGroupInfo.ts +23 -23
- package/src/utils/studioFileHistory.ts +36 -30
- package/dist/assets/hyperframes-player-Bx_Kw4Ey.js +0 -464
- package/src/player/components/automationGestureKeys.test.ts +0 -49
- package/src/player/components/automationGestureKeys.ts +0 -49
|
@@ -5,15 +5,15 @@
|
|
|
5
5
|
* Reading lives in `automationLaneData`, shared with the row layout, which needs
|
|
6
6
|
* the lane count to reserve height.
|
|
7
7
|
*
|
|
8
|
-
* Edits
|
|
9
|
-
*
|
|
10
|
-
* read only, which is also what stops a
|
|
8
|
+
* Edits save through the timeline's gated save, as an effect preset does, so a
|
|
9
|
+
* locked clip or group refuses them. Only the selected element is editable: an
|
|
10
|
+
* unselected one still draws its envelopes read only, which is also what stops a
|
|
11
|
+
* stray drag from editing the wrong track.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
|
-
import { useCallback, useMemo
|
|
14
|
+
import { useCallback, useMemo } from "react";
|
|
14
15
|
import {
|
|
15
16
|
HF_AUDIO_AUTOMATION_ATTR,
|
|
16
|
-
serializeAutomation,
|
|
17
17
|
type HfAutomation,
|
|
18
18
|
type HfAutomationLane,
|
|
19
19
|
} from "@hyperframes/core/audio-automation";
|
|
@@ -22,22 +22,25 @@ import {
|
|
|
22
22
|
useDomEditActionsContextOptional,
|
|
23
23
|
useDomEditSelectionContextOptional,
|
|
24
24
|
} from "../../contexts/DomEditContext";
|
|
25
|
-
import {
|
|
25
|
+
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
26
|
+
import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
|
|
26
27
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
27
28
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
28
29
|
import type { AutomationSelection } from "../store/automationSelectionSlice";
|
|
30
|
+
import { automationAttrValue } from "../../components/editor/propertyPanelAutomation";
|
|
29
31
|
import { elementAutomation, elementFxChain } from "./automationLaneData";
|
|
30
|
-
import {
|
|
32
|
+
import { isGroupAutomationElement } from "./groupAutomationElement";
|
|
33
|
+
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
31
34
|
|
|
32
35
|
export interface AutomationLaneBinding {
|
|
33
36
|
automation: HfAutomation;
|
|
34
37
|
/** One entry per lane, in draw order — each gets its own row. */
|
|
35
38
|
lanes: HfAutomationLane[];
|
|
36
39
|
chain: HfAudioFxChain | null;
|
|
37
|
-
/** Continuous write while dragging; does not persist. */
|
|
38
|
-
onPreview(next: HfAutomation): void;
|
|
40
|
+
/** Continuous write while dragging; does not persist. `ended`: a cancel put the start back. */
|
|
41
|
+
onPreview(next: HfAutomation, ended?: boolean): void;
|
|
39
42
|
/** Gesture-end write; this is the one that persists and lands in undo. */
|
|
40
|
-
onCommit(next: HfAutomation): void
|
|
43
|
+
onCommit(next: HfAutomation): Promise<TimelineEditOutcome | void>;
|
|
41
44
|
/**
|
|
42
45
|
* Select this clip, which is what makes its lanes editable. A lane calls this
|
|
43
46
|
* instead of writing when it is read-only — pressing the lane is the only
|
|
@@ -46,15 +49,6 @@ export interface AutomationLaneBinding {
|
|
|
46
49
|
*/
|
|
47
50
|
onSelect(): void;
|
|
48
51
|
readOnly: boolean;
|
|
49
|
-
/**
|
|
50
|
-
* The timeline clip `onCommit`/`onPreview` will ACTUALLY persist to, which is
|
|
51
|
-
* whatever the dom-edit layer has selected — not necessarily the element this
|
|
52
|
-
* binding was made for (see `onSelect` below). Null outside an edit session or
|
|
53
|
-
* when the dom-edit selection maps to no clip. Resolved exactly the way
|
|
54
|
-
* applyDomSelection resolves it, so in a settled selection it equals the bound
|
|
55
|
-
* element's key; it lags only in the window a non-gesture caller can hit.
|
|
56
|
-
*/
|
|
57
|
-
commitTargetKey: string | null;
|
|
58
52
|
/** This element's active selection box, or null if none / it belongs to a
|
|
59
53
|
* different element. */
|
|
60
54
|
selection: AutomationSelection | null;
|
|
@@ -69,80 +63,69 @@ export interface UseAutomationLanesResult {
|
|
|
69
63
|
bind(element: TimelineElement, isSelected: boolean): AutomationLaneBinding;
|
|
70
64
|
}
|
|
71
65
|
|
|
66
|
+
const AUTOMATION_LABEL = "Edit automation";
|
|
67
|
+
|
|
68
|
+
/** A group's lanes save on the group, gated by its members; a clip's on the clip. */
|
|
69
|
+
function automationWriters(edit: TimelineEditCallbacks, element: TimelineElement) {
|
|
70
|
+
if (isGroupAutomationElement(element)) {
|
|
71
|
+
const { onSetAudioGroupAttributeLive: live, onSetAudioGroupAttributeQuiet: save } = edit;
|
|
72
|
+
if (!live || !save) return null;
|
|
73
|
+
return {
|
|
74
|
+
live: (value: string | null) => live(element.id, HF_AUDIO_AUTOMATION_ATTR, value),
|
|
75
|
+
revert: () => edit.onRevertAudioGroupAttributeLive?.(element.id, HF_AUDIO_AUTOMATION_ATTR),
|
|
76
|
+
save: (value: string | null) =>
|
|
77
|
+
save(element.id, HF_AUDIO_AUTOMATION_ATTR, value, AUTOMATION_LABEL),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const { onSetElementAttributeLive: live, onSetElementAttributeQuiet: save } = edit;
|
|
81
|
+
if (!live || !save) return null;
|
|
82
|
+
return {
|
|
83
|
+
live: (value: string | null) => live(element, HF_AUDIO_AUTOMATION_ATTR, value),
|
|
84
|
+
revert: () => edit.onRevertElementAttributeLive?.(element, HF_AUDIO_AUTOMATION_ATTR),
|
|
85
|
+
save: (value: string | null) =>
|
|
86
|
+
save(element, HF_AUDIO_AUTOMATION_ATTR, value, AUTOMATION_LABEL),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
72
90
|
export function useAutomationLanes(): UseAutomationLanesResult {
|
|
73
|
-
// One per hook instance, held across renders: a gesture spans many commits and
|
|
74
|
-
// they all have to record under the same key for undo to take the whole drag.
|
|
75
|
-
const gestureKeys = useRef(createAutomationGestureKeys());
|
|
76
91
|
// Optional: the player also runs outside Studio, where there is no edit
|
|
77
92
|
// session. There the lanes render read-only, which is the right fallback.
|
|
78
93
|
const domEdit = useDomEditActionsContextOptional();
|
|
79
|
-
const
|
|
80
|
-
const
|
|
94
|
+
const domEditSelectionRef = useDomEditSelectionContextOptional()?.domEditSelectionRef;
|
|
95
|
+
const timelineEdit = useTimelineEditContextOptional();
|
|
81
96
|
const automationSelection = usePlayerStore((s) => s.automationSelection);
|
|
82
97
|
const setAutomationSelection = usePlayerStore((s) => s.setAutomationSelection);
|
|
83
98
|
const clearAutomationSelection = usePlayerStore((s) => s.clearAutomationSelection);
|
|
84
99
|
|
|
85
|
-
// Read from the SAME render as the commit handlers below: both contexts update
|
|
86
|
-
// in one commit, so a handler and this key can never describe different
|
|
87
|
-
// moments. activeCompPath is not needed — resolveTimelineIdForSelection only
|
|
88
|
-
// uses it as a fallback for a selection with no sourceFile of its own, and
|
|
89
|
-
// DomEditSelection always carries one.
|
|
90
|
-
const commitTargetKey = useMemo(
|
|
91
|
-
() =>
|
|
92
|
-
domEditSelection ? resolveTimelineIdForSelection(domEditSelection, elements, null) : null,
|
|
93
|
-
[domEditSelection, elements],
|
|
94
|
-
);
|
|
95
|
-
|
|
96
100
|
const bind = useCallback(
|
|
97
101
|
(element: TimelineElement, isSelected: boolean): AutomationLaneBinding => {
|
|
98
102
|
const chain = elementFxChain(element);
|
|
99
103
|
const automation = elementAutomation(element);
|
|
100
104
|
const elementKey = getTimelineElementIdentity(element);
|
|
101
105
|
|
|
102
|
-
const
|
|
103
|
-
if (!domEdit || !isSelected) return;
|
|
104
|
-
const value = next.lanes.length > 0 ? serializeAutomation(next) : "";
|
|
105
|
-
// Every write of one gesture under one key, so undo takes back the whole
|
|
106
|
-
// drag rather than the last fragment history happened to keep.
|
|
107
|
-
const coalesce = persist ? gestureKeys.current.commit() : gestureKeys.current.live();
|
|
108
|
-
// Quiet, not the refreshing commit: releasing a dragged point used to
|
|
109
|
-
// reload the preview, which restarts every playing track — the same chop
|
|
110
|
-
// the live write during the drag exists to avoid. Quiet still persists
|
|
111
|
-
// and still resyncs the selection, so the next edit sees this one.
|
|
112
|
-
if (persist) {
|
|
113
|
-
void domEdit.handleDomAttributeQuietCommit(HF_AUDIO_AUTOMATION_ATTR, value, coalesce);
|
|
114
|
-
}
|
|
115
|
-
// Dragging a point writes live: no preview refresh, so the composition
|
|
116
|
-
// does not reload and restart playback on every pixel.
|
|
117
|
-
else {
|
|
118
|
-
// Preview only, because a gesture writes on every pointermove: the
|
|
119
|
-
// preview and the running audio follow the pointer, and the release
|
|
120
|
-
// below is the one write that reaches the file and the undo stack.
|
|
121
|
-
void domEdit.handleDomAttributeLiveCommit(
|
|
122
|
-
HF_AUDIO_AUTOMATION_ATTR,
|
|
123
|
-
value || null,
|
|
124
|
-
undefined,
|
|
125
|
-
{
|
|
126
|
-
coalesce,
|
|
127
|
-
previewOnly: true,
|
|
128
|
-
},
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
};
|
|
106
|
+
const writers = domEdit && isSelected ? automationWriters(timelineEdit, element) : null;
|
|
132
107
|
|
|
133
108
|
return {
|
|
134
109
|
automation,
|
|
135
110
|
lanes: automation.lanes,
|
|
136
111
|
chain,
|
|
137
|
-
onPreview: (next) =>
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
112
|
+
onPreview: (next, ended) => {
|
|
113
|
+
writers?.live(automationAttrValue(next) || null);
|
|
114
|
+
if (ended) writers?.revert();
|
|
115
|
+
},
|
|
116
|
+
onCommit: async (next) => {
|
|
117
|
+
if (!writers) return;
|
|
118
|
+
const outcome = await writers.save(automationAttrValue(next) || null);
|
|
119
|
+
// The FX panel reads the selection snapshot; a stale one makes its next
|
|
120
|
+
// edit start from the automation this one replaced.
|
|
121
|
+
const selection = domEditSelectionRef?.current;
|
|
122
|
+
if (selection) void domEdit?.refreshDomEditSelectionFromPreview(selection);
|
|
123
|
+
return outcome;
|
|
124
|
+
},
|
|
125
|
+
// Deliberately not awaited before an edit: the lane goes live when the
|
|
126
|
+
// selection lands and it re-renders as selected.
|
|
143
127
|
onSelect: () => void domEdit?.handleTimelineElementSelect(element),
|
|
144
|
-
readOnly: !
|
|
145
|
-
commitTargetKey: domEdit ? commitTargetKey : null,
|
|
128
|
+
readOnly: !writers,
|
|
146
129
|
selection: automationSelection?.elementKey === elementKey ? automationSelection : null,
|
|
147
130
|
// Not gated on `isSelected`, unlike the writes above. A selection is
|
|
148
131
|
// ephemeral store state, and the drag that draws one on a read-only lane is
|
|
@@ -159,7 +142,8 @@ export function useAutomationLanes(): UseAutomationLanesResult {
|
|
|
159
142
|
},
|
|
160
143
|
[
|
|
161
144
|
domEdit,
|
|
162
|
-
|
|
145
|
+
domEditSelectionRef,
|
|
146
|
+
timelineEdit,
|
|
163
147
|
automationSelection,
|
|
164
148
|
setAutomationSelection,
|
|
165
149
|
clearAutomationSelection,
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* an envelope afterwards writes to the preview document and the source file, and the
|
|
7
7
|
* store would go on holding the value it was born with until a reload.
|
|
8
8
|
*
|
|
9
|
-
* One reader, called
|
|
10
|
-
*
|
|
9
|
+
* One reader, called where a change lands: the resync every dom-edit attribute
|
|
10
|
+
* commit runs, and an undo or redo's soft restore (timeline saves record their own
|
|
11
|
+
* value through syncStoredElementAttribute below). It
|
|
11
12
|
* reads the preview rather than being told, because those callers know a file
|
|
12
13
|
* changed, not which attribute — and because three separate writers shipped without
|
|
13
14
|
* remembering to sync, which is what a single sink prevents.
|
|
@@ -17,7 +18,7 @@ import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
|
|
|
17
18
|
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
|
|
18
19
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
19
20
|
import { groupInfoFor } from "./timelineGroupInfo";
|
|
20
|
-
import { previewElementFinder } from "./timelineElementHelpers";
|
|
21
|
+
import { getTimelineElementIdentity, previewElementFinder } from "./timelineElementHelpers";
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Re-read every element's automation and FX-chain attributes from the preview
|
|
@@ -72,3 +73,29 @@ export function syncStoredAutomationFromPreview(doc: Document | null | undefined
|
|
|
72
73
|
return changed ? { elements } : {};
|
|
73
74
|
});
|
|
74
75
|
}
|
|
76
|
+
|
|
77
|
+
const STORED_FIELD: Record<string, "automation" | "fxChain"> = {
|
|
78
|
+
[HF_AUDIO_AUTOMATION_ATTR]: "automation",
|
|
79
|
+
[HF_AUDIO_FX_ATTR]: "fxChain",
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Record a saved automation or FX-chain value on one element's stored copy. */
|
|
83
|
+
export function syncStoredElementAttribute(
|
|
84
|
+
target: TimelineElement,
|
|
85
|
+
attr: string,
|
|
86
|
+
value: string | null,
|
|
87
|
+
): void {
|
|
88
|
+
const field = STORED_FIELD[attr];
|
|
89
|
+
if (!field) return;
|
|
90
|
+
const key = getTimelineElementIdentity(target);
|
|
91
|
+
const next = value ?? undefined;
|
|
92
|
+
usePlayerStore.setState((state) => {
|
|
93
|
+
let changed = false;
|
|
94
|
+
const elements = state.elements.map((element) => {
|
|
95
|
+
if (getTimelineElementIdentity(element) !== key || element[field] === next) return element;
|
|
96
|
+
changed = true;
|
|
97
|
+
return { ...element, [field]: next };
|
|
98
|
+
});
|
|
99
|
+
return changed ? { elements } : {};
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { decodeImageThumbnail } from "./thumbnailImageDecoder";
|
|
4
|
+
|
|
5
|
+
class TestImage {
|
|
6
|
+
static instances: TestImage[] = [];
|
|
7
|
+
naturalWidth = 180;
|
|
8
|
+
naturalHeight = 135;
|
|
9
|
+
src = "";
|
|
10
|
+
onload: (() => void) | null = null;
|
|
11
|
+
onerror: (() => void) | null = null;
|
|
12
|
+
constructor() {
|
|
13
|
+
TestImage.instances.push(this);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
TestImage.instances = [];
|
|
18
|
+
vi.stubGlobal("Image", TestImage);
|
|
19
|
+
});
|
|
20
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
21
|
+
function latest() {
|
|
22
|
+
const image = TestImage.instances.at(-1);
|
|
23
|
+
if (!image) throw new Error("missing image");
|
|
24
|
+
return image;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("timeline image thumbnails", () => {
|
|
28
|
+
it("requests a bounded local JPEG and accounts for its actual decoded dimensions", async () => {
|
|
29
|
+
const result = decodeImageThumbnail(
|
|
30
|
+
"/api/projects/p/preview/photo.jpg?v=2",
|
|
31
|
+
new AbortController().signal,
|
|
32
|
+
);
|
|
33
|
+
expect(latest().src).toBe(
|
|
34
|
+
new URL("/api/projects/p/image-thumbnail/photo.jpg?v=2", location.href).href,
|
|
35
|
+
);
|
|
36
|
+
latest().onload?.();
|
|
37
|
+
expect(await result).toEqual({
|
|
38
|
+
value: {
|
|
39
|
+
kind: "image",
|
|
40
|
+
url: new URL("/api/projects/p/image-thumbnail/photo.jpg?v=2", location.href).href,
|
|
41
|
+
aspect: 4 / 3,
|
|
42
|
+
},
|
|
43
|
+
weight: 180 * 135 * 4,
|
|
44
|
+
});
|
|
45
|
+
expect(latest().src).toBe("");
|
|
46
|
+
});
|
|
47
|
+
it.each([
|
|
48
|
+
"https://remote.test/photo.jpg",
|
|
49
|
+
"/api/projects/p/preview/image.png",
|
|
50
|
+
"/api/projects/p/preview/image.gif",
|
|
51
|
+
"/api/projects/p/preview/image.webp",
|
|
52
|
+
"/api/projects/p/preview/image.svg",
|
|
53
|
+
"/other/photo.jpg",
|
|
54
|
+
])("preserves %s", async (source) => {
|
|
55
|
+
const result = decodeImageThumbnail(source, new AbortController().signal);
|
|
56
|
+
expect(latest().src).toBe(source);
|
|
57
|
+
latest().onload?.();
|
|
58
|
+
expect((await result).value).toMatchObject({ url: source });
|
|
59
|
+
});
|
|
60
|
+
it("falls back for an older server and accounts for original dimensions", async () => {
|
|
61
|
+
const result = decodeImageThumbnail(
|
|
62
|
+
"/api/projects/p/preview/photo.jpg",
|
|
63
|
+
new AbortController().signal,
|
|
64
|
+
);
|
|
65
|
+
latest().onerror?.();
|
|
66
|
+
await Promise.resolve();
|
|
67
|
+
expect(latest().src).toBe("/api/projects/p/preview/photo.jpg");
|
|
68
|
+
latest().naturalWidth = 2560;
|
|
69
|
+
latest().naturalHeight = 1920;
|
|
70
|
+
latest().onload?.();
|
|
71
|
+
expect((await result).weight).toBe(2560 * 1920 * 4);
|
|
72
|
+
});
|
|
73
|
+
it("does not retry a cancelled request", async () => {
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
const result = decodeImageThumbnail("/api/projects/p/preview/photo.jpg", controller.signal);
|
|
76
|
+
controller.abort();
|
|
77
|
+
await expect(result).rejects.toMatchObject({ name: "AbortError" });
|
|
78
|
+
expect(TestImage.instances).toHaveLength(1);
|
|
79
|
+
expect(latest().src).toBe("");
|
|
80
|
+
expect(latest().onload).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ThumbnailLoadedResult } from "./thumbnailScheduler";
|
|
2
|
+
import { TIMELINE_VIEWPORT_BUDGETS } from "./timelineViewportBudgets";
|
|
3
|
+
|
|
4
|
+
function thumbnailSource(source: string): string {
|
|
5
|
+
const url = new URL(source, window.location.href);
|
|
6
|
+
if (
|
|
7
|
+
url.origin !== window.location.origin ||
|
|
8
|
+
!/^\/api\/projects\/[^/]+\/preview\/.+\.jpe?g$/i.test(url.pathname)
|
|
9
|
+
)
|
|
10
|
+
return source;
|
|
11
|
+
url.pathname = url.pathname.replace(
|
|
12
|
+
/^(\/api\/projects\/[^/]+)\/preview\//,
|
|
13
|
+
"$1/image-thumbnail/",
|
|
14
|
+
);
|
|
15
|
+
return url.href;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function loadImage(source: string, signal: AbortSignal): Promise<HTMLImageElement> {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const image = new Image();
|
|
21
|
+
const cleanup = () => {
|
|
22
|
+
image.onload = null;
|
|
23
|
+
image.onerror = null;
|
|
24
|
+
signal.removeEventListener("abort", onAbort);
|
|
25
|
+
};
|
|
26
|
+
const onAbort = () => {
|
|
27
|
+
cleanup();
|
|
28
|
+
image.src = "";
|
|
29
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
30
|
+
};
|
|
31
|
+
if (signal.aborted) {
|
|
32
|
+
onAbort();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
image.onload = () => {
|
|
36
|
+
cleanup();
|
|
37
|
+
resolve(image);
|
|
38
|
+
};
|
|
39
|
+
image.onerror = () => {
|
|
40
|
+
cleanup();
|
|
41
|
+
if (/\.svg($|\?)/i.test(source)) resolve(image);
|
|
42
|
+
else {
|
|
43
|
+
image.src = "";
|
|
44
|
+
reject(new Error("Image thumbnail failed to load"));
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
48
|
+
image.src = source;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function decodeImageThumbnail(
|
|
53
|
+
source: string,
|
|
54
|
+
signal: AbortSignal,
|
|
55
|
+
): Promise<ThumbnailLoadedResult> {
|
|
56
|
+
let url = thumbnailSource(source);
|
|
57
|
+
let image: HTMLImageElement;
|
|
58
|
+
try {
|
|
59
|
+
image = await loadImage(url, signal);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (signal.aborted || url === source) throw error;
|
|
62
|
+
url = source;
|
|
63
|
+
image = await loadImage(source, signal);
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
signal.throwIfAborted();
|
|
67
|
+
const width = image.naturalWidth || TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalWidth;
|
|
68
|
+
const height = image.naturalHeight || TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalHeight;
|
|
69
|
+
return { value: { kind: "image", url, aspect: width / height }, weight: width * height * 4 };
|
|
70
|
+
} finally {
|
|
71
|
+
image.src = "";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
} from "./timelineDOM";
|
|
9
9
|
import { isTimelineIgnoredElement } from "./timelineElementHelpers";
|
|
10
10
|
import { computeResizePreview } from "../components/timelineClipDragPreview";
|
|
11
|
-
import { invalidateGroupInfoCache } from "./timelineGroupInfo";
|
|
12
11
|
import type { TimelineElement } from "../store/playerStore";
|
|
13
12
|
|
|
14
13
|
function el(id: string, extra: Partial<TimelineElement> = {}): TimelineElement {
|
|
@@ -21,6 +20,32 @@ function makeDoc(html: string): Document {
|
|
|
21
20
|
return d;
|
|
22
21
|
}
|
|
23
22
|
|
|
23
|
+
describe("parseTimelineFromDOM — media in-point", () => {
|
|
24
|
+
it("reads a negative in-point as 0, as the runtime does, so a head trim keeps the clip", () => {
|
|
25
|
+
const doc = makeDoc(`
|
|
26
|
+
<div data-composition-id="root">
|
|
27
|
+
<video id="v" class="clip" data-start="2" data-duration="1" data-media-start="-1"></video>
|
|
28
|
+
</div>
|
|
29
|
+
`);
|
|
30
|
+
const element = parseTimelineFromDOM(doc, 10).find((e) => e.domId === "v")!;
|
|
31
|
+
expect(element.playbackStart).toBe(0);
|
|
32
|
+
|
|
33
|
+
const preview = computeResizePreview(
|
|
34
|
+
{
|
|
35
|
+
element,
|
|
36
|
+
edge: "start",
|
|
37
|
+
originClientX: 0,
|
|
38
|
+
previewStart: 2,
|
|
39
|
+
previewDuration: 1,
|
|
40
|
+
started: true,
|
|
41
|
+
},
|
|
42
|
+
10,
|
|
43
|
+
{ scroll: null, pps: 100, buildSnapTargets: () => [] },
|
|
44
|
+
);
|
|
45
|
+
expect(preview.previewDuration).toBe(0.9);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
24
49
|
describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
|
|
25
50
|
it("bridges a real GSAP transition marker onto both named clips", () => {
|
|
26
51
|
document.body.innerHTML = `
|
|
@@ -166,7 +191,7 @@ describe("group info cache", () => {
|
|
|
166
191
|
// changes either. Without an explicit drop, a muted group could never be
|
|
167
192
|
// unmuted: the header kept reading the cached `hidden: false` and re-wrote
|
|
168
193
|
// `data-hidden` forever.
|
|
169
|
-
it("re-reads group state
|
|
194
|
+
it("re-reads group state written earlier in the same task", () => {
|
|
170
195
|
const doc = makeDoc(`
|
|
171
196
|
<div data-composition-id="root">
|
|
172
197
|
<audio id="voice-1" data-start="0" data-duration="5" data-audio-group="voiceover"></audio>
|
|
@@ -175,15 +200,10 @@ describe("group info cache", () => {
|
|
|
175
200
|
`);
|
|
176
201
|
|
|
177
202
|
expect(parseMember(doc).audioGroupHidden).toBe(false);
|
|
178
|
-
|
|
203
|
+
// No await: the observer has not delivered either write when the next read runs.
|
|
179
204
|
doc.getElementById("voiceover")?.setAttribute("data-hidden", "");
|
|
180
|
-
expect(parseMember(doc).audioGroupHidden).toBe(false); // still the cached scan
|
|
181
|
-
|
|
182
|
-
invalidateGroupInfoCache(doc);
|
|
183
205
|
expect(parseMember(doc).audioGroupHidden).toBe(true);
|
|
184
|
-
|
|
185
206
|
doc.getElementById("voiceover")?.removeAttribute("data-hidden");
|
|
186
|
-
invalidateGroupInfoCache(doc);
|
|
187
207
|
expect(parseMember(doc).audioGroupHidden).toBe(false);
|
|
188
208
|
});
|
|
189
209
|
|
|
@@ -13,6 +13,7 @@ import { isFinitePositive } from "./playbackAdapter";
|
|
|
13
13
|
import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex";
|
|
14
14
|
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
|
15
15
|
import { readElementFades } from "@hyperframes/core/audio-fade";
|
|
16
|
+
import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
|
|
16
17
|
|
|
17
18
|
// ---------------------------------------------------------------------------
|
|
18
19
|
// Layer-reveal lift transparency
|
|
@@ -171,10 +172,8 @@ function applyPlaybackMetadataFromElement(entry: TimelineElement, el: Element):
|
|
|
171
172
|
const playbackStartValue = el.getAttribute("data-playback-start");
|
|
172
173
|
const legacyMediaStartValue = el.getAttribute("data-media-start");
|
|
173
174
|
const mediaStartValue = playbackStartValue ?? legacyMediaStartValue;
|
|
174
|
-
if (mediaStartValue != null)
|
|
175
|
-
|
|
176
|
-
if (Number.isFinite(playbackStart)) entry.playbackStart = playbackStart;
|
|
177
|
-
}
|
|
175
|
+
if (mediaStartValue != null)
|
|
176
|
+
entry.playbackStart = readMediaOffsetSeconds((n) => el.getAttribute(n));
|
|
178
177
|
if (playbackStartValue != null) entry.playbackStartAttr = "playback-start";
|
|
179
178
|
else if (legacyMediaStartValue != null) entry.playbackStartAttr = "media-start";
|
|
180
179
|
|
|
@@ -42,7 +42,24 @@ const groupInfoCache = new WeakMap<
|
|
|
42
42
|
|
|
43
43
|
/** Bumped by every observed mutation to group state in a document. */
|
|
44
44
|
const groupRevisions = new WeakMap<Document, number>();
|
|
45
|
-
const groupObservers = new
|
|
45
|
+
const groupObservers = new WeakMap<Document, MutationObserver>();
|
|
46
|
+
|
|
47
|
+
function countGroupChanges(doc: Document, records: readonly MutationRecord[]): void {
|
|
48
|
+
// Only a group element added or removed counts: childList fires for every node a playing
|
|
49
|
+
// preview inserts. Attribute records always count; the observer filter already narrowed them.
|
|
50
|
+
const relevant = records.some(
|
|
51
|
+
(record) =>
|
|
52
|
+
record.type !== "childList" ||
|
|
53
|
+
[...record.addedNodes, ...record.removedNodes].some(
|
|
54
|
+
(node) =>
|
|
55
|
+
node instanceof Element &&
|
|
56
|
+
(node.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG ||
|
|
57
|
+
node.hasAttribute(HF_AUDIO_GROUP_ATTR) ||
|
|
58
|
+
node.querySelector?.(`${HF_AUDIO_GROUP_TAG},[${HF_AUDIO_GROUP_ATTR}]`) != null),
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
if (relevant) groupRevisions.set(doc, (groupRevisions.get(doc) ?? 0) + 1);
|
|
62
|
+
}
|
|
46
63
|
|
|
47
64
|
/**
|
|
48
65
|
* Watch a document for any change to group state, so the cache expires itself.
|
|
@@ -55,28 +72,8 @@ const groupObservers = new WeakSet<Document>();
|
|
|
55
72
|
*/
|
|
56
73
|
function observeGroupState(doc: Document): void {
|
|
57
74
|
if (groupObservers.has(doc) || typeof MutationObserver === "undefined" || !doc.body) return;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// `childList` fires for EVERY node added or removed anywhere in the live
|
|
61
|
-
// preview, which on a composition that churns nodes during playback
|
|
62
|
-
// (SplitText, a typewriter, anything runtime-inserted) would expire this
|
|
63
|
-
// cache permanently and put it back to one whole-tree scan per parse. Only
|
|
64
|
-
// a group ELEMENT appearing or leaving actually changes the answer, so
|
|
65
|
-
// childList records are filtered rather than trusted; attribute records
|
|
66
|
-
// always count, because the filter below already narrowed them.
|
|
67
|
-
const relevant = records.some(
|
|
68
|
-
(record) =>
|
|
69
|
-
record.type !== "childList" ||
|
|
70
|
-
[...record.addedNodes, ...record.removedNodes].some(
|
|
71
|
-
(node) =>
|
|
72
|
-
node instanceof Element &&
|
|
73
|
-
(node.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG ||
|
|
74
|
-
node.hasAttribute(HF_AUDIO_GROUP_ATTR) ||
|
|
75
|
-
node.querySelector?.(`${HF_AUDIO_GROUP_TAG},[${HF_AUDIO_GROUP_ATTR}]`) != null),
|
|
76
|
-
),
|
|
77
|
-
);
|
|
78
|
-
if (relevant) groupRevisions.set(doc, (groupRevisions.get(doc) ?? 0) + 1);
|
|
79
|
-
});
|
|
75
|
+
const observer = new MutationObserver((records) => countGroupChanges(doc, records));
|
|
76
|
+
groupObservers.set(doc, observer);
|
|
80
77
|
observer.observe(doc.body, {
|
|
81
78
|
subtree: true,
|
|
82
79
|
childList: true,
|
|
@@ -112,6 +109,9 @@ export function invalidateGroupInfoCache(doc: Document | null | undefined): void
|
|
|
112
109
|
export function groupInfoFor(doc: Document | null | undefined, groupId: string): GroupInfo {
|
|
113
110
|
if (!doc) return { label: groupId, volume: 1, hidden: false };
|
|
114
111
|
observeGroupState(doc);
|
|
112
|
+
// A write earlier in this task has not reached the observer's callback yet.
|
|
113
|
+
const unseen = groupObservers.get(doc)?.takeRecords();
|
|
114
|
+
if (unseen?.length) countGroupChanges(doc, unseen);
|
|
115
115
|
const revision = groupRevisions.get(doc) ?? 0;
|
|
116
116
|
const cached = groupInfoCache.get(doc);
|
|
117
117
|
let info = cached && cached.revision === revision ? cached.entries : undefined;
|
|
@@ -60,7 +60,15 @@ export async function readProjectFileContent(pid: string, path: string): Promise
|
|
|
60
60
|
return data.content;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
export async function saveProjectFilesWithHistory(
|
|
63
|
+
export async function saveProjectFilesWithHistory(
|
|
64
|
+
input: SaveProjectFilesWithHistoryInput,
|
|
65
|
+
): Promise<string[]> {
|
|
66
|
+
return serializeStudioFileMutations(input.writeFile, Object.keys(input.files), () =>
|
|
67
|
+
writeProjectFilesWithHistoryInQueue(input),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function writeProjectFilesWithHistoryInQueue({
|
|
64
72
|
label,
|
|
65
73
|
coalesceKey,
|
|
66
74
|
coalesceMs,
|
|
@@ -70,39 +78,37 @@ export async function saveProjectFilesWithHistory({
|
|
|
70
78
|
recordEdit,
|
|
71
79
|
diskContent,
|
|
72
80
|
}: SaveProjectFilesWithHistoryInput): Promise<string[]> {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
snapshots[path] = { before, after };
|
|
79
|
-
}
|
|
81
|
+
const snapshots: Record<string, { before: string; after: string }> = {};
|
|
82
|
+
for (const [path, after] of Object.entries(files)) {
|
|
83
|
+
const before = await readFile(path);
|
|
84
|
+
if (before !== after) {
|
|
85
|
+
snapshots[path] = { before, after };
|
|
80
86
|
}
|
|
87
|
+
}
|
|
81
88
|
|
|
82
|
-
|
|
83
|
-
|
|
89
|
+
const changedPaths = Object.keys(snapshots);
|
|
90
|
+
if (changedPaths.length === 0) return [];
|
|
84
91
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
92
|
+
const writtenPaths: string[] = [];
|
|
93
|
+
try {
|
|
94
|
+
for (const path of changedPaths) {
|
|
95
|
+
await writeFile(path, snapshots[path].after, diskContent?.[path] ?? snapshots[path].before);
|
|
96
|
+
writtenPaths.push(path);
|
|
97
|
+
}
|
|
91
98
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
} catch (rollbackError) {
|
|
99
|
-
throw new AggregateError(
|
|
100
|
-
[error, rollbackError],
|
|
101
|
-
"Failed to save project files and rollback did not complete",
|
|
102
|
-
);
|
|
99
|
+
await recordEdit({ label, coalesceKey, coalesceMs, files: snapshots });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
try {
|
|
102
|
+
for (const path of writtenPaths.reverse()) {
|
|
103
|
+
await writeFile(path, snapshots[path].before, snapshots[path].after);
|
|
103
104
|
}
|
|
104
|
-
|
|
105
|
+
} catch (rollbackError) {
|
|
106
|
+
throw new AggregateError(
|
|
107
|
+
[error, rollbackError],
|
|
108
|
+
"Failed to save project files and rollback did not complete",
|
|
109
|
+
);
|
|
105
110
|
}
|
|
106
|
-
|
|
107
|
-
}
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
return changedPaths;
|
|
108
114
|
}
|