@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.
Files changed (154) hide show
  1. package/dist/assets/{hyperframes-player-DcM3fVcp.js → hyperframes-player-bDcU464i.js} +1 -1
  2. package/dist/assets/index-Ba9zbpT9.css +1 -0
  3. package/dist/assets/index-CUXnyoIa.js +428 -0
  4. package/dist/assets/{index-DgJrvYGf.js → index-FkAs3zRu.js} +1 -1
  5. package/dist/assets/{index-Del6XWZj.js → index-MSOSP1-J.js} +1 -1
  6. package/dist/index.d.ts +98 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +10337 -7200
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +22 -23
  12. package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
  13. package/src/captions/components/CaptionOverlay.tsx +172 -29
  14. package/src/captions/components/CaptionOverlayUtils.ts +54 -0
  15. package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
  16. package/src/captions/components/CaptionTimeline.tsx +36 -79
  17. package/src/captions/components/shared.tsx +76 -1
  18. package/src/captions/hooks/useCaptionSync.ts +72 -13
  19. package/src/captions/keyboard.test.ts +12 -0
  20. package/src/captions/keyboard.ts +12 -1
  21. package/src/captions/store.ts +120 -14
  22. package/src/components/EditorShell.selectionSync.test.tsx +1 -1
  23. package/src/components/EditorShell.tsx +13 -1
  24. package/src/components/StudioRightPanel.tsx +2 -0
  25. package/src/components/StudioRightPanel.types.ts +1 -0
  26. package/src/components/editor/AnimationCard.tsx +2 -1
  27. package/src/components/editor/AnimationCardParts.tsx +6 -3
  28. package/src/components/editor/ArcPathControls.tsx +10 -4
  29. package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
  30. package/src/components/editor/BlockParamsPanel.tsx +153 -11
  31. package/src/components/editor/BorderRadiusEditor.tsx +3 -22
  32. package/src/components/editor/FileTree.tsx +41 -12
  33. package/src/components/editor/FileTreeNodes.tsx +90 -20
  34. package/src/components/editor/GsapAnimationList.tsx +5 -0
  35. package/src/components/editor/KeyframeDiamond.tsx +3 -1
  36. package/src/components/editor/KeyframeEaseList.tsx +3 -2
  37. package/src/components/editor/KeyframeNavigation.tsx +6 -2
  38. package/src/components/editor/LayersPanel.tsx +11 -2
  39. package/src/components/editor/MotionPathNode.tsx +1 -0
  40. package/src/components/editor/PropertyPanel.test.tsx +7 -6
  41. package/src/components/editor/PropertyPanel.tsx +13 -5
  42. package/src/components/editor/PropertyPanelFlat.tsx +2 -0
  43. package/src/components/editor/SnapToolbar.tsx +20 -4
  44. package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
  45. package/src/components/editor/TimelineFxPopover.tsx +138 -0
  46. package/src/components/editor/Transform3DCube.tsx +22 -1
  47. package/src/components/editor/manualOffsetDrag.test.ts +49 -0
  48. package/src/components/editor/manualOffsetDrag.ts +13 -1
  49. package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
  50. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
  51. package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
  52. package/src/components/editor/propertyPanelColor.tsx +9 -1
  53. package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
  54. package/src/components/editor/propertyPanelCommitField.tsx +9 -1
  55. package/src/components/editor/propertyPanelFill.tsx +50 -2
  56. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  57. package/src/components/editor/propertyPanelFont.tsx +45 -9
  58. package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
  59. package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
  60. package/src/components/editor/propertyPanelFxSection.tsx +8 -23
  61. package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
  62. package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
  63. package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
  64. package/src/components/editor/propertyPanelTypes.ts +2 -0
  65. package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
  66. package/src/components/editor/useFxCarve.ts +0 -0
  67. package/src/components/editor/useFxCarveGrouping.ts +132 -0
  68. package/src/components/nle/PreviewOverlays.tsx +69 -2
  69. package/src/components/nle/PreviewPane.tsx +1 -0
  70. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  71. package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
  72. package/src/components/panels/SlideshowPanel.tsx +81 -12
  73. package/src/components/panels/SlideshowSubPanels.tsx +61 -7
  74. package/src/components/sidebar/AssetCard.test.tsx +2 -2
  75. package/src/components/sidebar/AssetCard.tsx +94 -46
  76. package/src/components/sidebar/AssetContextMenu.tsx +196 -42
  77. package/src/components/sidebar/AssetsTab.tsx +156 -96
  78. package/src/components/sidebar/AudioRow.tsx +95 -40
  79. package/src/components/sidebar/BlocksTab.tsx +40 -128
  80. package/src/components/sidebar/CompositionsTab.tsx +46 -34
  81. package/src/components/sidebar/LeftSidebar.tsx +87 -75
  82. package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
  83. package/src/components/sidebar/assetHelpers.ts +8 -0
  84. package/src/contexts/TimelineEditContext.tsx +5 -0
  85. package/src/hooks/timelineAudioGroupVolume.ts +148 -0
  86. package/src/hooks/timelineEditingHelpers.ts +78 -1
  87. package/src/hooks/timelineElementFxAttribute.ts +150 -0
  88. package/src/hooks/timelineTrackVisibility.test.ts +222 -1
  89. package/src/hooks/timelineTrackVisibility.ts +187 -4
  90. package/src/hooks/useAppHotkeys.ts +22 -0
  91. package/src/hooks/useAudioSoloBridge.ts +61 -0
  92. package/src/hooks/useCaptionDetection.ts +22 -2
  93. package/src/hooks/useDomEditCommits.test.tsx +24 -0
  94. package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
  95. package/src/hooks/useGroupLevel.ts +36 -0
  96. package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
  97. package/src/hooks/useMusicBeatAnalysis.ts +13 -0
  98. package/src/hooks/useTimelineDeleteOps.ts +170 -0
  99. package/src/hooks/useTimelineEditing.ts +53 -129
  100. package/src/player/components/AudioWaveform.tsx +29 -26
  101. package/src/player/components/BeatStrip.test.tsx +5 -9
  102. package/src/player/components/BeatStrip.tsx +12 -5
  103. package/src/player/components/ClipContextMenu.tsx +8 -2
  104. package/src/player/components/EditModal.tsx +48 -7
  105. package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
  106. package/src/player/components/LayerDisclosureRow.tsx +12 -13
  107. package/src/player/components/Player.tsx +43 -8
  108. package/src/player/components/PlayerControls.tsx +107 -63
  109. package/src/player/components/ShortcutsPanel.tsx +23 -4
  110. package/src/player/components/SpeedMenu.tsx +34 -42
  111. package/src/player/components/Timeline.test.ts +7 -7
  112. package/src/player/components/Timeline.tsx +7 -10
  113. package/src/player/components/TimelineFxButton.test.tsx +81 -0
  114. package/src/player/components/TimelineFxButton.tsx +143 -0
  115. package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
  116. package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
  117. package/src/player/components/TimelineGroupHeader.tsx +176 -0
  118. package/src/player/components/TimelineGroupRow.tsx +146 -0
  119. package/src/player/components/TimelineLanes.test.tsx +5 -0
  120. package/src/player/components/TimelineLanes.tsx +39 -33
  121. package/src/player/components/TimelineOverlays.tsx +34 -0
  122. package/src/player/components/TimelineSoloButton.tsx +32 -0
  123. package/src/player/components/TimelineTrackHeader.tsx +74 -98
  124. package/src/player/components/TimelineTrackPlainHeader.tsx +119 -0
  125. package/src/player/components/VideoThumbnail.tsx +6 -1
  126. package/src/player/components/menuKeyboardNav.ts +47 -0
  127. package/src/player/components/timelineCallbacks.ts +26 -0
  128. package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
  129. package/src/player/components/timelineKeyboardNavigation.ts +147 -30
  130. package/src/player/components/timelineLaneProps.ts +3 -0
  131. package/src/player/components/timelineLayout.ts +1 -1
  132. package/src/player/components/timelineNavigationIdentity.ts +4 -0
  133. package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
  134. package/src/player/components/useTimelineLogicalFocus.ts +9 -0
  135. package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
  136. package/src/player/components/useTimelineLogicalRows.ts +17 -12
  137. package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
  138. package/src/player/components/useTimelineTrackDerivations.ts +150 -6
  139. package/src/player/components/useTimelineTrackLayout.ts +61 -4
  140. package/src/player/hooks/previewMessageRouter.ts +127 -0
  141. package/src/player/hooks/useExpandedTimelineElements.test.ts +64 -17
  142. package/src/player/hooks/useExpandedTimelineElements.ts +8 -11
  143. package/src/player/hooks/useTimelinePlayer.ts +9 -47
  144. package/src/player/lib/timelineDOM.ts +51 -0
  145. package/src/player/store/audioSoloSlice.test.ts +113 -0
  146. package/src/player/store/audioSoloSlice.ts +43 -0
  147. package/src/player/store/editingModeSlice.ts +45 -0
  148. package/src/player/store/groupLevels.ts +33 -0
  149. package/src/player/store/keyframeSlice.ts +26 -0
  150. package/src/player/store/playerStore.ts +21 -22
  151. package/src/player/store/timelineElement.ts +10 -0
  152. package/src/telemetry/events.ts +12 -0
  153. package/dist/assets/index-Cx_UrhoW.js +0 -428
  154. 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
- // fallow-ignore-next-line complexity
392
- const handleTimelineElementsDelete = useCallback(
393
- // fallow-ignore-next-line complexity
394
- async (selection: TimelineElement[]) => {
395
- if (isRecordingRef?.current) {
396
- showToast("Cannot edit timeline while recording", "error");
397
- return;
398
- }
399
- const pid = projectIdRef.current;
400
- if (!pid) throw new Error("No active project");
401
- const [element] = selection;
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
- domEditSaveTimestampRef.current = Date.now();
460
- try {
461
- await saveProjectFilesWithHistory({
462
- projectId: pid,
463
- label: "Delete timeline clip",
464
- kind: "timeline",
465
- files: { [targetPath]: patchedContent },
466
- readFile: async () => originalContent,
467
- // remove-element already wrote the removal, so disk holds THAT — not the
468
- // content read at the top. Undo still goes back to the original.
469
- diskContent: { [targetPath]: removedContent },
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
- const deletedKeys = new Set(sameFile.map((te) => te.key ?? te.id));
479
- usePlayerStore
480
- .getState()
481
- .setElements(timelineElements.filter((te) => !deletedKeys.has(te.key ?? te.id)));
482
- usePlayerStore.getState().setSelectedElementId(null);
483
- usePlayerStore.getState().setSelectedElementIds(new Set());
484
- forceReloadSdkSession?.();
485
- reloadPreview();
486
- showToast(
487
- `Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
488
- "info",
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
- /** Single-clip delete the context menu and clip chrome path. */
510
- const handleTimelineElementDelete = useCallback(
511
- async (element: TimelineElement) => {
512
- await handleTimelineElementsDelete([element]);
513
- },
514
- [handleTimelineElementsDelete],
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
- try {
62
- return waveformUrl
63
- ? await fetchWaveformPeaks(waveformUrl, signal)
64
- : await decodeWaveformPeaks(audioUrl, signal);
65
- } catch (error) {
66
- if (signal.aborted) throw error;
67
- return fakePeaks(waveformUrl ?? audioUrl, 4000);
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 · double-click to delete"]'),
296
+ document.querySelectorAll<HTMLDivElement>('[title="Drag to move · ⌥-click to delete"]'),
301
297
  (beat) => beat.style.left,
302
298
  );
303
- expect(lefts).toContain("134px");
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 · double-click to delete"]',
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 = 12; // grab width per beat (px)
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
- * double-click to delete; both scrub the audio. Dot size/brightness scale with
359
- * beat loudness (gamma-curved for contrast).
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 · double-click to delete"
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
- onDoubleClick={(e) => {
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