@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.
- package/dist/assets/hyperframes-player-DgsMQSvV.js +418 -0
- package/dist/assets/index-B62bDCQv.css +1 -0
- package/dist/assets/{index-HveJ0MuV.js → index-C52IT_lp.js} +1 -1
- package/dist/assets/index-DOh7E1uj.js +1 -0
- package/dist/assets/index-DrwSRbsl.js +252 -0
- package/dist/index.html +2 -2
- package/package.json +7 -5
- package/src/App.tsx +182 -177
- package/src/captions/store.ts +11 -11
- package/src/components/StudioHeader.tsx +4 -4
- package/src/components/StudioLeftSidebar.tsx +2 -2
- package/src/components/StudioPreviewArea.tsx +225 -183
- package/src/components/StudioRightPanel.tsx +3 -3
- package/src/components/TimelineToolbar.tsx +25 -0
- package/src/components/editor/DomEditOverlay.tsx +2 -5
- package/src/components/editor/EaseCurveSection.tsx +2 -3
- package/src/components/editor/GestureTrailOverlay.tsx +4 -3
- package/src/components/editor/LayersPanel.tsx +3 -9
- package/src/components/editor/PropertyPanel.tsx +20 -61
- package/src/components/editor/colorValue.ts +3 -1
- package/src/components/editor/domEditOverlayGestures.ts +54 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
- package/src/components/editor/gradientValue.ts +3 -3
- package/src/components/editor/keyframeMove.test.ts +101 -0
- package/src/components/editor/keyframeMove.ts +151 -0
- package/src/components/editor/manualEditsDom.ts +0 -12
- package/src/components/editor/propertyPanelHelpers.ts +10 -38
- package/src/components/editor/propertyPanelMediaSection.tsx +1 -5
- package/src/components/editor/propertyPanelTimingSection.tsx +1 -6
- package/src/components/editor/propertyPanelTransformCommit.ts +129 -0
- package/src/components/editor/studioMotionOps.test.ts +1 -1
- package/src/components/editor/studioMotionOps.ts +2 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +1 -46
- package/src/components/nle/NLELayout.tsx +1 -24
- package/src/components/sidebar/BlocksTab.tsx +2 -2
- package/src/contexts/DomEditContext.tsx +134 -31
- package/src/contexts/StudioContext.tsx +90 -40
- package/src/contexts/TimelineEditContext.tsx +47 -0
- package/src/hooks/domEditCommitTypes.ts +14 -0
- package/src/hooks/gsapDragCommit.ts +9 -24
- package/src/hooks/gsapKeyframeCacheHelpers.ts +2 -1
- package/src/hooks/gsapKeyframeCommit.ts +5 -15
- package/src/hooks/gsapRuntimeBridge.ts +18 -52
- package/src/hooks/gsapRuntimeKeyframes.ts +8 -57
- package/src/hooks/gsapRuntimeReaders.ts +19 -26
- package/src/hooks/gsapScriptCommitHelpers.ts +1 -11
- package/src/hooks/gsapScriptCommitTypes.ts +58 -0
- package/src/hooks/gsapShared.ts +157 -0
- package/src/hooks/timelineEditingHelpers.ts +63 -2
- package/src/hooks/useAnimatedPropertyCommit.ts +3 -25
- package/src/hooks/useAppHotkeys.ts +299 -377
- package/src/hooks/useConsoleErrorCapture.ts +33 -5
- package/src/hooks/useDomEditCommits.ts +35 -293
- package/src/hooks/useDomEditPositionPatchCommit.ts +1 -1
- package/src/hooks/useDomEditSession.ts +78 -249
- package/src/hooks/useDomEditTextCommits.ts +1 -1
- package/src/hooks/useDomEditWiring.ts +255 -0
- package/src/hooks/useDomGeometryCommits.ts +181 -0
- package/src/hooks/useDomSelection.ts +10 -27
- package/src/hooks/useEditorSave.ts +82 -0
- package/src/hooks/useElementLifecycleOps.ts +177 -0
- package/src/hooks/useEnableKeyframes.ts +10 -15
- package/src/hooks/useFileManager.ts +32 -114
- package/src/hooks/useFileTree.ts +80 -0
- package/src/hooks/useGestureCommit.ts +7 -5
- package/src/hooks/useGestureRecording.ts +1 -1
- package/src/hooks/useGsapAnimationOps.ts +122 -0
- package/src/hooks/useGsapArcPathOps.ts +61 -0
- package/src/hooks/useGsapAwareEditing.ts +242 -0
- package/src/hooks/useGsapKeyframeOps.ts +167 -0
- package/src/hooks/useGsapPropertyDebounce.ts +135 -0
- package/src/hooks/useGsapScriptCommits.ts +58 -570
- package/src/hooks/useGsapSelectionHandlers.ts +22 -9
- package/src/hooks/useGsapTweenCache.ts +35 -29
- package/src/hooks/useLintModal.ts +7 -0
- package/src/hooks/useMusicBeatAnalysis.ts +152 -0
- package/src/hooks/useRazorSplit.ts +1 -1
- package/src/hooks/useRenderClipContent.ts +46 -21
- package/src/hooks/useTimelineEditing.ts +48 -4
- package/src/player/components/AudioWaveform.tsx +29 -4
- package/src/player/components/BeatStrip.tsx +166 -0
- package/src/player/components/Timeline.tsx +39 -18
- package/src/player/components/TimelineCanvas.tsx +52 -12
- package/src/player/components/TimelineClipDiamonds.tsx +130 -20
- package/src/player/components/TimelinePropertyRows.tsx +8 -2
- package/src/player/components/TimelineRuler.tsx +36 -2
- package/src/player/components/timelineEditing.ts +30 -5
- package/src/player/components/useTimelineClipDrag.ts +155 -4
- package/src/player/components/useTimelinePlayhead.ts +30 -1
- package/src/player/hooks/useTimelinePlayer.ts +47 -45
- package/src/player/lib/mediaProbe.ts +46 -3
- package/src/player/lib/playbackScrub.ts +16 -0
- package/src/player/lib/timelineDOM.ts +10 -2
- package/src/player/lib/timelineIframeHelpers.ts +89 -0
- package/src/player/store/playerStore.ts +92 -33
- package/src/utils/beatEditActions.ts +109 -0
- package/src/utils/beatEditing.ts +136 -0
- package/src/utils/clipboardPayload.ts +3 -2
- package/src/utils/compositionPatterns.ts +2 -0
- package/src/utils/keyframeSelection.test.ts +45 -0
- package/src/utils/keyframeSelection.ts +29 -0
- package/src/utils/rounding.ts +9 -0
- package/src/utils/studioHelpers.ts +5 -2
- package/src/utils/studioUrlState.ts +2 -1
- package/src/utils/timelineAssetDrop.ts +6 -5
- package/src/utils/timelineInspector.ts +15 -100
- package/dist/assets/hyperframes-player-Daj5djxa.js +0 -418
- package/dist/assets/index-B0twsRu0.css +0 -1
- package/dist/assets/index-Cfye9xzo.js +0 -251
- package/src/components/editor/DopesheetStrip.tsx +0 -141
- package/src/components/editor/StaggerControls.tsx +0 -61
- package/src/components/editor/TimelineLayerPanel.test.ts +0 -42
- package/src/components/editor/TimelineLayerPanel.tsx +0 -15
- package/src/components/nle/TimelineEditorNotice.tsx +0 -133
- package/src/hooks/gsapRuntimePreview.ts +0 -19
- package/src/player/components/timelineUtils.ts +0 -211
- package/src/utils/audioBeatDetection.ts +0 -58
- package/src/utils/keyframeSnapping.test.ts +0 -74
- package/src/utils/keyframeSnapping.ts +0 -63
- 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
|
|
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) *
|
|
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
|
|
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) *
|
|
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 "./
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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) *
|
|
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(
|