@hyperframes/studio 0.8.6 → 0.8.8
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-DdNgW-Np.js → hyperframes-player-DYTqZd0d.js} +1 -1
- package/dist/assets/{index-bv8b5eiQ.js → index-B69X8UsY.js} +1 -1
- package/dist/assets/index-Ba9zbpT9.css +1 -0
- package/dist/assets/{index-Ci7Uy8fR.js → index-D6CV2x61.js} +1 -1
- package/dist/assets/index-ng4tq6YE.js +428 -0
- package/dist/index.d.ts +108 -18
- package/dist/index.html +2 -2
- package/dist/index.js +10335 -7201
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +22 -23
- package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
- package/src/captions/components/CaptionOverlay.tsx +172 -29
- package/src/captions/components/CaptionOverlayUtils.ts +54 -0
- package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
- package/src/captions/components/CaptionTimeline.tsx +36 -79
- package/src/captions/components/shared.tsx +76 -1
- package/src/captions/hooks/useCaptionSync.ts +72 -13
- package/src/captions/keyboard.test.ts +12 -0
- package/src/captions/keyboard.ts +12 -1
- package/src/captions/store.ts +120 -14
- package/src/components/EditorShell.selectionSync.test.tsx +1 -1
- package/src/components/EditorShell.tsx +13 -1
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/StudioRightPanel.types.ts +1 -0
- package/src/components/editor/AnimationCard.tsx +2 -1
- package/src/components/editor/AnimationCardParts.tsx +6 -3
- package/src/components/editor/ArcPathControls.tsx +10 -4
- package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
- package/src/components/editor/BlockParamsPanel.tsx +153 -11
- package/src/components/editor/BorderRadiusEditor.tsx +3 -22
- package/src/components/editor/FileTree.tsx +41 -12
- package/src/components/editor/FileTreeNodes.tsx +90 -20
- package/src/components/editor/GsapAnimationList.tsx +5 -0
- package/src/components/editor/KeyframeDiamond.tsx +3 -1
- package/src/components/editor/KeyframeEaseList.tsx +3 -2
- package/src/components/editor/KeyframeNavigation.tsx +6 -2
- package/src/components/editor/LayersPanel.tsx +11 -2
- package/src/components/editor/MotionPathNode.tsx +1 -0
- package/src/components/editor/PropertyPanel.test.tsx +7 -6
- package/src/components/editor/PropertyPanel.tsx +13 -5
- package/src/components/editor/PropertyPanelFlat.tsx +3 -5
- package/src/components/editor/SnapToolbar.tsx +20 -4
- package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
- package/src/components/editor/TimelineFxPopover.tsx +138 -0
- package/src/components/editor/Transform3DCube.tsx +22 -1
- package/src/components/editor/manualOffsetDrag.test.ts +49 -0
- package/src/components/editor/manualOffsetDrag.ts +13 -1
- package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
- package/src/components/editor/propertyPanelColor.tsx +9 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
- package/src/components/editor/propertyPanelCommitField.tsx +9 -1
- package/src/components/editor/propertyPanelFill.tsx +50 -2
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFont.tsx +45 -9
- package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
- package/src/components/editor/propertyPanelFxSection.tsx +8 -23
- package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
- package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxCarveGrouping.ts +132 -0
- package/src/components/nle/PreviewOverlays.tsx +69 -2
- package/src/components/nle/PreviewPane.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +81 -12
- package/src/components/panels/SlideshowSubPanels.tsx +61 -7
- package/src/components/sidebar/AssetCard.test.tsx +2 -2
- package/src/components/sidebar/AssetCard.tsx +94 -46
- package/src/components/sidebar/AssetContextMenu.tsx +196 -42
- package/src/components/sidebar/AssetsTab.tsx +156 -96
- package/src/components/sidebar/AudioRow.tsx +95 -40
- package/src/components/sidebar/BlocksTab.tsx +40 -128
- package/src/components/sidebar/CompositionsTab.tsx +46 -34
- package/src/components/sidebar/LeftSidebar.tsx +87 -75
- package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
- package/src/components/sidebar/assetHelpers.ts +8 -0
- package/src/contexts/TimelineEditContext.tsx +5 -0
- package/src/hooks/timelineAudioGroupVolume.ts +148 -0
- package/src/hooks/timelineEditingHelpers.ts +78 -1
- package/src/hooks/timelineElementFxAttribute.ts +150 -0
- package/src/hooks/timelineTrackVisibility.test.ts +253 -1
- package/src/hooks/timelineTrackVisibility.ts +207 -8
- package/src/hooks/useAppHotkeys.ts +22 -0
- package/src/hooks/useAudioSoloBridge.ts +61 -0
- package/src/hooks/useCaptionDetection.ts +22 -2
- package/src/hooks/useDomEditCommits.test.tsx +24 -0
- package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
- package/src/hooks/useGroupLevel.ts +36 -0
- package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
- package/src/hooks/useMusicBeatAnalysis.ts +13 -0
- package/src/hooks/useTimelineDeleteOps.ts +170 -0
- package/src/hooks/useTimelineEditing.ts +53 -129
- package/src/player/components/AudioWaveform.tsx +29 -26
- package/src/player/components/BeatStrip.test.tsx +5 -9
- package/src/player/components/BeatStrip.tsx +12 -5
- package/src/player/components/ClipContextMenu.tsx +8 -2
- package/src/player/components/EditModal.tsx +48 -7
- package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
- package/src/player/components/LayerDisclosureRow.tsx +12 -13
- package/src/player/components/Player.tsx +43 -8
- package/src/player/components/PlayerControls.tsx +107 -63
- package/src/player/components/ShortcutsPanel.tsx +23 -4
- package/src/player/components/SpeedMenu.tsx +34 -42
- package/src/player/components/Timeline.test.ts +8 -8
- package/src/player/components/Timeline.tsx +7 -10
- package/src/player/components/TimelineFxButton.test.tsx +81 -0
- package/src/player/components/TimelineFxButton.tsx +143 -0
- package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
- package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
- package/src/player/components/TimelineGroupHeader.tsx +176 -0
- package/src/player/components/TimelineGroupRow.tsx +146 -0
- package/src/player/components/TimelineLanes.test.tsx +8 -1
- package/src/player/components/TimelineLanes.tsx +39 -33
- package/src/player/components/TimelineOverlays.tsx +34 -0
- package/src/player/components/TimelineSoloButton.tsx +32 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +2 -1
- package/src/player/components/TimelineTrackHeader.tsx +74 -98
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +161 -0
- package/src/player/components/TimelineTrackPlainHeader.tsx +126 -0
- package/src/player/components/VideoThumbnail.tsx +6 -1
- package/src/player/components/menuKeyboardNav.ts +47 -0
- package/src/player/components/timelineCallbacks.ts +40 -1
- package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
- package/src/player/components/timelineKeyboardNavigation.ts +147 -30
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineLayout.ts +1 -1
- package/src/player/components/timelineNavigationIdentity.ts +4 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
- package/src/player/components/useTimelineLogicalFocus.ts +9 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
- package/src/player/components/useTimelineLogicalRows.ts +17 -12
- package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
- package/src/player/components/useTimelineTrackDerivations.test.ts +103 -0
- package/src/player/components/useTimelineTrackDerivations.ts +143 -6
- package/src/player/components/useTimelineTrackLayout.ts +61 -4
- package/src/player/hooks/previewMessageRouter.ts +127 -0
- package/src/player/hooks/useTimelinePlayer.ts +9 -47
- package/src/player/lib/timelineDOM.ts +51 -0
- package/src/player/store/audioSoloSlice.test.ts +113 -0
- package/src/player/store/audioSoloSlice.ts +43 -0
- package/src/player/store/editingModeSlice.ts +45 -0
- package/src/player/store/groupLevels.ts +33 -0
- package/src/player/store/keyframeSlice.ts +26 -0
- package/src/player/store/playerStore.ts +21 -22
- package/src/player/store/timelineElement.ts +10 -0
- package/src/telemetry/events.ts +12 -0
- package/dist/assets/index-Cy6fGN1u.js +0 -428
- package/dist/assets/index-DcF4s1PG.css +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect } from "react";
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
2
|
import { useCaptionStore } from "../captions/store";
|
|
3
3
|
import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol";
|
|
4
4
|
import { useCaptionSync } from "../captions/hooks/useCaptionSync";
|
|
@@ -25,6 +25,24 @@ export function useCaptionDetection({
|
|
|
25
25
|
captionSync,
|
|
26
26
|
setRightCollapsed,
|
|
27
27
|
}: UseCaptionDetectionParams) {
|
|
28
|
+
// Switching compositions must drop the previous comp's caption state — a
|
|
29
|
+
// stale model + full-canvas overlay otherwise blocks normal element editing
|
|
30
|
+
// on the new composition (and edit mode could never be exited).
|
|
31
|
+
const prevCompPathRef = useRef<string | null>(activeCompPath);
|
|
32
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (prevCompPathRef.current !== activeCompPath) {
|
|
35
|
+
prevCompPathRef.current = activeCompPath;
|
|
36
|
+
const store = useCaptionStore.getState();
|
|
37
|
+
if (store.model || store.isEditMode) {
|
|
38
|
+
// Flush the last debounced caption edit before the reset destroys the
|
|
39
|
+
// model — otherwise a save landing after the switch writes nothing.
|
|
40
|
+
store.retrySave?.();
|
|
41
|
+
store.reset();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}, [activeCompPath]);
|
|
45
|
+
|
|
28
46
|
// eslint-disable-next-line no-restricted-syntax
|
|
29
47
|
useEffect(() => {
|
|
30
48
|
if (!projectId) return;
|
|
@@ -32,7 +50,9 @@ export function useCaptionDetection({
|
|
|
32
50
|
let activating = false;
|
|
33
51
|
|
|
34
52
|
const tryActivateCaptions = () => {
|
|
35
|
-
|
|
53
|
+
const captionState = useCaptionStore.getState();
|
|
54
|
+
// `dismissed` = user explicitly exited caption editing; don't re-trap them.
|
|
55
|
+
if (captionState.isEditMode || captionState.dismissed || activating) {
|
|
36
56
|
return;
|
|
37
57
|
}
|
|
38
58
|
|
|
@@ -1376,6 +1376,30 @@ describe("useDomEditCommits attribute persist handling", () => {
|
|
|
1376
1376
|
}
|
|
1377
1377
|
});
|
|
1378
1378
|
|
|
1379
|
+
it("sets and removes data-audio-group like any other data attribute", async () => {
|
|
1380
|
+
stubPatchFetch({ ok: true, changed: true, matched: true });
|
|
1381
|
+
const { iframe, element } = createPreviewElement();
|
|
1382
|
+
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
1383
|
+
|
|
1384
|
+
try {
|
|
1385
|
+
await act(async () => {
|
|
1386
|
+
await rendered.hook.handleDomAttributeLiveCommit("audio-group", "voiceover", undefined, {
|
|
1387
|
+
previewOnly: true,
|
|
1388
|
+
});
|
|
1389
|
+
});
|
|
1390
|
+
expect(element.getAttribute("data-audio-group")).toBe("voiceover");
|
|
1391
|
+
|
|
1392
|
+
await act(async () => {
|
|
1393
|
+
await rendered.hook.handleDomAttributeLiveCommit("audio-group", "", undefined, {
|
|
1394
|
+
previewOnly: true,
|
|
1395
|
+
});
|
|
1396
|
+
});
|
|
1397
|
+
expect(element.getAttribute("data-audio-group")).toBeNull();
|
|
1398
|
+
} finally {
|
|
1399
|
+
rendered.cleanup();
|
|
1400
|
+
}
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1379
1403
|
it("keeps a data-attribute commit on success", async () => {
|
|
1380
1404
|
stubPatchFetch({
|
|
1381
1405
|
ok: true,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { TimelineElement } from "../player/store/timelineElement";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The stored `duration` lags a moment behind an edit that pushes an element
|
|
6
|
+
* past it (drag, trim, paste) — this is the actual end of the timeline, the
|
|
7
|
+
* later of the stored duration and the furthest element's end.
|
|
8
|
+
*/
|
|
9
|
+
export function useEffectiveTimelineDuration(
|
|
10
|
+
timelineDuration: number,
|
|
11
|
+
timelineElements: readonly TimelineElement[],
|
|
12
|
+
): number {
|
|
13
|
+
return useMemo(() => {
|
|
14
|
+
const maxEnd =
|
|
15
|
+
timelineElements.length > 0
|
|
16
|
+
? Math.max(...timelineElements.map((el) => el.start + el.duration))
|
|
17
|
+
: 0;
|
|
18
|
+
return Math.max(timelineDuration, maxEnd);
|
|
19
|
+
}, [timelineDuration, timelineElements]);
|
|
20
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A group's live meter reading (0..1 RMS-ish level, whether it just clipped),
|
|
3
|
+
* or null when the group is idle/unknown — never zero for "not playing yet".
|
|
4
|
+
*
|
|
5
|
+
* Mirrors useLivePlayheadTime's shape: the runtime posts readings at its own
|
|
6
|
+
* cadence (only while playing — see `postGroupLevels` in init.ts), this just
|
|
7
|
+
* throttles the re-render, it does not add its own polling loop.
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useRef, useState } from "react";
|
|
10
|
+
import { groupLevels, type GroupLevelReading } from "../player/store/groupLevels";
|
|
11
|
+
|
|
12
|
+
const THROTTLE_MS = 33;
|
|
13
|
+
|
|
14
|
+
export function useGroupLevel(groupId: string): GroupLevelReading | null {
|
|
15
|
+
const latestRef = useRef<GroupLevelReading | null>(groupLevels.get().get(groupId) ?? null);
|
|
16
|
+
const [, forceRender] = useState(0);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
let timerId: ReturnType<typeof setTimeout> | 0 = 0;
|
|
20
|
+
const unsubscribe = groupLevels.subscribe((levels) => {
|
|
21
|
+
latestRef.current = levels.get(groupId) ?? null;
|
|
22
|
+
if (!timerId) {
|
|
23
|
+
timerId = setTimeout(() => {
|
|
24
|
+
timerId = 0;
|
|
25
|
+
forceRender((v) => v + 1);
|
|
26
|
+
}, THROTTLE_MS);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
return () => {
|
|
30
|
+
unsubscribe();
|
|
31
|
+
if (timerId) clearTimeout(timerId);
|
|
32
|
+
};
|
|
33
|
+
}, [groupId]);
|
|
34
|
+
|
|
35
|
+
return latestRef.current;
|
|
36
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import type { MutableRefObject } from "react";
|
|
3
|
+
import { normalizeStudioCompositionPath, type StudioUrlState } from "../utils/studioUrlState";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One-time hydration of `activeCompPath` from the initial URL state, once the
|
|
7
|
+
* file tree has loaded (a path that isn't in the tree yet can't be
|
|
8
|
+
* validated). Runs exactly once — `hydrated` flips true whether or not the
|
|
9
|
+
* URL named a valid path, so a later file-tree change never re-fires it.
|
|
10
|
+
*/
|
|
11
|
+
export function useHydrateActiveCompPathFromUrl({
|
|
12
|
+
hydrated,
|
|
13
|
+
fileTreeLoaded,
|
|
14
|
+
fileTree,
|
|
15
|
+
initialUrlStateRef,
|
|
16
|
+
setActiveCompPath,
|
|
17
|
+
setHydrated,
|
|
18
|
+
}: {
|
|
19
|
+
hydrated: boolean;
|
|
20
|
+
fileTreeLoaded: boolean;
|
|
21
|
+
fileTree: string[];
|
|
22
|
+
initialUrlStateRef: MutableRefObject<StudioUrlState>;
|
|
23
|
+
setActiveCompPath: (updater: (current: string | null) => string | null) => void;
|
|
24
|
+
setHydrated: (value: boolean) => void;
|
|
25
|
+
}): void {
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (hydrated) return;
|
|
28
|
+
if (!fileTreeLoaded) return;
|
|
29
|
+
const nextCompPath = normalizeStudioCompositionPath(
|
|
30
|
+
initialUrlStateRef.current.activeCompPath,
|
|
31
|
+
fileTree,
|
|
32
|
+
);
|
|
33
|
+
setActiveCompPath((current) => (current === nextCompPath ? current : nextCompPath));
|
|
34
|
+
setHydrated(true);
|
|
35
|
+
}, [hydrated, fileTree, fileTreeLoaded, initialUrlStateRef, setActiveCompPath, setHydrated]);
|
|
36
|
+
}
|
|
@@ -4,6 +4,8 @@ import { resolveBeatSourceTrack } from "../utils/timelineInspector";
|
|
|
4
4
|
import { analyzeMusicFromUrl } from "@hyperframes/core/beats";
|
|
5
5
|
import { useFileManagerContextOptional } from "../contexts/FileManagerContext";
|
|
6
6
|
import { mergeUserBeats } from "../utils/beatEditing";
|
|
7
|
+
import { getTimelineElementIndexes } from "../player/lib/timelineElementIndexes";
|
|
8
|
+
import { remapBeatAnalysisToComposition } from "../utils/beatEditActions";
|
|
7
9
|
import {
|
|
8
10
|
audioRelPathForSrc,
|
|
9
11
|
beatFilePathForSrc,
|
|
@@ -90,6 +92,17 @@ async function loadBeatAnalysis(
|
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
/** The current beat analysis, remapped onto the composition's edited beat grid. */
|
|
96
|
+
export function useAdjustedBeatAnalysis() {
|
|
97
|
+
const beatAnalysis = usePlayerStore((s) => s.beatAnalysis);
|
|
98
|
+
const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement);
|
|
99
|
+
const beatEdits = usePlayerStore((s) => s.beatEdits);
|
|
100
|
+
return useMemo(
|
|
101
|
+
() => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits),
|
|
102
|
+
[beatAnalysis, musicElement, beatEdits],
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
93
106
|
export function useMusicBeatAnalysis(): void {
|
|
94
107
|
const elements = usePlayerStore((s) => s.elements);
|
|
95
108
|
const setBeatAnalysis = usePlayerStore((s) => s.setBeatAnalysis);
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Timeline clip deletion: the marquee/multi path and the single-clip wrapper
|
|
2
|
+
// the context menu uses. Extracted verbatim from useTimelineEditing.ts to keep
|
|
3
|
+
// it under the studio 600-line cap, following useTimelineAssetDropOps.
|
|
4
|
+
import { useCallback, type MutableRefObject, type RefObject } from "react";
|
|
5
|
+
import type { TimelineElement } from "../player";
|
|
6
|
+
import { usePlayerStore } from "../player";
|
|
7
|
+
import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
|
|
8
|
+
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
9
|
+
import { getTimelineElementLabel } from "../utils/studioHelpers";
|
|
10
|
+
import { buildPatchTarget } from "./timelineEditingHelpers";
|
|
11
|
+
import { captureDurationRollback, readFileContent } from "./timelineTimingSync";
|
|
12
|
+
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
|
|
13
|
+
import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
|
|
14
|
+
|
|
15
|
+
interface UseTimelineDeleteOpsOptions {
|
|
16
|
+
projectIdRef: MutableRefObject<string | null>;
|
|
17
|
+
activeCompPath: string | null;
|
|
18
|
+
timelineElements: TimelineElement[];
|
|
19
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
20
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
21
|
+
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
22
|
+
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
23
|
+
reloadPreview: () => void;
|
|
24
|
+
isRecordingRef?: MutableRefObject<boolean>;
|
|
25
|
+
forceReloadSdkSession?: () => void;
|
|
26
|
+
previewIframeRef: RefObject<HTMLIFrameElement | null>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useTimelineDeleteOps({
|
|
30
|
+
projectIdRef,
|
|
31
|
+
activeCompPath,
|
|
32
|
+
timelineElements,
|
|
33
|
+
showToast,
|
|
34
|
+
writeProjectFile,
|
|
35
|
+
recordEdit,
|
|
36
|
+
domEditSaveTimestampRef,
|
|
37
|
+
reloadPreview,
|
|
38
|
+
isRecordingRef,
|
|
39
|
+
forceReloadSdkSession,
|
|
40
|
+
previewIframeRef,
|
|
41
|
+
}: UseTimelineDeleteOpsOptions) {
|
|
42
|
+
// fallow-ignore-next-line complexity
|
|
43
|
+
const handleTimelineElementsDelete = useCallback(
|
|
44
|
+
// fallow-ignore-next-line complexity
|
|
45
|
+
async (selection: TimelineElement[]) => {
|
|
46
|
+
if (isRecordingRef?.current) {
|
|
47
|
+
showToast("Cannot edit timeline while recording", "error");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const pid = projectIdRef.current;
|
|
51
|
+
if (!pid) throw new Error("No active project");
|
|
52
|
+
const [element] = selection;
|
|
53
|
+
if (!element) return;
|
|
54
|
+
const label =
|
|
55
|
+
selection.length === 1 ? getTimelineElementLabel(element) : `${selection.length} clips`;
|
|
56
|
+
|
|
57
|
+
// One file per delete pass. Every element in a marquee selection lives in
|
|
58
|
+
// the composition being edited, so they share a target; anything that
|
|
59
|
+
// does not is dropped rather than written to the wrong file.
|
|
60
|
+
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
61
|
+
const sameFile = selection.filter(
|
|
62
|
+
(candidate) => (candidate.sourceFile || activeCompPath || "index.html") === targetPath,
|
|
63
|
+
);
|
|
64
|
+
try {
|
|
65
|
+
const originalContent = await readFileContent(pid, targetPath);
|
|
66
|
+
|
|
67
|
+
// Remove every selected element before saving once. The server rewrites
|
|
68
|
+
// the file per call, so `removedContent` after the last one holds them
|
|
69
|
+
// all — which is what makes this a single history entry, and a single
|
|
70
|
+
// undo, rather than one per clip.
|
|
71
|
+
let removedContent = originalContent;
|
|
72
|
+
for (const target of sameFile) {
|
|
73
|
+
const patchTarget = buildPatchTarget(target);
|
|
74
|
+
if (!patchTarget) {
|
|
75
|
+
throw new Error(`Timeline element ${target.id} is missing a patchable target`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const removeResponse = await fetch(
|
|
79
|
+
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
|
80
|
+
{
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
83
|
+
body: JSON.stringify({ target: patchTarget }),
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
if (!removeResponse.ok) {
|
|
87
|
+
throw new Error(`Failed to delete ${target.id} from ${targetPath}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const removeData = (await removeResponse.json()) as {
|
|
91
|
+
changed?: boolean;
|
|
92
|
+
content?: string;
|
|
93
|
+
};
|
|
94
|
+
if (typeof removeData.content === "string") removedContent = removeData.content;
|
|
95
|
+
}
|
|
96
|
+
// Content-driven duration: shrink the composition to the furthest
|
|
97
|
+
// remaining clip end, read from the post-removal SOURCE (raw
|
|
98
|
+
// data-duration), so deleting the last/longest clip removes trailing
|
|
99
|
+
// empty space. Measured from the source, not the store, whose
|
|
100
|
+
// durations are runtime-truncated.
|
|
101
|
+
const deleteContentEnd = furthestClipEndFromSource(removedContent);
|
|
102
|
+
const patchedContent = setCompositionDurationToContent(removedContent, deleteContentEnd);
|
|
103
|
+
// Optimistically reflect the shrunk length in the readout/seek bar,
|
|
104
|
+
// rolling it back if the persist below fails (see captureDurationRollback).
|
|
105
|
+
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
|
|
106
|
+
if (deleteContentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
|
|
107
|
+
usePlayerStore.getState().setDuration(deleteContentEnd);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
111
|
+
try {
|
|
112
|
+
await saveProjectFilesWithHistory({
|
|
113
|
+
projectId: pid,
|
|
114
|
+
label: "Delete timeline clip",
|
|
115
|
+
kind: "timeline",
|
|
116
|
+
files: { [targetPath]: patchedContent },
|
|
117
|
+
readFile: async () => originalContent,
|
|
118
|
+
// remove-element already wrote the removal, so disk holds THAT — not the
|
|
119
|
+
// content read at the top. Undo still goes back to the original.
|
|
120
|
+
diskContent: { [targetPath]: removedContent },
|
|
121
|
+
writeFile: writeProjectFile,
|
|
122
|
+
recordEdit,
|
|
123
|
+
});
|
|
124
|
+
} catch (error) {
|
|
125
|
+
rollbackDuration();
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const deletedKeys = new Set(sameFile.map((te) => te.key ?? te.id));
|
|
130
|
+
usePlayerStore
|
|
131
|
+
.getState()
|
|
132
|
+
.setElements(timelineElements.filter((te) => !deletedKeys.has(te.key ?? te.id)));
|
|
133
|
+
usePlayerStore.getState().setSelectedElementId(null);
|
|
134
|
+
usePlayerStore.getState().setSelectedElementIds(new Set());
|
|
135
|
+
forceReloadSdkSession?.();
|
|
136
|
+
reloadPreview();
|
|
137
|
+
showToast(
|
|
138
|
+
`Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
|
|
139
|
+
"info",
|
|
140
|
+
);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
|
|
143
|
+
showToast(message);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
[
|
|
147
|
+
activeCompPath,
|
|
148
|
+
projectIdRef,
|
|
149
|
+
recordEdit,
|
|
150
|
+
showToast,
|
|
151
|
+
timelineElements,
|
|
152
|
+
writeProjectFile,
|
|
153
|
+
domEditSaveTimestampRef,
|
|
154
|
+
reloadPreview,
|
|
155
|
+
isRecordingRef,
|
|
156
|
+
forceReloadSdkSession,
|
|
157
|
+
previewIframeRef,
|
|
158
|
+
],
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
/** Single-clip delete — the context menu and clip chrome path. */
|
|
162
|
+
const handleTimelineElementDelete = useCallback(
|
|
163
|
+
async (element: TimelineElement) => {
|
|
164
|
+
await handleTimelineElementsDelete([element]);
|
|
165
|
+
},
|
|
166
|
+
[handleTimelineElementsDelete],
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
return { handleTimelineElementsDelete, handleTimelineElementDelete };
|
|
170
|
+
}
|
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
// fallow-ignore-file complexity
|
|
2
2
|
import { useCallback, useRef } from "react";
|
|
3
3
|
import type { TimelineElement } from "../player";
|
|
4
|
-
import { usePlayerStore } from "../player";
|
|
5
4
|
import { useRazorSplit } from "./useRazorSplit";
|
|
6
5
|
import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
|
|
7
|
-
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
8
|
-
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
|
|
9
|
-
import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
|
|
10
|
-
import { getTimelineElementLabel } from "../utils/studioHelpers";
|
|
11
6
|
import {
|
|
12
7
|
applyTimelineStackingReorder,
|
|
13
|
-
buildPatchTarget,
|
|
14
8
|
patchIframeDomTiming,
|
|
15
9
|
playbackStartAttributeForElement,
|
|
16
10
|
persistTimelineEdit,
|
|
@@ -27,7 +21,11 @@ import {
|
|
|
27
21
|
} from "./timelineTimingSync";
|
|
28
22
|
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
|
29
23
|
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
|
|
24
|
+
import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
|
|
25
|
+
import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
|
|
26
|
+
import { useSetElementAttribute } from "./timelineElementFxAttribute";
|
|
30
27
|
import {
|
|
28
|
+
useAudioGroupCarveAssignment,
|
|
31
29
|
useTimelineElementVisibilityEditing,
|
|
32
30
|
useTimelineTrackVisibilityEditing,
|
|
33
31
|
} from "./timelineTrackVisibility";
|
|
@@ -36,7 +34,6 @@ import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
|
|
|
36
34
|
import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
|
|
37
35
|
import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
|
38
36
|
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
|
|
39
|
-
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
40
37
|
|
|
41
38
|
type TimelineMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
|
|
42
39
|
stackingReorder?: TimelineStackingReorderIntent | null;
|
|
@@ -388,131 +385,55 @@ export function useTimelineEditing({
|
|
|
388
385
|
forceReloadSdkSession,
|
|
389
386
|
});
|
|
390
387
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
if (!element) return;
|
|
403
|
-
const label =
|
|
404
|
-
selection.length === 1 ? getTimelineElementLabel(element) : `${selection.length} clips`;
|
|
405
|
-
|
|
406
|
-
// One file per delete pass. Every element in a marquee selection lives in
|
|
407
|
-
// the composition being edited, so they share a target; anything that
|
|
408
|
-
// does not is dropped rather than written to the wrong file.
|
|
409
|
-
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
410
|
-
const sameFile = selection.filter(
|
|
411
|
-
(candidate) => (candidate.sourceFile || activeCompPath || "index.html") === targetPath,
|
|
412
|
-
);
|
|
413
|
-
try {
|
|
414
|
-
const originalContent = await readFileContent(pid, targetPath);
|
|
415
|
-
|
|
416
|
-
// Remove every selected element before saving once. The server rewrites
|
|
417
|
-
// the file per call, so `removedContent` after the last one holds them
|
|
418
|
-
// all — which is what makes this a single history entry, and a single
|
|
419
|
-
// undo, rather than one per clip.
|
|
420
|
-
let removedContent = originalContent;
|
|
421
|
-
for (const target of sameFile) {
|
|
422
|
-
const patchTarget = buildPatchTarget(target);
|
|
423
|
-
if (!patchTarget) {
|
|
424
|
-
throw new Error(`Timeline element ${target.id} is missing a patchable target`);
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
const removeResponse = await fetch(
|
|
428
|
-
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
|
429
|
-
{
|
|
430
|
-
method: "POST",
|
|
431
|
-
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
432
|
-
body: JSON.stringify({ target: patchTarget }),
|
|
433
|
-
},
|
|
434
|
-
);
|
|
435
|
-
if (!removeResponse.ok) {
|
|
436
|
-
throw new Error(`Failed to delete ${target.id} from ${targetPath}`);
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
const removeData = (await removeResponse.json()) as {
|
|
440
|
-
changed?: boolean;
|
|
441
|
-
content?: string;
|
|
442
|
-
};
|
|
443
|
-
if (typeof removeData.content === "string") removedContent = removeData.content;
|
|
444
|
-
}
|
|
445
|
-
// Content-driven duration: shrink the composition to the furthest
|
|
446
|
-
// remaining clip end, read from the post-removal SOURCE (raw
|
|
447
|
-
// data-duration), so deleting the last/longest clip removes trailing
|
|
448
|
-
// empty space. Measured from the source, not the store, whose
|
|
449
|
-
// durations are runtime-truncated.
|
|
450
|
-
const deleteContentEnd = furthestClipEndFromSource(removedContent);
|
|
451
|
-
const patchedContent = setCompositionDurationToContent(removedContent, deleteContentEnd);
|
|
452
|
-
// Optimistically reflect the shrunk length in the readout/seek bar,
|
|
453
|
-
// rolling it back if the persist below fails (see captureDurationRollback).
|
|
454
|
-
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
|
|
455
|
-
if (deleteContentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
|
|
456
|
-
usePlayerStore.getState().setDuration(deleteContentEnd);
|
|
457
|
-
}
|
|
388
|
+
const handleAutoGroupCarveSources = useAudioGroupCarveAssignment({
|
|
389
|
+
projectIdRef,
|
|
390
|
+
activeCompPath,
|
|
391
|
+
showToast,
|
|
392
|
+
writeProjectFile,
|
|
393
|
+
recordEdit,
|
|
394
|
+
domEditSaveTimestampRef,
|
|
395
|
+
previewIframeRef,
|
|
396
|
+
pendingTimelineEditPathRef,
|
|
397
|
+
isRecordingRef,
|
|
398
|
+
});
|
|
458
399
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
writeFile: writeProjectFile,
|
|
471
|
-
recordEdit,
|
|
472
|
-
});
|
|
473
|
-
} catch (error) {
|
|
474
|
-
rollbackDuration();
|
|
475
|
-
throw error;
|
|
476
|
-
}
|
|
400
|
+
const setElementFxAttribute = useSetElementAttribute({
|
|
401
|
+
projectIdRef,
|
|
402
|
+
activeCompPath,
|
|
403
|
+
showToast,
|
|
404
|
+
writeProjectFile,
|
|
405
|
+
recordEdit,
|
|
406
|
+
domEditSaveTimestampRef,
|
|
407
|
+
previewIframeRef,
|
|
408
|
+
pendingTimelineEditPathRef,
|
|
409
|
+
isRecordingRef,
|
|
410
|
+
});
|
|
477
411
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
);
|
|
490
|
-
} catch (error) {
|
|
491
|
-
const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
|
|
492
|
-
showToast(message);
|
|
493
|
-
}
|
|
494
|
-
},
|
|
495
|
-
[
|
|
496
|
-
activeCompPath,
|
|
497
|
-
recordEdit,
|
|
498
|
-
showToast,
|
|
499
|
-
timelineElements,
|
|
500
|
-
writeProjectFile,
|
|
501
|
-
domEditSaveTimestampRef,
|
|
502
|
-
reloadPreview,
|
|
503
|
-
isRecordingRef,
|
|
504
|
-
forceReloadSdkSession,
|
|
505
|
-
previewIframeRef,
|
|
506
|
-
],
|
|
507
|
-
);
|
|
412
|
+
const setAudioGroupAttribute = useSetAudioGroupAttribute({
|
|
413
|
+
projectIdRef,
|
|
414
|
+
activeCompPath,
|
|
415
|
+
showToast,
|
|
416
|
+
writeProjectFile,
|
|
417
|
+
recordEdit,
|
|
418
|
+
domEditSaveTimestampRef,
|
|
419
|
+
previewIframeRef,
|
|
420
|
+
pendingTimelineEditPathRef,
|
|
421
|
+
isRecordingRef,
|
|
422
|
+
});
|
|
508
423
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
424
|
+
const { handleTimelineElementsDelete, handleTimelineElementDelete } = useTimelineDeleteOps({
|
|
425
|
+
projectIdRef,
|
|
426
|
+
activeCompPath,
|
|
427
|
+
timelineElements,
|
|
428
|
+
showToast,
|
|
429
|
+
writeProjectFile,
|
|
430
|
+
recordEdit,
|
|
431
|
+
domEditSaveTimestampRef,
|
|
432
|
+
reloadPreview,
|
|
433
|
+
isRecordingRef,
|
|
434
|
+
forceReloadSdkSession,
|
|
435
|
+
previewIframeRef,
|
|
436
|
+
});
|
|
516
437
|
|
|
517
438
|
const { handleTimelineAssetDrop, handleTimelineFileDrop, handleTimelineCompositionDrop } =
|
|
518
439
|
useTimelineAssetDropOps({
|
|
@@ -558,6 +479,9 @@ export function useTimelineEditing({
|
|
|
558
479
|
handleTimelineElementResize,
|
|
559
480
|
handleToggleTrackHidden,
|
|
560
481
|
handleToggleElementHidden,
|
|
482
|
+
handleAutoGroupCarveSources,
|
|
483
|
+
setAudioGroupAttribute,
|
|
484
|
+
setElementFxAttribute,
|
|
561
485
|
handleTimelineElementDelete,
|
|
562
486
|
handleTimelineElementsDelete,
|
|
563
487
|
handleTimelineElementSplit: handleRazorSplit,
|
|
@@ -35,37 +35,19 @@ function extractPeaks(channelData: Float32Array, barCount: number): number[] {
|
|
|
35
35
|
return peaks.map((peak) => peak / maxPeak);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
function fakePeaks(url: string, count: number): number[] {
|
|
39
|
-
let seed = 0;
|
|
40
|
-
for (let index = 0; index < url.length; index++) {
|
|
41
|
-
seed = ((seed << 5) - seed + url.charCodeAt(index)) | 0;
|
|
42
|
-
}
|
|
43
|
-
seed = Math.abs(seed) || 42;
|
|
44
|
-
const random = () => {
|
|
45
|
-
seed = (seed * 16807) % 2147483647;
|
|
46
|
-
return (seed & 0x7fffffff) / 2147483647;
|
|
47
|
-
};
|
|
48
|
-
return Array.from({ length: count }, (_, index) => {
|
|
49
|
-
const time = index / count;
|
|
50
|
-
const envelope =
|
|
51
|
-
0.3 + 0.3 * Math.sin(time * Math.PI * 3.2) + 0.2 * Math.sin(time * Math.PI * 7.1);
|
|
52
|
-
return Math.max(0.05, Math.min(1, envelope * (0.4 + 0.6 * random())));
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
|
|
56
38
|
async function loadWaveform(
|
|
57
39
|
audioUrl: string,
|
|
58
40
|
waveformUrl: string | undefined,
|
|
59
41
|
signal: AbortSignal,
|
|
60
42
|
): Promise<number[]> {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
43
|
+
// Failures propagate. Synthesised peaks are worse than an honest gap: an
|
|
44
|
+
// author trims and beat-aligns against this waveform, and a plausible
|
|
45
|
+
// fabrication is indistinguishable from the real thing while being wrong.
|
|
46
|
+
// The scheduler caches the failure (metadataFailureTtlMs) so the degraded
|
|
47
|
+
// state neither refetch-loops nor pins itself past a transient error.
|
|
48
|
+
return waveformUrl
|
|
49
|
+
? await fetchWaveformPeaks(waveformUrl, signal)
|
|
50
|
+
: await decodeWaveformPeaks(audioUrl, signal);
|
|
69
51
|
}
|
|
70
52
|
|
|
71
53
|
async function fetchWaveformPeaks(url: string, signal: AbortSignal): Promise<number[]> {
|
|
@@ -193,6 +175,27 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
193
175
|
}}
|
|
194
176
|
/>
|
|
195
177
|
)}
|
|
178
|
+
{/* Degraded state — the decode failed; say so rather than paint a
|
|
179
|
+
waveform the author could edit against. */}
|
|
180
|
+
{snapshot.status === "error" && (
|
|
181
|
+
<div
|
|
182
|
+
className="absolute inset-x-0 flex items-center justify-center gap-1.5"
|
|
183
|
+
style={{ top: 16, bottom: 0 }}
|
|
184
|
+
>
|
|
185
|
+
<div
|
|
186
|
+
className="absolute inset-x-0"
|
|
187
|
+
style={{
|
|
188
|
+
bottom: "20%",
|
|
189
|
+
height: 2,
|
|
190
|
+
background:
|
|
191
|
+
"repeating-linear-gradient(90deg, rgba(75,163,210,0.35) 0 2px, transparent 2px 5px)",
|
|
192
|
+
}}
|
|
193
|
+
/>
|
|
194
|
+
<span className="relative rounded bg-black/50 px-1 text-[8px] text-neutral-500">
|
|
195
|
+
waveform unavailable
|
|
196
|
+
</span>
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
196
199
|
{label && (
|
|
197
200
|
<div className="absolute inset-x-0 top-0 z-10 px-1.5 py-0.5">
|
|
198
201
|
<span
|