@hyperframes/studio 0.8.5 → 0.8.7
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-DcM3fVcp.js → hyperframes-player-bDcU464i.js} +1 -1
- package/dist/assets/index-Ba9zbpT9.css +1 -0
- package/dist/assets/index-CUXnyoIa.js +428 -0
- package/dist/assets/{index-DgJrvYGf.js → index-FkAs3zRu.js} +1 -1
- package/dist/assets/{index-Del6XWZj.js → index-MSOSP1-J.js} +1 -1
- package/dist/index.d.ts +98 -17
- package/dist/index.html +2 -2
- package/dist/index.js +10337 -7200
- 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 +2 -0
- 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 +222 -1
- package/src/hooks/timelineTrackVisibility.ts +187 -4
- 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 +7 -7
- 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 +5 -0
- 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.tsx +74 -98
- package/src/player/components/TimelineTrackPlainHeader.tsx +119 -0
- package/src/player/components/VideoThumbnail.tsx +6 -1
- package/src/player/components/menuKeyboardNav.ts +47 -0
- package/src/player/components/timelineCallbacks.ts +26 -0
- 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.ts +150 -6
- package/src/player/components/useTimelineTrackLayout.ts +61 -4
- package/src/player/hooks/previewMessageRouter.ts +127 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +64 -17
- package/src/player/hooks/useExpandedTimelineElements.ts +8 -11
- 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-Cx_UrhoW.js +0 -428
- package/dist/assets/index-DcF4s1PG.css +0 -1
|
@@ -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
|
|
@@ -74,9 +74,7 @@ function mountBeatStrip(renderTimeRange?: { start: number; end: number }) {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
function firstBeat(): HTMLDivElement {
|
|
77
|
-
const beat = document.querySelector<HTMLDivElement>(
|
|
78
|
-
'[title="Drag to move · double-click to delete"]',
|
|
79
|
-
);
|
|
77
|
+
const beat = document.querySelector<HTMLDivElement>('[title="Drag to move · ⌥-click to delete"]');
|
|
80
78
|
if (!beat) throw new Error("Expected a beat handle");
|
|
81
79
|
return beat;
|
|
82
80
|
}
|
|
@@ -256,9 +254,7 @@ describe("BeatStrip gesture ownership", () => {
|
|
|
256
254
|
);
|
|
257
255
|
});
|
|
258
256
|
|
|
259
|
-
expect(
|
|
260
|
-
document.querySelectorAll('[title="Drag to move · double-click to delete"]'),
|
|
261
|
-
).toHaveLength(2);
|
|
257
|
+
expect(document.querySelectorAll('[title="Drag to move · ⌥-click to delete"]')).toHaveLength(2);
|
|
262
258
|
expect(commitBeatEditsSpy).not.toHaveBeenCalled();
|
|
263
259
|
|
|
264
260
|
act(() => {
|
|
@@ -297,10 +293,10 @@ describe("BeatStrip gesture ownership", () => {
|
|
|
297
293
|
});
|
|
298
294
|
|
|
299
295
|
const lefts = Array.from(
|
|
300
|
-
document.querySelectorAll<HTMLDivElement>('[title="Drag to move ·
|
|
296
|
+
document.querySelectorAll<HTMLDivElement>('[title="Drag to move · ⌥-click to delete"]'),
|
|
301
297
|
(beat) => beat.style.left,
|
|
302
298
|
);
|
|
303
|
-
expect(lefts).toContain("
|
|
299
|
+
expect(lefts).toContain("128px");
|
|
304
300
|
|
|
305
301
|
releaseBeatDrag(140);
|
|
306
302
|
expectCommittedBeatAt(1.4);
|
|
@@ -310,7 +306,7 @@ describe("BeatStrip gesture ownership", () => {
|
|
|
310
306
|
mountBeatStrip();
|
|
311
307
|
startBeatDrag();
|
|
312
308
|
const beats = document.querySelectorAll<HTMLDivElement>(
|
|
313
|
-
'[title="Drag to move ·
|
|
309
|
+
'[title="Drag to move · ⌥-click to delete"]',
|
|
314
310
|
);
|
|
315
311
|
act(() => {
|
|
316
312
|
beats[1]?.dispatchEvent(
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
|
|
15
15
|
|
|
16
16
|
export const BEAT_BAND_H = 14; // dark band height at top of track
|
|
17
|
-
const BEAT_HIT_W =
|
|
17
|
+
const BEAT_HIT_W = 24; // grab width per beat (px) — ≥24px pointer target
|
|
18
18
|
|
|
19
19
|
interface BeatDragActor {
|
|
20
20
|
readonly pointerId: number;
|
|
@@ -355,8 +355,9 @@ export const BeatBackgroundLines = memo(function BeatBackgroundLines({
|
|
|
355
355
|
|
|
356
356
|
/**
|
|
357
357
|
* Green beat dots on the music track's row. Drag a dot to move its beat,
|
|
358
|
-
*
|
|
359
|
-
* beat
|
|
358
|
+
* ⌥-click to delete (kept off double-click so a stuttered drag can't destroy
|
|
359
|
+
* a beat); both scrub the audio. Dot size/brightness scale with beat loudness
|
|
360
|
+
* (gamma-curved for contrast). Deletes remain undoable via ⌘Z.
|
|
360
361
|
*/
|
|
361
362
|
export const BeatStrip = memo(function BeatStrip({
|
|
362
363
|
beatTimes,
|
|
@@ -412,7 +413,7 @@ export const BeatStrip = memo(function BeatStrip({
|
|
|
412
413
|
<div
|
|
413
414
|
key={`${t}-${i}`}
|
|
414
415
|
className="absolute select-none"
|
|
415
|
-
title="Drag to move ·
|
|
416
|
+
title="Drag to move · ⌥-click to delete"
|
|
416
417
|
draggable={false}
|
|
417
418
|
style={{
|
|
418
419
|
left: x - BEAT_HIT_W / 2,
|
|
@@ -428,9 +429,15 @@ export const BeatStrip = memo(function BeatStrip({
|
|
|
428
429
|
// selection (which otherwise "selects" the whole panel mid-drag).
|
|
429
430
|
e.preventDefault();
|
|
430
431
|
e.stopPropagation();
|
|
432
|
+
// ⌥ starts no drag: the delete lands on release below, so a
|
|
433
|
+
// slipped ⌥-drag abandons instead of destroying the beat.
|
|
434
|
+
if (e.altKey) return;
|
|
431
435
|
beginBeatDrag(e, t, pps);
|
|
432
436
|
}}
|
|
433
|
-
|
|
437
|
+
onClick={(e) => {
|
|
438
|
+
if (!e.altKey) return;
|
|
439
|
+
// ⌥-click deletes. Deliberately NOT double-click: a stuttered drag
|
|
440
|
+
// attempt reads as a double-click and would destroy the beat.
|
|
434
441
|
e.stopPropagation();
|
|
435
442
|
deleteBeatAtCompositionTime(t);
|
|
436
443
|
usePlayerStore.getState().requestSeek(Math.max(0, t)); // park scrubber at deleted beat
|