@hyperframes/studio 0.6.97 → 0.6.99

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 (120) hide show
  1. package/dist/assets/hyperframes-player-DgsMQSvV.js +418 -0
  2. package/dist/assets/index-B62bDCQv.css +1 -0
  3. package/dist/assets/{index-HveJ0MuV.js → index-C52IT_lp.js} +1 -1
  4. package/dist/assets/index-DOh7E1uj.js +1 -0
  5. package/dist/assets/index-DrwSRbsl.js +252 -0
  6. package/dist/index.html +2 -2
  7. package/package.json +7 -5
  8. package/src/App.tsx +182 -177
  9. package/src/captions/store.ts +11 -11
  10. package/src/components/StudioHeader.tsx +4 -4
  11. package/src/components/StudioLeftSidebar.tsx +2 -2
  12. package/src/components/StudioPreviewArea.tsx +225 -183
  13. package/src/components/StudioRightPanel.tsx +3 -3
  14. package/src/components/TimelineToolbar.tsx +25 -0
  15. package/src/components/editor/DomEditOverlay.tsx +2 -5
  16. package/src/components/editor/EaseCurveSection.tsx +2 -3
  17. package/src/components/editor/GestureTrailOverlay.tsx +4 -3
  18. package/src/components/editor/LayersPanel.tsx +3 -9
  19. package/src/components/editor/PropertyPanel.tsx +20 -61
  20. package/src/components/editor/colorValue.ts +3 -1
  21. package/src/components/editor/domEditOverlayGestures.ts +54 -1
  22. package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
  23. package/src/components/editor/gradientValue.ts +3 -3
  24. package/src/components/editor/keyframeMove.test.ts +101 -0
  25. package/src/components/editor/keyframeMove.ts +151 -0
  26. package/src/components/editor/manualEditsDom.ts +0 -12
  27. package/src/components/editor/propertyPanelHelpers.ts +10 -38
  28. package/src/components/editor/propertyPanelMediaSection.tsx +1 -5
  29. package/src/components/editor/propertyPanelTimingSection.tsx +1 -6
  30. package/src/components/editor/propertyPanelTransformCommit.ts +129 -0
  31. package/src/components/editor/studioMotionOps.test.ts +1 -1
  32. package/src/components/editor/studioMotionOps.ts +2 -1
  33. package/src/components/editor/useDomEditOverlayGestures.ts +1 -46
  34. package/src/components/nle/NLELayout.tsx +1 -24
  35. package/src/components/sidebar/BlocksTab.tsx +2 -2
  36. package/src/contexts/DomEditContext.tsx +134 -31
  37. package/src/contexts/StudioContext.tsx +90 -40
  38. package/src/contexts/TimelineEditContext.tsx +47 -0
  39. package/src/hooks/domEditCommitTypes.ts +14 -0
  40. package/src/hooks/gsapDragCommit.ts +9 -24
  41. package/src/hooks/gsapKeyframeCacheHelpers.ts +2 -1
  42. package/src/hooks/gsapKeyframeCommit.ts +5 -15
  43. package/src/hooks/gsapRuntimeBridge.ts +18 -52
  44. package/src/hooks/gsapRuntimeKeyframes.ts +8 -57
  45. package/src/hooks/gsapRuntimeReaders.ts +19 -26
  46. package/src/hooks/gsapScriptCommitHelpers.ts +1 -11
  47. package/src/hooks/gsapScriptCommitTypes.ts +58 -0
  48. package/src/hooks/gsapShared.ts +157 -0
  49. package/src/hooks/timelineEditingHelpers.ts +63 -2
  50. package/src/hooks/useAnimatedPropertyCommit.ts +3 -25
  51. package/src/hooks/useAppHotkeys.ts +299 -377
  52. package/src/hooks/useConsoleErrorCapture.ts +33 -5
  53. package/src/hooks/useDomEditCommits.ts +35 -293
  54. package/src/hooks/useDomEditPositionPatchCommit.ts +1 -1
  55. package/src/hooks/useDomEditSession.ts +78 -249
  56. package/src/hooks/useDomEditTextCommits.ts +1 -1
  57. package/src/hooks/useDomEditWiring.ts +255 -0
  58. package/src/hooks/useDomGeometryCommits.ts +181 -0
  59. package/src/hooks/useDomSelection.ts +10 -27
  60. package/src/hooks/useEditorSave.ts +82 -0
  61. package/src/hooks/useElementLifecycleOps.ts +177 -0
  62. package/src/hooks/useEnableKeyframes.ts +10 -15
  63. package/src/hooks/useFileManager.ts +32 -114
  64. package/src/hooks/useFileTree.ts +80 -0
  65. package/src/hooks/useGestureCommit.ts +7 -5
  66. package/src/hooks/useGestureRecording.ts +1 -1
  67. package/src/hooks/useGsapAnimationOps.ts +122 -0
  68. package/src/hooks/useGsapArcPathOps.ts +61 -0
  69. package/src/hooks/useGsapAwareEditing.ts +242 -0
  70. package/src/hooks/useGsapKeyframeOps.ts +167 -0
  71. package/src/hooks/useGsapPropertyDebounce.ts +135 -0
  72. package/src/hooks/useGsapScriptCommits.ts +58 -570
  73. package/src/hooks/useGsapSelectionHandlers.ts +22 -9
  74. package/src/hooks/useGsapTweenCache.ts +35 -29
  75. package/src/hooks/useLintModal.ts +7 -0
  76. package/src/hooks/useMusicBeatAnalysis.ts +152 -0
  77. package/src/hooks/useRazorSplit.ts +1 -1
  78. package/src/hooks/useRenderClipContent.ts +46 -21
  79. package/src/hooks/useTimelineEditing.ts +48 -4
  80. package/src/player/components/AudioWaveform.tsx +29 -4
  81. package/src/player/components/BeatStrip.tsx +166 -0
  82. package/src/player/components/Timeline.tsx +39 -18
  83. package/src/player/components/TimelineCanvas.tsx +52 -12
  84. package/src/player/components/TimelineClipDiamonds.tsx +130 -20
  85. package/src/player/components/TimelinePropertyRows.tsx +8 -2
  86. package/src/player/components/TimelineRuler.tsx +36 -2
  87. package/src/player/components/timelineEditing.ts +30 -5
  88. package/src/player/components/useTimelineClipDrag.ts +155 -4
  89. package/src/player/components/useTimelinePlayhead.ts +30 -1
  90. package/src/player/hooks/useTimelinePlayer.ts +47 -45
  91. package/src/player/lib/mediaProbe.ts +46 -3
  92. package/src/player/lib/playbackScrub.ts +16 -0
  93. package/src/player/lib/timelineDOM.ts +10 -2
  94. package/src/player/lib/timelineIframeHelpers.ts +89 -0
  95. package/src/player/store/playerStore.ts +92 -33
  96. package/src/utils/beatEditActions.ts +109 -0
  97. package/src/utils/beatEditing.ts +136 -0
  98. package/src/utils/clipboardPayload.ts +3 -2
  99. package/src/utils/compositionPatterns.ts +2 -0
  100. package/src/utils/keyframeSelection.test.ts +45 -0
  101. package/src/utils/keyframeSelection.ts +29 -0
  102. package/src/utils/rounding.ts +9 -0
  103. package/src/utils/studioHelpers.ts +5 -2
  104. package/src/utils/studioUrlState.ts +2 -1
  105. package/src/utils/timelineAssetDrop.ts +6 -5
  106. package/src/utils/timelineInspector.ts +15 -100
  107. package/dist/assets/hyperframes-player-Daj5djxa.js +0 -418
  108. package/dist/assets/index-B0twsRu0.css +0 -1
  109. package/dist/assets/index-Cfye9xzo.js +0 -251
  110. package/src/components/editor/DopesheetStrip.tsx +0 -141
  111. package/src/components/editor/StaggerControls.tsx +0 -61
  112. package/src/components/editor/TimelineLayerPanel.test.ts +0 -42
  113. package/src/components/editor/TimelineLayerPanel.tsx +0 -15
  114. package/src/components/nle/TimelineEditorNotice.tsx +0 -133
  115. package/src/hooks/gsapRuntimePreview.ts +0 -19
  116. package/src/player/components/timelineUtils.ts +0 -211
  117. package/src/utils/audioBeatDetection.ts +0 -58
  118. package/src/utils/keyframeSnapping.test.ts +0 -74
  119. package/src/utils/keyframeSnapping.ts +0 -63
  120. package/src/utils/timelineInspector.test.ts +0 -79
@@ -3,6 +3,7 @@ import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/
3
3
  import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
4
4
  import { usePlayerStore } from "../player/store/playerStore";
5
5
  import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
6
+ import { PROPERTY_DEFAULTS, toAbsoluteTime } from "./gsapShared";
6
7
 
7
8
  function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercentageKeyframe[] {
8
9
  const byPct = new Map<number, GsapPercentageKeyframe>();
@@ -18,16 +19,6 @@ function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercenta
18
19
  return Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
19
20
  }
20
21
 
21
- const PROPERTY_DEFAULTS: Record<string, number> = {
22
- opacity: 1,
23
- x: 0,
24
- y: 0,
25
- scale: 1,
26
- scaleX: 1,
27
- scaleY: 1,
28
- rotation: 0,
29
- };
30
-
31
22
  function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null {
32
23
  if (anim.method === "set") {
33
24
  return {
@@ -133,12 +124,18 @@ export function useGsapAnimationsForElement(
133
124
  const [multipleTimelines, setMultipleTimelines] = useState(false);
134
125
  const [unsupportedTimelinePattern, setUnsupportedTimelinePattern] = useState(false);
135
126
  const lastFetchKeyRef = useRef("");
127
+ const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
136
128
 
137
129
  useEffect(() => {
138
130
  const fetchKey = `${projectId}:${sourceFile}:${version}`;
139
131
  if (fetchKey === lastFetchKeyRef.current) return;
140
132
  lastFetchKeyRef.current = fetchKey;
141
133
 
134
+ if (retryTimerRef.current) {
135
+ clearTimeout(retryTimerRef.current);
136
+ retryTimerRef.current = null;
137
+ }
138
+
142
139
  if (!projectId) {
143
140
  setAllAnimations([]);
144
141
  setMultipleTimelines(false);
@@ -158,26 +155,30 @@ export function useGsapAnimationsForElement(
158
155
  setAllAnimations(parsed.animations);
159
156
  setMultipleTimelines(parsed.multipleTimelines === true);
160
157
  setUnsupportedTimelinePattern(parsed.unsupportedTimelinePattern === true);
158
+
159
+ // Retry once if initial fetch returned 0 animations — handles
160
+ // cold-load race where the sourceFile isn't resolved yet.
161
+ if (parsed.animations.length === 0 && target) {
162
+ retryTimerRef.current = setTimeout(() => {
163
+ if (cancelled) return;
164
+ fetchParsedAnimations(projectId, sourceFile).then((retryParsed) => {
165
+ if (cancelled) return;
166
+ if (retryParsed && retryParsed.animations.length > 0) {
167
+ setAllAnimations(retryParsed.animations);
168
+ }
169
+ });
170
+ }, 800);
171
+ }
161
172
  });
162
173
 
163
174
  return () => {
164
175
  cancelled = true;
176
+ if (retryTimerRef.current) {
177
+ clearTimeout(retryTimerRef.current);
178
+ retryTimerRef.current = null;
179
+ }
165
180
  };
166
- }, [projectId, sourceFile, version]);
167
-
168
- // Retry fetch if we have a target but no animations — handles cold-load race
169
- // where the initial fetch runs before the drilled-down sourceFile is resolved
170
- useEffect(() => {
171
- if (!projectId || !target || allAnimations.length > 0) return;
172
- const timer = setTimeout(() => {
173
- fetchParsedAnimations(projectId, sourceFile).then((parsed) => {
174
- if (parsed && parsed.animations.length > 0) {
175
- setAllAnimations(parsed.animations);
176
- }
177
- });
178
- }, 800);
179
- return () => clearTimeout(timer);
180
- }, [projectId, sourceFile, target, allAnimations.length]);
181
+ }, [projectId, sourceFile, version, target]);
181
182
 
182
183
  const targetId = target?.id ?? null;
183
184
  const targetSelector = target?.selector ?? null;
@@ -281,10 +282,12 @@ export function useGsapAnimationsForElement(
281
282
  anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
282
283
  const tweenDur = anim.duration ?? elDuration;
283
284
  for (const k of kf.keyframes) {
284
- const absTime = tweenPos + (k.percentage / 100) * tweenDur;
285
+ const absTime = toAbsoluteTime(tweenPos, tweenDur, k.percentage);
286
+ // 0.001% precision (was 0.1%) so a beat-snapped keyframe centers exactly
287
+ // on the beat dot, which is rendered at the true beat time.
285
288
  const clipPct =
286
289
  elDuration > 0
287
- ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
290
+ ? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000
288
291
  : k.percentage;
289
292
  allKeyframes.push({
290
293
  ...k,
@@ -379,10 +382,13 @@ export function usePopulateKeyframeCacheForFile(
379
382
  const elStart = timelineEl?.start ?? 0;
380
383
  const elDuration = timelineEl?.duration ?? 1;
381
384
  const clipKeyframes = kfData.keyframes.map((kf) => {
382
- const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
385
+ const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage);
386
+ // 0.001% precision (matching useGsapAnimationsForElement above) so a
387
+ // beat-snapped keyframe centers exactly on the beat dot and the two
388
+ // caches agree on a keyframe's percentage.
383
389
  const clipPct =
384
390
  elDuration > 0
385
- ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
391
+ ? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000
386
392
  : kf.percentage;
387
393
  return {
388
394
  ...kf,
@@ -1,5 +1,6 @@
1
1
  import { useState, useCallback, useEffect, useRef, useMemo } from "react";
2
2
  import type { LintFinding } from "../components/LintModal";
3
+ import { usePlayerStore } from "../player";
3
4
 
4
5
  interface RawFinding {
5
6
  severity?: string;
@@ -95,6 +96,12 @@ export function useLintModal(projectId: string | null, refreshKey?: number) {
95
96
  const findingsByElement = useMemo(() => groupFindings((f) => f.elementId), [groupFindings]);
96
97
  const findingsByFile = useMemo(() => groupFindings((f) => f.file), [groupFindings]);
97
98
 
99
+ // Sync lint findings directly to the player store — eliminates the
100
+ // mirroring useEffect that was previously in App.tsx.
101
+ useEffect(() => {
102
+ usePlayerStore.getState().setLintFindingsByElement(findingsByElement);
103
+ }, [findingsByElement]);
104
+
98
105
  return {
99
106
  lintModal,
100
107
  linting,
@@ -0,0 +1,152 @@
1
+ import { useEffect, useMemo, useRef } from "react";
2
+ import { usePlayerStore } from "../player/store/playerStore";
3
+ import { isMusicTrack } from "../utils/timelineInspector";
4
+ import { analyzeMusicFromUrl } from "@hyperframes/core/beats";
5
+ import { useFileManagerContext } from "../contexts/FileManagerContext";
6
+ import { mergeUserBeats } from "../utils/beatEditing";
7
+ import {
8
+ audioRelPathForSrc,
9
+ beatFilePathForSrc,
10
+ serializeBeats,
11
+ parseBeats,
12
+ } from "@hyperframes/core/beats";
13
+
14
+ // Module-level cache so the same URL isn't re-decoded/analyzed on re-mount.
15
+ // Capped so decoded PCM buffers don't accumulate unbounded across a session.
16
+ const analysisCache = new Map<string, ReturnType<typeof analyzeMusicFromUrl>>();
17
+ const MAX_ANALYSIS_CACHE = 4;
18
+
19
+ const PERSIST_DEBOUNCE_MS = 350;
20
+
21
+ function cacheAnalysis(url: string, promise: ReturnType<typeof analyzeMusicFromUrl>): void {
22
+ analysisCache.set(url, promise);
23
+ while (analysisCache.size > MAX_ANALYSIS_CACHE) {
24
+ const oldest = analysisCache.keys().next().value;
25
+ if (oldest === undefined) break;
26
+ analysisCache.delete(oldest);
27
+ }
28
+ }
29
+
30
+ type ProjectIo = { readOptionalProjectFile: (p: string) => Promise<string> };
31
+
32
+ /**
33
+ * Resolve the effective beat list for a track: a saved file with real beats
34
+ * wins; otherwise the detected beats are used (and `hasFile` is false so the
35
+ * caller seeds a new file). An empty saved file is ignored so detection retries.
36
+ */
37
+ async function resolveBeats(
38
+ beatPath: string | null,
39
+ detected: { times: number[]; strengths: number[] },
40
+ io: ProjectIo,
41
+ ): Promise<{ times: number[]; strengths: number[]; hasFile: boolean }> {
42
+ if (!beatPath) return { ...detected, hasFile: false };
43
+ try {
44
+ const content = await io.readOptionalProjectFile(beatPath);
45
+ const parsed = content ? parseBeats(content) : null;
46
+ if (parsed && parsed.times.length > 0) {
47
+ return { times: parsed.times, strengths: parsed.strengths, hasFile: true };
48
+ }
49
+ } catch {
50
+ /* fall back to detected beats */
51
+ }
52
+ return { ...detected, hasFile: false };
53
+ }
54
+
55
+ export function useMusicBeatAnalysis(): void {
56
+ const elements = usePlayerStore((s) => s.elements);
57
+ const setBeatAnalysis = usePlayerStore((s) => s.setBeatAnalysis);
58
+ const setBeatEdits = usePlayerStore((s) => s.setBeatEdits);
59
+ const setBeatPersist = usePlayerStore((s) => s.setBeatPersist);
60
+ const resetBeatHistory = usePlayerStore((s) => s.resetBeatHistory);
61
+ const { readOptionalProjectFile, writeProjectFile } = useFileManagerContext();
62
+
63
+ // File IO via ref so the effects only re-run when the track changes.
64
+ const ioRef = useRef({ readOptionalProjectFile, writeProjectFile });
65
+ ioRef.current = { readOptionalProjectFile, writeProjectFile };
66
+
67
+ const musicSrc = useMemo(() => {
68
+ const el = elements.find((e) => isMusicTrack(e));
69
+ return el?.src ?? null;
70
+ }, [elements]);
71
+
72
+ // ── Load: decode for strength data, then use the saved beat file if present,
73
+ // otherwise seed it from detection. Resets edits + history on track change. ──
74
+ useEffect(() => {
75
+ if (!musicSrc) {
76
+ setBeatAnalysis(null);
77
+ setBeatEdits(null);
78
+ resetBeatHistory();
79
+ return;
80
+ }
81
+ let cancelled = false;
82
+
83
+ let promise = analysisCache.get(musicSrc);
84
+ if (!promise) {
85
+ promise = analyzeMusicFromUrl(musicSrc);
86
+ cacheAnalysis(musicSrc, promise);
87
+ }
88
+
89
+ const beatPath = beatFilePathForSrc(musicSrc);
90
+ promise
91
+ .then(async (analysis) => {
92
+ const detected = { times: analysis.beatTimes, strengths: analysis.beatStrengths };
93
+ const { times, strengths, hasFile } = await resolveBeats(beatPath, detected, ioRef.current);
94
+ if (cancelled) return;
95
+ setBeatEdits(null);
96
+ resetBeatHistory();
97
+ setBeatAnalysis({ ...analysis, beatTimes: times, beatStrengths: strengths });
98
+ // Seed a missing file through the SAME debounced writer the edits use, so
99
+ // the initial write can't race a near-simultaneous edit's persist.
100
+ if (beatPath && !hasFile && times.length > 0) usePlayerStore.getState().beatPersist?.();
101
+ })
102
+ .catch(() => {
103
+ if (cancelled) return;
104
+ setBeatAnalysis(null);
105
+ analysisCache.delete(musicSrc);
106
+ });
107
+
108
+ return () => {
109
+ cancelled = true;
110
+ };
111
+ }, [musicSrc, setBeatAnalysis, setBeatEdits, resetBeatHistory]);
112
+
113
+ // ── Persist: register a debounced writer fired by every beat edit/undo/redo.
114
+ // Flushes any pending write on cleanup so the last edit is never lost. ──
115
+ useEffect(() => {
116
+ const beatPath = beatFilePathForSrc(musicSrc);
117
+ if (!musicSrc || !beatPath) {
118
+ setBeatPersist(null);
119
+ return;
120
+ }
121
+ const audio = audioRelPathForSrc(musicSrc) ?? "audio";
122
+ let timer: ReturnType<typeof setTimeout> | null = null;
123
+ let pending: string | null = null;
124
+
125
+ const flush = () => {
126
+ if (pending === null) return;
127
+ const content = pending;
128
+ pending = null;
129
+ void ioRef.current.writeProjectFile(beatPath, content).catch(() => {});
130
+ };
131
+
132
+ const persist = () => {
133
+ const s = usePlayerStore.getState();
134
+ const a = s.beatAnalysis;
135
+ if (!a) return;
136
+ const merged = mergeUserBeats(a.beatTimes, a.beatStrengths, s.beatEdits, musicSrc);
137
+ pending = serializeBeats(merged.times, merged.strengths, audio);
138
+ if (timer) clearTimeout(timer);
139
+ timer = setTimeout(() => {
140
+ timer = null;
141
+ flush();
142
+ }, PERSIST_DEBOUNCE_MS);
143
+ };
144
+
145
+ setBeatPersist(persist);
146
+ return () => {
147
+ if (timer) clearTimeout(timer);
148
+ flush(); // write the last pending edit before tearing down
149
+ setBeatPersist(null);
150
+ };
151
+ }, [musicSrc, setBeatPersist]);
152
+ }
@@ -9,7 +9,7 @@ import {
9
9
  readFileContent,
10
10
  isSplitTimeWithinBounds,
11
11
  } from "../utils/timelineElementSplit";
12
- import type { RecordEditInput } from "./useTimelineEditing";
12
+ import type { RecordEditInput } from "./timelineEditingHelpers";
13
13
 
14
14
  interface UseRazorSplitOptions {
15
15
  projectId: string | null;
@@ -22,6 +22,49 @@ export function normalizeCompositionSrc(
22
22
  return compSrc;
23
23
  }
24
24
 
25
+ /** Resolve a media src to its project-relative preview path, or null. */
26
+ function resolvePreviewRelative(src: string | undefined, pid: string): string | null {
27
+ if (!src) return null;
28
+ if (!src.startsWith("http")) return src;
29
+ const base = `/api/projects/${pid}/preview/`;
30
+ const idx = src.indexOf(base);
31
+ return idx !== -1 ? decodeURIComponent(src.slice(idx + base.length)) : null;
32
+ }
33
+
34
+ /**
35
+ * The trimmed source slice as start/end fractions (0–1) of the source, so the
36
+ * waveform can window its peaks to the clip edges. Undefined when the source
37
+ * length is unknown (renders full).
38
+ */
39
+ function trimFractions(el: TimelineElement): { start?: number; end?: number } {
40
+ const sourceDur = el.sourceDuration;
41
+ if (sourceDur == null || sourceDur <= 0) return {};
42
+ const mediaStart = el.playbackStart ?? 0;
43
+ const rate = el.playbackRate ?? 1;
44
+ const start = Math.max(0, Math.min(1, mediaStart / sourceDur));
45
+ const end = Math.max(start, Math.min(1, (mediaStart + el.duration * rate) / sourceDur));
46
+ return { start, end };
47
+ }
48
+
49
+ /**
50
+ * Build the waveform element for an audio clip, windowing the rendered peaks to
51
+ * the trimmed source slice so the bars track the clip edges.
52
+ */
53
+ function renderAudioClip(el: TimelineElement, pid: string, labelColor: string): ReactNode {
54
+ const srcRelative = resolvePreviewRelative(el.src, pid);
55
+ const audioUrl = srcRelative ? `/api/projects/${pid}/preview/${srcRelative}` : (el.src ?? "");
56
+ const waveformUrl = srcRelative ? `/api/projects/${pid}/waveform/${srcRelative}` : undefined;
57
+ const { start, end } = trimFractions(el);
58
+ return createElement(AudioWaveform, {
59
+ audioUrl,
60
+ waveformUrl,
61
+ label: getTimelineElementLabel(el),
62
+ labelColor,
63
+ trimStartFraction: start,
64
+ trimEndFraction: end,
65
+ });
66
+ }
67
+
25
68
  interface UseRenderClipContentOptions {
26
69
  projectIdRef: { current: string | null };
27
70
  compIdToSrc: Map<string, string>;
@@ -36,6 +79,8 @@ export function useRenderClipContent({
36
79
  effectiveTimelineDuration,
37
80
  }: UseRenderClipContentOptions) {
38
81
  return useCallback(
82
+ // Pre-existing clip-content dispatcher; reduced by extracting renderAudioClip.
83
+ // fallow-ignore-next-line complexity
39
84
  (el: TimelineElement, style: { clip: string; label: string }): ReactNode => {
40
85
  const pid = projectIdRef.current;
41
86
  if (!pid) return null;
@@ -88,27 +133,7 @@ export function useRenderClipContent({
88
133
 
89
134
  // Audio clips — waveform visualization
90
135
  if (el.tag === "audio") {
91
- const previewBase = `/api/projects/${pid}/preview/`;
92
- const previewIdx = el.src?.startsWith("http") ? el.src.indexOf(previewBase) : -1;
93
- const srcRelative = el.src
94
- ? previewIdx !== -1
95
- ? decodeURIComponent(el.src.slice(previewIdx + previewBase.length))
96
- : el.src.startsWith("http")
97
- ? null
98
- : el.src
99
- : null;
100
- const audioUrl = srcRelative
101
- ? `/api/projects/${pid}/preview/${srcRelative}`
102
- : (el.src ?? "");
103
- const waveformUrl = srcRelative
104
- ? `/api/projects/${pid}/waveform/${srcRelative}`
105
- : undefined;
106
- return createElement(AudioWaveform, {
107
- audioUrl,
108
- waveformUrl,
109
- label: getTimelineElementLabel(el),
110
- labelColor: style.label,
111
- });
136
+ return renderAudioClip(el, pid, style.label);
112
137
  }
113
138
 
114
139
  if ((el.tag === "video" || el.tag === "img") && el.src) {
@@ -26,6 +26,8 @@ import {
26
26
  readFileContent,
27
27
  applyPatchByTarget,
28
28
  formatTimelineAttributeNumber,
29
+ shiftGsapPositions,
30
+ scaleGsapPositions,
29
31
  } from "./timelineEditingHelpers";
30
32
  import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
31
33
 
@@ -122,6 +124,8 @@ export function useTimelineEditing({
122
124
  ["data-start", formatTimelineAttributeNumber(updates.start)],
123
125
  ["data-track-index", String(updates.track)],
124
126
  ]);
127
+ const delta = updates.start - element.start;
128
+ const filePath = element.sourceFile || activeCompPath || "index.html";
125
129
  return enqueueEdit(element, "Move timeline clip", (original, target) => {
126
130
  let patched = applyPatchByTarget(original, target, {
127
131
  type: "attribute",
@@ -133,9 +137,16 @@ export function useTimelineEditing({
133
137
  property: "track-index",
134
138
  value: String(updates.track),
135
139
  });
140
+ }).then(() => {
141
+ const pid = projectIdRef.current;
142
+ if (delta !== 0 && element.domId && pid) {
143
+ return shiftGsapPositions(pid, filePath, element.domId, delta)
144
+ .then(() => reloadPreview())
145
+ .catch((err) => console.error("[Timeline] Failed to shift GSAP positions", err));
146
+ }
136
147
  });
137
148
  },
138
- [previewIframeRef, enqueueEdit],
149
+ [previewIframeRef, enqueueEdit, activeCompPath, reloadPreview],
139
150
  );
140
151
 
141
152
  const handleTimelineElementResize = useCallback(
@@ -143,10 +154,21 @@ export function useTimelineEditing({
143
154
  element: TimelineElement,
144
155
  updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
145
156
  ) => {
146
- patchIframeDomTiming(previewIframeRef.current, element, [
157
+ const liveAttrs: Array<[string, string]> = [
147
158
  ["data-start", formatTimelineAttributeNumber(updates.start)],
148
159
  ["data-duration", formatTimelineAttributeNumber(updates.duration)],
149
- ]);
160
+ ];
161
+ if (updates.playbackStart != null) {
162
+ const liveAttr =
163
+ element.playbackStartAttr === "playback-start"
164
+ ? "data-playback-start"
165
+ : "data-media-start";
166
+ liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
167
+ }
168
+ patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
169
+ const filePath = element.sourceFile || activeCompPath || "index.html";
170
+ const timingChanged =
171
+ updates.start !== element.start || updates.duration !== element.duration;
150
172
  return enqueueEdit(element, "Resize timeline clip", (original, target) => {
151
173
  const pbs = resolveResizePlaybackStart(original, target, element, updates);
152
174
  let patched = applyPatchByTarget(original, target, {
@@ -167,12 +189,30 @@ export function useTimelineEditing({
167
189
  });
168
190
  }
169
191
  return patched;
192
+ }).then(() => {
193
+ const pid = projectIdRef.current;
194
+ if (timingChanged && element.domId && pid) {
195
+ return scaleGsapPositions(
196
+ pid,
197
+ filePath,
198
+ element.domId,
199
+ element.start,
200
+ element.duration,
201
+ updates.start,
202
+ updates.duration,
203
+ )
204
+ .then(() => reloadPreview())
205
+ .catch((err) => console.error("[Timeline] Failed to scale GSAP positions", err));
206
+ }
207
+ return reloadPreview();
170
208
  });
171
209
  },
172
- [previewIframeRef, enqueueEdit],
210
+ [previewIframeRef, enqueueEdit, activeCompPath, reloadPreview],
173
211
  );
174
212
 
175
213
  const handleTimelineElementDelete = useCallback(
214
+ // Pre-existing handler complexity, unchanged by this PR.
215
+ // fallow-ignore-next-line complexity
176
216
  async (element: TimelineElement) => {
177
217
  if (isRecordingRef?.current) {
178
218
  showToast("Cannot edit timeline while recording", "error");
@@ -247,6 +287,8 @@ export function useTimelineEditing({
247
287
  );
248
288
 
249
289
  const handleTimelineAssetDrop = useCallback(
290
+ // Pre-existing handler complexity, unchanged by this PR.
291
+ // fallow-ignore-next-line complexity
250
292
  async (
251
293
  assetPath: string,
252
294
  placement: Pick<TimelineElement, "start" | "track">,
@@ -329,6 +371,8 @@ export function useTimelineEditing({
329
371
  );
330
372
 
331
373
  const handleTimelineFileDrop = useCallback(
374
+ // Pre-existing handler complexity, unchanged by this PR.
375
+ // fallow-ignore-next-line complexity
332
376
  async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
333
377
  if (isRecordingRef?.current) {
334
378
  showToast("Cannot edit timeline while recording", "error");
@@ -5,6 +5,17 @@ interface AudioWaveformProps {
5
5
  waveformUrl?: string;
6
6
  label: string;
7
7
  labelColor: string;
8
+ /**
9
+ * Fraction (0–1) of the source the clip starts at, after the media-start
10
+ * trim. Defaults to 0 (no front trim).
11
+ */
12
+ trimStartFraction?: number;
13
+ /**
14
+ * Fraction (0–1) of the source the clip ends at. Defaults to 1 (no tail
15
+ * trim). Together these window the rendered peaks to the trimmed slice so the
16
+ * waveform tracks the clip edges instead of squeezing the whole file in.
17
+ */
18
+ trimEndFraction?: number;
8
19
  }
9
20
 
10
21
  const BAR_W = 2;
@@ -62,6 +73,8 @@ export const AudioWaveform = memo(function AudioWaveform({
62
73
  waveformUrl,
63
74
  label,
64
75
  labelColor,
76
+ trimStartFraction,
77
+ trimEndFraction,
65
78
  }: AudioWaveformProps) {
66
79
  const containerRef = useRef<HTMLDivElement | null>(null);
67
80
  const barsRef = useRef<HTMLDivElement | null>(null);
@@ -116,20 +129,32 @@ export const AudioWaveform = memo(function AudioWaveform({
116
129
  const barsEl = barsRef.current;
117
130
  if (!container || !barsEl || !peaks) return;
118
131
 
132
+ // Window the peaks to the trimmed slice [start, end) of the source so the
133
+ // bars track the clip edges. Clamp to a valid, non-empty range.
134
+ const winStart = Math.max(0, Math.min(1, trimStartFraction ?? 0));
135
+ const winEnd = Math.max(winStart, Math.min(1, trimEndFraction ?? 1));
136
+ const lo = Math.floor(winStart * peaks.length);
137
+ const hi = Math.max(lo + 1, Math.ceil(winEnd * peaks.length));
138
+ const span = hi - lo;
139
+
140
+ // Fill the full (possibly zoomed) clip width with STEP-spaced bars, resampling
141
+ // the windowed peaks across them — upsampling (repeating peaks) when the clip
142
+ // is wider than the slice has samples, so the waveform stretches with zoom
143
+ // instead of stopping partway across.
119
144
  const w = container.clientWidth || 400;
120
- const barCount = Math.min(Math.floor(w / STEP), peaks.length);
145
+ const barCount = Math.max(0, Math.floor(w / STEP));
121
146
 
122
147
  let html = "";
123
148
  for (let i = 0; i < barCount; i++) {
124
- // Map bar index to peak index (resample)
125
- const peakIdx = Math.floor((i / barCount) * peaks.length);
149
+ // Map bar index to peak index within the windowed range (resample)
150
+ const peakIdx = lo + Math.min(span - 1, Math.floor((i / barCount) * span));
126
151
  const amp = peaks[peakIdx] ?? 0;
127
152
  const pct = Math.max(3, Math.round(amp * 100));
128
153
  const opacity = (0.45 + amp * 0.4).toFixed(2);
129
154
  html += `<div style="position:absolute;bottom:0;left:${i * STEP}px;width:${BAR_W}px;height:${pct}%;background:rgba(75,163,210,${opacity})"></div>`;
130
155
  }
131
156
  barsEl.innerHTML = html;
132
- }, [peaks]);
157
+ }, [peaks, trimStartFraction, trimEndFraction]);
133
158
 
134
159
  // Observe container size and redraw
135
160
  const setContainerRef = useCallback(