@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.
Files changed (155) hide show
  1. package/dist/assets/{hyperframes-player-DdNgW-Np.js → hyperframes-player-DYTqZd0d.js} +1 -1
  2. package/dist/assets/{index-bv8b5eiQ.js → index-B69X8UsY.js} +1 -1
  3. package/dist/assets/index-Ba9zbpT9.css +1 -0
  4. package/dist/assets/{index-Ci7Uy8fR.js → index-D6CV2x61.js} +1 -1
  5. package/dist/assets/index-ng4tq6YE.js +428 -0
  6. package/dist/index.d.ts +108 -18
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +10335 -7201
  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 +3 -5
  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 +253 -1
  89. package/src/hooks/timelineTrackVisibility.ts +207 -8
  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 +8 -8
  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 +8 -1
  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.test.tsx +2 -1
  124. package/src/player/components/TimelineTrackHeader.tsx +74 -98
  125. package/src/player/components/TimelineTrackPlainHeader.test.tsx +161 -0
  126. package/src/player/components/TimelineTrackPlainHeader.tsx +126 -0
  127. package/src/player/components/VideoThumbnail.tsx +6 -1
  128. package/src/player/components/menuKeyboardNav.ts +47 -0
  129. package/src/player/components/timelineCallbacks.ts +40 -1
  130. package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
  131. package/src/player/components/timelineKeyboardNavigation.ts +147 -30
  132. package/src/player/components/timelineLaneProps.ts +3 -0
  133. package/src/player/components/timelineLayout.ts +1 -1
  134. package/src/player/components/timelineNavigationIdentity.ts +4 -0
  135. package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
  136. package/src/player/components/useTimelineLogicalFocus.ts +9 -0
  137. package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
  138. package/src/player/components/useTimelineLogicalRows.ts +17 -12
  139. package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
  140. package/src/player/components/useTimelineTrackDerivations.test.ts +103 -0
  141. package/src/player/components/useTimelineTrackDerivations.ts +143 -6
  142. package/src/player/components/useTimelineTrackLayout.ts +61 -4
  143. package/src/player/hooks/previewMessageRouter.ts +127 -0
  144. package/src/player/hooks/useTimelinePlayer.ts +9 -47
  145. package/src/player/lib/timelineDOM.ts +51 -0
  146. package/src/player/store/audioSoloSlice.test.ts +113 -0
  147. package/src/player/store/audioSoloSlice.ts +43 -0
  148. package/src/player/store/editingModeSlice.ts +45 -0
  149. package/src/player/store/groupLevels.ts +33 -0
  150. package/src/player/store/keyframeSlice.ts +26 -0
  151. package/src/player/store/playerStore.ts +21 -22
  152. package/src/player/store/timelineElement.ts +10 -0
  153. package/src/telemetry/events.ts +12 -0
  154. package/dist/assets/index-Cy6fGN1u.js +0 -428
  155. 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
- if (useCaptionStore.getState().isEditMode || activating) {
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
- // 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