@hyperframes/studio 0.6.97 → 0.6.98
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-Ce3pBm_I.js +252 -0
- package/dist/assets/{index-HveJ0MuV.js → index-D-ET9M0b.js} +1 -1
- package/dist/assets/index-D-bS9Dxx.js +1 -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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
2
|
import type { KeyframeCacheEntry } from "../store/playerStore";
|
|
3
|
+
import { KF_MIN_PCT, KF_MAX_PCT } from "./TimelineClipDiamonds";
|
|
3
4
|
|
|
4
5
|
const SUB_TRACK_H = 24;
|
|
5
6
|
const DIAMOND_SIZE = 6;
|
|
@@ -44,7 +45,9 @@ export const TimelinePropertyRows = memo(function TimelinePropertyRows({
|
|
|
44
45
|
return (
|
|
45
46
|
<div className="flex flex-col">
|
|
46
47
|
{properties.map((prop) => {
|
|
47
|
-
const propKeyframes = keyframesData.keyframes
|
|
48
|
+
const propKeyframes = keyframesData.keyframes
|
|
49
|
+
.filter((kf) => prop in kf.properties)
|
|
50
|
+
.filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT);
|
|
48
51
|
if (propKeyframes.length === 0) return null;
|
|
49
52
|
|
|
50
53
|
return (
|
|
@@ -67,7 +70,10 @@ export const TimelinePropertyRows = memo(function TimelinePropertyRows({
|
|
|
67
70
|
strokeWidth={1}
|
|
68
71
|
/>
|
|
69
72
|
{propKeyframes.map((kf) => {
|
|
70
|
-
const x = (
|
|
73
|
+
const x = Math.max(
|
|
74
|
+
HALF,
|
|
75
|
+
Math.min(clipWidthPx - HALF, (kf.percentage / 100) * clipWidthPx),
|
|
76
|
+
);
|
|
71
77
|
const y = SUB_TRACK_H / 2;
|
|
72
78
|
const key = `${elementId}:${kf.percentage}`;
|
|
73
79
|
const isKfSelected = selectedKeyframes.has(key);
|
|
@@ -2,6 +2,7 @@ import { memo } from "react";
|
|
|
2
2
|
import type { TimelineTheme } from "./timelineTheme";
|
|
3
3
|
import type { TimelineRangeSelection } from "./timelineEditing";
|
|
4
4
|
import { GUTTER, RULER_H, formatTimelineTickLabel } from "./timelineLayout";
|
|
5
|
+
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
|
5
6
|
|
|
6
7
|
interface TimelineRulerProps {
|
|
7
8
|
major: number[];
|
|
@@ -14,6 +15,7 @@ interface TimelineRulerProps {
|
|
|
14
15
|
shiftHeld: boolean;
|
|
15
16
|
rangeSelection: TimelineRangeSelection | null;
|
|
16
17
|
theme: TimelineTheme;
|
|
18
|
+
beatAnalysis?: MusicBeatAnalysis | null;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export const TimelineRuler = memo(function TimelineRuler({
|
|
@@ -27,13 +29,25 @@ export const TimelineRuler = memo(function TimelineRuler({
|
|
|
27
29
|
shiftHeld,
|
|
28
30
|
rangeSelection,
|
|
29
31
|
theme,
|
|
32
|
+
beatAnalysis,
|
|
30
33
|
}: TimelineRulerProps) {
|
|
34
|
+
const beatTimes = beatAnalysis?.beatTimes ?? [];
|
|
35
|
+
const beatStrengths = beatAnalysis?.beatStrengths ?? [];
|
|
36
|
+
|
|
37
|
+
// Only draw beat lines when they'd be at least 5px apart
|
|
38
|
+
const avgBeatInterval =
|
|
39
|
+
beatTimes.length > 1
|
|
40
|
+
? (beatTimes[beatTimes.length - 1]! - beatTimes[0]!) / (beatTimes.length - 1)
|
|
41
|
+
: null;
|
|
42
|
+
const showBeats = avgBeatInterval !== null && avgBeatInterval * pps >= 5;
|
|
43
|
+
|
|
31
44
|
return (
|
|
32
45
|
<>
|
|
33
|
-
{/* Grid lines
|
|
46
|
+
{/* Grid lines (major ticks + beat lines) — behind the tracks (background).
|
|
47
|
+
Opaque track rows hide them; only the beat dots show on tracks. */}
|
|
34
48
|
<svg
|
|
35
49
|
className="absolute pointer-events-none"
|
|
36
|
-
style={{ left: GUTTER, width: trackContentWidth }}
|
|
50
|
+
style={{ left: GUTTER, width: trackContentWidth, zIndex: 0 }}
|
|
37
51
|
height={totalH}
|
|
38
52
|
>
|
|
39
53
|
{major.map((t) => {
|
|
@@ -50,6 +64,24 @@ export const TimelineRuler = memo(function TimelineRuler({
|
|
|
50
64
|
/>
|
|
51
65
|
);
|
|
52
66
|
})}
|
|
67
|
+
{showBeats &&
|
|
68
|
+
beatTimes.map((t, i) => {
|
|
69
|
+
const x = t * pps;
|
|
70
|
+
// Louder beats → brighter line. Gamma curve widens the contrast.
|
|
71
|
+
const strength = Math.pow(Math.min(1, beatStrengths[i] ?? 0.5), 2.2);
|
|
72
|
+
const opacity = 0.08 + strength * 0.62;
|
|
73
|
+
return (
|
|
74
|
+
<line
|
|
75
|
+
key={`b-${t}-${i}`}
|
|
76
|
+
x1={x}
|
|
77
|
+
y1={0}
|
|
78
|
+
x2={x}
|
|
79
|
+
y2={totalH}
|
|
80
|
+
stroke={`rgba(34, 197, 94, ${opacity.toFixed(3)})`}
|
|
81
|
+
strokeWidth="1"
|
|
82
|
+
/>
|
|
83
|
+
);
|
|
84
|
+
})}
|
|
53
85
|
</svg>
|
|
54
86
|
|
|
55
87
|
{/* Ruler */}
|
|
@@ -64,11 +96,13 @@ export const TimelineRuler = memo(function TimelineRuler({
|
|
|
64
96
|
</span>
|
|
65
97
|
</div>
|
|
66
98
|
)}
|
|
99
|
+
|
|
67
100
|
{minor.map((t) => (
|
|
68
101
|
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
|
|
69
102
|
<div className="w-px h-[3px]" style={{ background: theme.tickMinor }} />
|
|
70
103
|
</div>
|
|
71
104
|
))}
|
|
105
|
+
|
|
72
106
|
{major.map((t) => (
|
|
73
107
|
<div
|
|
74
108
|
key={`M-${t}`}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { formatTime } from "../lib/time";
|
|
2
|
+
import { roundToCenti } from "../../utils/rounding";
|
|
2
3
|
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
function roundToCentiseconds(value: number): number {
|
|
6
|
-
return Math.round(value * TIME_PRECISION) / TIME_PRECISION;
|
|
7
|
-
}
|
|
4
|
+
const roundToCentiseconds = roundToCenti;
|
|
8
5
|
|
|
9
6
|
function clamp(value: number, min: number, max: number): number {
|
|
10
7
|
return Math.min(Math.max(value, min), max);
|
|
@@ -114,6 +111,34 @@ export function resolveTimelineMove(
|
|
|
114
111
|
};
|
|
115
112
|
}
|
|
116
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Snap a keyframe's clip-relative percentage to the nearest beat within ~8px,
|
|
116
|
+
* mapping through composition time (pct → time → nearest beat → pct). Returns
|
|
117
|
+
* the percentage unchanged when no beat is in range, so dragging stays free
|
|
118
|
+
* between beats.
|
|
119
|
+
*/
|
|
120
|
+
export function snapKeyframePctToBeat(
|
|
121
|
+
el: { start: number; duration: number },
|
|
122
|
+
pct: number,
|
|
123
|
+
beatTimes: number[] | undefined,
|
|
124
|
+
pixelsPerSecond: number,
|
|
125
|
+
): number {
|
|
126
|
+
if (!beatTimes || beatTimes.length === 0 || el.duration <= 0) return pct;
|
|
127
|
+
const t = el.start + (pct / 100) * el.duration;
|
|
128
|
+
const snapSecs = 8 / Math.max(pixelsPerSecond, 1);
|
|
129
|
+
let best = t;
|
|
130
|
+
let bestDist = snapSecs;
|
|
131
|
+
for (const bt of beatTimes) {
|
|
132
|
+
const d = Math.abs(bt - t);
|
|
133
|
+
if (d < bestDist) {
|
|
134
|
+
bestDist = d;
|
|
135
|
+
best = bt;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (best === t) return pct;
|
|
139
|
+
return Math.max(0, Math.min(100, ((best - el.start) / el.duration) * 100));
|
|
140
|
+
}
|
|
141
|
+
|
|
117
142
|
export function resolveTimelineResize(
|
|
118
143
|
input: TimelineResizeInput,
|
|
119
144
|
edge: "start" | "end",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useRef, useState, useCallback } from "react";
|
|
1
|
+
import { useRef, useState, useCallback, useMemo } from "react";
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
3
|
import {
|
|
4
4
|
resolveTimelineMove,
|
|
@@ -9,6 +9,64 @@ import {
|
|
|
9
9
|
import { usePlayerStore } from "../store/playerStore";
|
|
10
10
|
import type { TimelineElement } from "../store/playerStore";
|
|
11
11
|
import { TRACK_H } from "./timelineLayout";
|
|
12
|
+
import { isMusicTrack } from "../../utils/timelineInspector";
|
|
13
|
+
import { mergeUserBeats } from "../../utils/beatEditing";
|
|
14
|
+
|
|
15
|
+
const BEAT_SNAP_PX = 8;
|
|
16
|
+
const EMPTY_BEAT_TIMES: number[] = [];
|
|
17
|
+
|
|
18
|
+
function snapToNearestBeat(time: number, beatTimes: number[], thresholdSecs: number): number {
|
|
19
|
+
let best = time;
|
|
20
|
+
let bestDist = thresholdSecs;
|
|
21
|
+
for (const bt of beatTimes) {
|
|
22
|
+
const d = Math.abs(bt - time);
|
|
23
|
+
if (d < bestDist) {
|
|
24
|
+
bestDist = d;
|
|
25
|
+
best = bt;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return best;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Snap a moved clip so whichever edge (start or end) is nearest a beat lands on
|
|
33
|
+
* it, keeping the duration fixed. Returns the (clamped) start plus the beat time
|
|
34
|
+
* it snapped to (for the grid-line highlight), or `beat: null` when no edge is
|
|
35
|
+
* within threshold.
|
|
36
|
+
*/
|
|
37
|
+
function snapMoveStartToBeat(
|
|
38
|
+
start: number,
|
|
39
|
+
duration: number,
|
|
40
|
+
beatTimes: number[],
|
|
41
|
+
pixelsPerSecond: number,
|
|
42
|
+
timelineDuration: number,
|
|
43
|
+
): { start: number; beat: number | null } {
|
|
44
|
+
if (beatTimes.length === 0) return { start, beat: null };
|
|
45
|
+
const snapSecs = BEAT_SNAP_PX / Math.max(pixelsPerSecond, 1);
|
|
46
|
+
const snappedStart = snapToNearestBeat(start, beatTimes, snapSecs);
|
|
47
|
+
const snappedEnd = snapToNearestBeat(start + duration, beatTimes, snapSecs);
|
|
48
|
+
const startMoved = snappedStart !== start;
|
|
49
|
+
const endMoved = snappedEnd !== start + duration;
|
|
50
|
+
|
|
51
|
+
let candidate = start;
|
|
52
|
+
let beat: number | null = null;
|
|
53
|
+
if (
|
|
54
|
+
startMoved &&
|
|
55
|
+
(!endMoved || Math.abs(snappedStart - start) <= Math.abs(snappedEnd - (start + duration)))
|
|
56
|
+
) {
|
|
57
|
+
candidate = snappedStart;
|
|
58
|
+
beat = snappedStart;
|
|
59
|
+
} else if (endMoved) {
|
|
60
|
+
candidate = snappedEnd - duration;
|
|
61
|
+
beat = snappedEnd;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const maxStart = Math.max(0, timelineDuration - duration);
|
|
65
|
+
const clamped = Math.max(0, Math.min(maxStart, Math.round(candidate * 1000) / 1000));
|
|
66
|
+
// If clamping pulled the clip off the snap target, drop the highlight.
|
|
67
|
+
if (beat != null && Math.abs(clamped - candidate) > 1e-6) beat = null;
|
|
68
|
+
return { start: clamped, beat };
|
|
69
|
+
}
|
|
12
70
|
|
|
13
71
|
/* ── Shared state types ─────────────────────────────────────────── */
|
|
14
72
|
export interface DraggedClipState {
|
|
@@ -23,6 +81,8 @@ export interface DraggedClipState {
|
|
|
23
81
|
pointerOffsetY: number;
|
|
24
82
|
previewStart: number;
|
|
25
83
|
previewTrack: number;
|
|
84
|
+
/** Beat time the clip will snap to on drop, for the grid-line highlight. */
|
|
85
|
+
snapBeatTime: number | null;
|
|
26
86
|
started: boolean;
|
|
27
87
|
}
|
|
28
88
|
|
|
@@ -76,6 +136,36 @@ export function useTimelineClipDrag({
|
|
|
76
136
|
setRangeSelectionRef,
|
|
77
137
|
}: UseTimelineClipDragInput) {
|
|
78
138
|
const updateElement = usePlayerStore((s) => s.updateElement);
|
|
139
|
+
const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
|
|
140
|
+
const rawBeatStrengths = usePlayerStore((s) => s.beatAnalysis?.beatStrengths ?? EMPTY_BEAT_TIMES);
|
|
141
|
+
const beatEdits = usePlayerStore((s) => s.beatEdits);
|
|
142
|
+
const musicStart = usePlayerStore((s) => s.elements.find(isMusicTrack)?.start ?? 0);
|
|
143
|
+
const musicPlaybackStart = usePlayerStore(
|
|
144
|
+
(s) => s.elements.find(isMusicTrack)?.playbackStart ?? 0,
|
|
145
|
+
);
|
|
146
|
+
const musicDuration = usePlayerStore((s) => s.elements.find(isMusicTrack)?.duration ?? 0);
|
|
147
|
+
const musicSrc = usePlayerStore((s) => s.elements.find(isMusicTrack)?.src ?? null);
|
|
148
|
+
|
|
149
|
+
const adjustedBeatTimes = useMemo(() => {
|
|
150
|
+
if (rawBeatTimes === EMPTY_BEAT_TIMES || musicDuration === 0) return EMPTY_BEAT_TIMES;
|
|
151
|
+
const merged = mergeUserBeats(rawBeatTimes, rawBeatStrengths, beatEdits, musicSrc);
|
|
152
|
+
const clipEnd = musicPlaybackStart + musicDuration;
|
|
153
|
+
const offset = musicStart - musicPlaybackStart;
|
|
154
|
+
return merged.times
|
|
155
|
+
.filter((t) => t >= musicPlaybackStart && t <= clipEnd)
|
|
156
|
+
.map((t) => Math.round((t + offset) * 1000) / 1000);
|
|
157
|
+
}, [
|
|
158
|
+
rawBeatTimes,
|
|
159
|
+
rawBeatStrengths,
|
|
160
|
+
beatEdits,
|
|
161
|
+
musicSrc,
|
|
162
|
+
musicStart,
|
|
163
|
+
musicPlaybackStart,
|
|
164
|
+
musicDuration,
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
const beatTimesRef = useRef<number[]>([]);
|
|
168
|
+
beatTimesRef.current = adjustedBeatTimes;
|
|
79
169
|
|
|
80
170
|
const [draggedClip, setDraggedClip] = useState<DraggedClipState | null>(null);
|
|
81
171
|
const draggedClipRef = useRef<DraggedClipState | null>(null);
|
|
@@ -118,13 +208,24 @@ export function useTimelineClipDrag({
|
|
|
118
208
|
clientX,
|
|
119
209
|
clientY,
|
|
120
210
|
);
|
|
211
|
+
// The music track defines the beats, so it must not snap to itself.
|
|
212
|
+
const snap = isMusicTrack(drag.element)
|
|
213
|
+
? { start: nextMove.start, beat: null }
|
|
214
|
+
: snapMoveStartToBeat(
|
|
215
|
+
nextMove.start,
|
|
216
|
+
drag.element.duration,
|
|
217
|
+
beatTimesRef.current,
|
|
218
|
+
ppsRef.current,
|
|
219
|
+
durationRef.current,
|
|
220
|
+
);
|
|
121
221
|
return {
|
|
122
222
|
...drag,
|
|
123
223
|
started: true,
|
|
124
224
|
pointerClientX: clientX,
|
|
125
225
|
pointerClientY: clientY,
|
|
126
|
-
previewStart:
|
|
226
|
+
previewStart: snap.start,
|
|
127
227
|
previewTrack: nextMove.track,
|
|
228
|
+
snapBeatTime: snap.beat,
|
|
128
229
|
};
|
|
129
230
|
},
|
|
130
231
|
[scrollRef, ppsRef, durationRef, trackOrderRef],
|
|
@@ -220,14 +321,16 @@ export function useTimelineClipDrag({
|
|
|
220
321
|
: Number.POSITIVE_INFINITY;
|
|
221
322
|
const normalizedTag = resize.element.tag.toLowerCase();
|
|
222
323
|
const canSeedPlaybackStart = normalizedTag === "audio" || normalizedTag === "video";
|
|
223
|
-
const
|
|
324
|
+
const playbackRate = Math.max(resize.element.playbackRate ?? 1, 0.1);
|
|
325
|
+
const maxEnd = Math.min(durationRef.current, resize.element.start + sourceRemaining);
|
|
326
|
+
let nextResize = resolveTimelineResize(
|
|
224
327
|
{
|
|
225
328
|
start: resize.element.start,
|
|
226
329
|
duration: resize.element.duration,
|
|
227
330
|
originClientX: resize.originClientX,
|
|
228
331
|
pixelsPerSecond: ppsRef.current,
|
|
229
332
|
minStart: 0,
|
|
230
|
-
maxEnd
|
|
333
|
+
maxEnd,
|
|
231
334
|
playbackStart:
|
|
232
335
|
resize.edge === "start" && canSeedPlaybackStart
|
|
233
336
|
? (resize.element.playbackStart ?? 0)
|
|
@@ -238,6 +341,54 @@ export function useTimelineClipDrag({
|
|
|
238
341
|
e.clientX,
|
|
239
342
|
);
|
|
240
343
|
|
|
344
|
+
// Snap edge to beat grid when beat analysis is available. The snap must
|
|
345
|
+
// stay inside the same limits resolveTimelineResize enforces, or it would
|
|
346
|
+
// push the edge past the available source media / composition end.
|
|
347
|
+
// The music track defines the beats, so it must not snap to itself.
|
|
348
|
+
const beatTimes = beatTimesRef.current;
|
|
349
|
+
if (beatTimes.length > 0 && !isMusicTrack(resize.element)) {
|
|
350
|
+
const snapSecs = BEAT_SNAP_PX / Math.max(ppsRef.current, 1);
|
|
351
|
+
if (resize.edge === "end") {
|
|
352
|
+
const edgeTime = nextResize.start + nextResize.duration;
|
|
353
|
+
const snapped = snapToNearestBeat(edgeTime, beatTimes, snapSecs);
|
|
354
|
+
// Stay within [start+minDuration, maxEnd] so the snap can't create a
|
|
355
|
+
// degenerate clip or run past the source/composition limit.
|
|
356
|
+
const snappedDuration = Math.round((snapped - nextResize.start) * 1000) / 1000;
|
|
357
|
+
if (snapped !== edgeTime && snapped <= maxEnd + 1e-6 && snappedDuration >= 0.05) {
|
|
358
|
+
nextResize = { ...nextResize, duration: snappedDuration };
|
|
359
|
+
}
|
|
360
|
+
} else {
|
|
361
|
+
const snapped = snapToNearestBeat(nextResize.start, beatTimes, snapSecs);
|
|
362
|
+
const delta = nextResize.start - snapped; // >0 when snapping left
|
|
363
|
+
// Leftward snap reveals more source; cap so playbackStart can't go < 0.
|
|
364
|
+
const maxLeftDelta =
|
|
365
|
+
nextResize.playbackStart != null
|
|
366
|
+
? nextResize.playbackStart / playbackRate
|
|
367
|
+
: Number.POSITIVE_INFINITY;
|
|
368
|
+
// Also require the resulting duration to stay >= minDuration so a
|
|
369
|
+
// rightward snap (delta < 0) can't collapse the clip to zero/negative.
|
|
370
|
+
const snappedDuration = Math.round((nextResize.duration + delta) * 1000) / 1000;
|
|
371
|
+
if (
|
|
372
|
+
snapped !== nextResize.start &&
|
|
373
|
+
snapped >= 0 &&
|
|
374
|
+
delta <= maxLeftDelta + 1e-6 &&
|
|
375
|
+
snappedDuration >= 0.05
|
|
376
|
+
) {
|
|
377
|
+
nextResize = {
|
|
378
|
+
...nextResize,
|
|
379
|
+
start: snapped,
|
|
380
|
+
duration: snappedDuration,
|
|
381
|
+
playbackStart:
|
|
382
|
+
nextResize.playbackStart != null
|
|
383
|
+
? Math.round(
|
|
384
|
+
Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000,
|
|
385
|
+
) / 1000
|
|
386
|
+
: undefined,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
241
392
|
setResizingClip((prev) =>
|
|
242
393
|
prev
|
|
243
394
|
? {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useRef, useCallback, useEffect } from "react";
|
|
1
|
+
import { useRef, useCallback, useEffect, useLayoutEffect } from "react";
|
|
2
2
|
import { liveTime, usePlayerStore, type ZoomMode } from "../store/playerStore";
|
|
3
3
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
4
|
import { getPinchTimelineZoomPercent } from "./timelineZoom";
|
|
@@ -54,6 +54,33 @@ export function useTimelinePlayhead({
|
|
|
54
54
|
}: UseTimelinePlayheadInput) {
|
|
55
55
|
const dragScrollRaf = useRef(0);
|
|
56
56
|
const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
|
|
57
|
+
// Center-anchored magnify: keep the time at the viewport center fixed when
|
|
58
|
+
// the zoom level (pps) changes via the toolbar / slider. The pinch handler
|
|
59
|
+
// anchors at the cursor instead, so it opts out via `skipCenterAnchorRef`.
|
|
60
|
+
const previousAnchorPpsRef = useRef(pps);
|
|
61
|
+
const skipCenterAnchorRef = useRef(false);
|
|
62
|
+
|
|
63
|
+
useLayoutEffect(() => {
|
|
64
|
+
const scroll = scrollRef.current;
|
|
65
|
+
const prevPps = previousAnchorPpsRef.current;
|
|
66
|
+
previousAnchorPpsRef.current = pps;
|
|
67
|
+
// Always consume the skip flag, even when pps didn't change — otherwise a
|
|
68
|
+
// pinch that produced no pps change (already at the zoom clamp) would strand
|
|
69
|
+
// it true and the next toolbar zoom would wrongly skip center-anchoring.
|
|
70
|
+
const skip = skipCenterAnchorRef.current;
|
|
71
|
+
skipCenterAnchorRef.current = false;
|
|
72
|
+
if (!scroll || pps === prevPps || skip) return;
|
|
73
|
+
const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
|
|
74
|
+
pointerX: scroll.clientWidth / 2,
|
|
75
|
+
currentScrollLeft: scroll.scrollLeft,
|
|
76
|
+
gutter: GUTTER,
|
|
77
|
+
currentPixelsPerSecond: prevPps,
|
|
78
|
+
nextPixelsPerSecond: pps,
|
|
79
|
+
duration: durationRef.current,
|
|
80
|
+
});
|
|
81
|
+
const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
|
|
82
|
+
scroll.scrollLeft = Math.max(0, Math.min(maxScrollLeft, nextScrollLeft));
|
|
83
|
+
}, [pps, scrollRef, durationRef]);
|
|
57
84
|
|
|
58
85
|
const syncPlayheadPosition = useCallback(
|
|
59
86
|
(time: number) => {
|
|
@@ -169,6 +196,8 @@ export function useTimelinePlayhead({
|
|
|
169
196
|
nextPixelsPerSecond: nextPps,
|
|
170
197
|
duration: durationRef.current,
|
|
171
198
|
});
|
|
199
|
+
// Pinch anchors at the cursor (below), so skip the center-anchor effect.
|
|
200
|
+
skipCenterAnchorRef.current = true;
|
|
172
201
|
setZoomMode("manual");
|
|
173
202
|
setManualZoomPercent(nextZoomPercent);
|
|
174
203
|
requestAnimationFrame(() => {
|
|
@@ -41,9 +41,30 @@ import {
|
|
|
41
41
|
setPreviewPlaybackRate,
|
|
42
42
|
shouldMutePreviewAudio,
|
|
43
43
|
} from "../lib/timelineIframeHelpers";
|
|
44
|
-
import {
|
|
44
|
+
import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
|
|
45
|
+
import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
|
|
45
46
|
import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
|
|
46
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Whether the derived elements differ from the current ones in any field that
|
|
50
|
+
* affects rendering (identity, timing, track, or source length) — used to skip
|
|
51
|
+
* redundant store writes.
|
|
52
|
+
*/
|
|
53
|
+
function timelineElementsChanged(prev: TimelineElement[], next: TimelineElement[]): boolean {
|
|
54
|
+
if (next.length !== prev.length) return true;
|
|
55
|
+
return next.some((el, i) => {
|
|
56
|
+
const p = prev[i];
|
|
57
|
+
return (
|
|
58
|
+
!p ||
|
|
59
|
+
el.id !== p.id ||
|
|
60
|
+
el.start !== p.start ||
|
|
61
|
+
el.duration !== p.duration ||
|
|
62
|
+
el.track !== p.track ||
|
|
63
|
+
el.sourceDuration !== p.sourceDuration
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
export function useTimelinePlayer() {
|
|
48
69
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
49
70
|
const rafRef = useRef<number>(0);
|
|
@@ -65,27 +86,19 @@ export function useTimelinePlayer() {
|
|
|
65
86
|
(elements: TimelineElement[], nextDuration?: number) => {
|
|
66
87
|
const state = usePlayerStore.getState();
|
|
67
88
|
const resolvedDuration = nextDuration ?? state.duration;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
89
|
+
// applyCachedSourceDurations re-applies the cached probe duration: re-derived
|
|
90
|
+
// elements (e.g. after a clip move) can arrive without sourceDuration, which
|
|
91
|
+
// otherwise makes trimmed waveforms lose their window.
|
|
92
|
+
const mergedElements = applyCachedSourceDurations(
|
|
93
|
+
mergeTimelineElementsPreservingDowngrades(
|
|
94
|
+
state.elements,
|
|
95
|
+
elements,
|
|
96
|
+
state.duration,
|
|
97
|
+
resolvedDuration,
|
|
98
|
+
),
|
|
73
99
|
);
|
|
74
100
|
|
|
75
|
-
|
|
76
|
-
mergedElements.length !== state.elements.length ||
|
|
77
|
-
mergedElements.some((el, i) => {
|
|
78
|
-
const prev = state.elements[i];
|
|
79
|
-
return (
|
|
80
|
-
!prev ||
|
|
81
|
-
el.id !== prev.id ||
|
|
82
|
-
el.start !== prev.start ||
|
|
83
|
-
el.duration !== prev.duration ||
|
|
84
|
-
el.track !== prev.track
|
|
85
|
-
);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
if (elementsChanged) {
|
|
101
|
+
if (timelineElementsChanged(state.elements, mergedElements)) {
|
|
89
102
|
setElements(mergedElements);
|
|
90
103
|
}
|
|
91
104
|
if (
|
|
@@ -99,31 +112,17 @@ export function useTimelinePlayer() {
|
|
|
99
112
|
setTimelineReady(true);
|
|
100
113
|
}
|
|
101
114
|
|
|
102
|
-
// Asynchronously enrich media elements missing sourceDuration
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
needsProbe.map(async (el) => {
|
|
114
|
-
const result = await probeMediaUrl(el.src!);
|
|
115
|
-
if (!result) return;
|
|
116
|
-
const key = el.key ?? el.id;
|
|
117
|
-
usePlayerStore.setState((state) => {
|
|
118
|
-
const idx = state.elements.findIndex((e) => (e.key ?? e.id) === key);
|
|
119
|
-
if (idx === -1 || state.elements[idx].sourceDuration != null) return {};
|
|
120
|
-
const patched = state.elements.slice();
|
|
121
|
-
patched[idx] = { ...state.elements[idx], sourceDuration: result.duration };
|
|
122
|
-
return { elements: patched };
|
|
123
|
-
});
|
|
124
|
-
}),
|
|
125
|
-
);
|
|
126
|
-
}
|
|
115
|
+
// Asynchronously enrich media elements still missing sourceDuration
|
|
116
|
+
// (header-only probe, cheap), applying each resolved value to the store.
|
|
117
|
+
void probeMissingSourceDurations(mergedElements, (key, durationSeconds) => {
|
|
118
|
+
usePlayerStore.setState((state) => {
|
|
119
|
+
const idx = state.elements.findIndex((e) => (e.key ?? e.id) === key);
|
|
120
|
+
if (idx === -1 || state.elements[idx].sourceDuration != null) return {};
|
|
121
|
+
const patched = state.elements.slice();
|
|
122
|
+
patched[idx] = { ...state.elements[idx], sourceDuration: durationSeconds };
|
|
123
|
+
return { elements: patched };
|
|
124
|
+
});
|
|
125
|
+
});
|
|
127
126
|
},
|
|
128
127
|
[setElements, setTimelineReady, setDuration],
|
|
129
128
|
);
|
|
@@ -280,6 +279,7 @@ export function useTimelinePlayer() {
|
|
|
280
279
|
const play = useCallback(() => {
|
|
281
280
|
stopRAFLoop();
|
|
282
281
|
stopReverseLoop();
|
|
282
|
+
stopScrubPreviewAudio();
|
|
283
283
|
const adapter = getAdapter();
|
|
284
284
|
if (!adapter) return;
|
|
285
285
|
if (adapter.getTime() >= adapter.getDuration()) {
|
|
@@ -392,6 +392,7 @@ export function useTimelinePlayer() {
|
|
|
392
392
|
adapter.seek(nextTime, options);
|
|
393
393
|
liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render
|
|
394
394
|
setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
|
|
395
|
+
if (!shouldResumeAfterSeek && !keepPlaying) scrubMusicAtSeek(iframeRef.current, nextTime);
|
|
395
396
|
if (shouldResumeAfterSeek) {
|
|
396
397
|
stopRAFLoop();
|
|
397
398
|
applyPlaybackRate(usePlayerStore.getState().playbackRate);
|
|
@@ -557,6 +558,7 @@ export function useTimelinePlayer() {
|
|
|
557
558
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
558
559
|
stopRAFLoop();
|
|
559
560
|
stopReverseLoop();
|
|
561
|
+
stopScrubPreviewAudio();
|
|
560
562
|
releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
|
|
561
563
|
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
562
564
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
interface MediaProbeResult {
|
|
2
2
|
duration: number;
|
|
3
3
|
width?: number;
|
|
4
4
|
height?: number;
|
|
@@ -61,11 +61,54 @@ async function probeOne(url: string): Promise<MediaProbeResult | null> {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
function getCachedProbe(url: string): MediaProbeResult | undefined {
|
|
65
65
|
return cache.get(normalizeUrl(url));
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Re-apply the cached probe `sourceDuration` to media elements that arrive
|
|
70
|
+
* without it. Re-deriving the timeline (e.g. after a clip move) produces fresh
|
|
71
|
+
* objects whose duration the DOM scan may not have, and the async probe skips
|
|
72
|
+
* already-cached srcs — so without this, trimmed waveforms lose their window.
|
|
73
|
+
*/
|
|
74
|
+
export function applyCachedSourceDurations<
|
|
75
|
+
T extends { src?: string; tag: string; sourceDuration?: number },
|
|
76
|
+
>(elements: T[]): T[] {
|
|
77
|
+
return elements.map((el) => {
|
|
78
|
+
const tag = el.tag.toLowerCase();
|
|
79
|
+
if (!el.src || el.sourceDuration != null || (tag !== "audio" && tag !== "video")) return el;
|
|
80
|
+
const cached = getCachedProbe(el.src);
|
|
81
|
+
return cached?.duration && cached.duration > 0
|
|
82
|
+
? { ...el, sourceDuration: cached.duration }
|
|
83
|
+
: el;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Probe (header-only, cheap) any media elements still missing sourceDuration
|
|
89
|
+
* after the cache pass, applying each resolved duration via `apply(key, secs)`.
|
|
90
|
+
* Skips already-cached srcs.
|
|
91
|
+
*/
|
|
92
|
+
export async function probeMissingSourceDurations<
|
|
93
|
+
T extends { src?: string; tag: string; sourceDuration?: number; key?: string; id: string },
|
|
94
|
+
>(elements: T[], apply: (key: string, durationSeconds: number) => void): Promise<void> {
|
|
95
|
+
const needs = elements.filter(
|
|
96
|
+
(el) =>
|
|
97
|
+
el.src &&
|
|
98
|
+
el.sourceDuration == null &&
|
|
99
|
+
["video", "audio"].includes(el.tag.toLowerCase()) &&
|
|
100
|
+
!getCachedProbe(el.src),
|
|
101
|
+
);
|
|
102
|
+
if (needs.length === 0) return;
|
|
103
|
+
await Promise.allSettled(
|
|
104
|
+
needs.map(async (el) => {
|
|
105
|
+
const result = await probeMediaUrl(el.src!);
|
|
106
|
+
if (result) apply(el.key ?? el.id, result.duration);
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function probeMediaUrl(url: string): Promise<MediaProbeResult | null> {
|
|
69
112
|
const key = normalizeUrl(url);
|
|
70
113
|
const cached = cache.get(key);
|
|
71
114
|
if (cached) return cached;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
2
|
+
import { isMusicTrack } from "../../utils/timelineInspector";
|
|
3
|
+
import { scrubPreviewAudio, stopScrubPreviewAudio } from "./timelineIframeHelpers";
|
|
4
|
+
|
|
5
|
+
export { stopScrubPreviewAudio };
|
|
6
|
+
|
|
7
|
+
// Scrub the music track's audio at a seeked composition time (paused-seek only).
|
|
8
|
+
// Skipped when audio is muted or the time falls outside the music clip.
|
|
9
|
+
export function scrubMusicAtSeek(iframe: HTMLIFrameElement | null, nextTime: number): void {
|
|
10
|
+
const s = usePlayerStore.getState();
|
|
11
|
+
const music = s.elements.find(isMusicTrack);
|
|
12
|
+
if (!music || s.audioMuted) return;
|
|
13
|
+
const rel = nextTime - music.start;
|
|
14
|
+
const audioFileTime = rel >= 0 && rel <= music.duration ? (music.playbackStart ?? 0) + rel : null;
|
|
15
|
+
scrubPreviewAudio(iframe, audioFileTime, music.domId ?? music.id);
|
|
16
|
+
}
|
|
@@ -115,6 +115,8 @@ export function createTimelineElementFromManifestClip(params: {
|
|
|
115
115
|
|
|
116
116
|
if (hostEl) {
|
|
117
117
|
applyMediaMetadataFromElement(entry, hostEl);
|
|
118
|
+
const timelineRole = hostEl.getAttribute("data-timeline-role");
|
|
119
|
+
if (timelineRole) entry.timelineRole = timelineRole;
|
|
118
120
|
}
|
|
119
121
|
if (clip.assetUrl) entry.src = clip.assetUrl;
|
|
120
122
|
if (clip.kind === "composition" && clip.compositionId) {
|
|
@@ -286,17 +288,23 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
|
|
286
288
|
if (mediaEl.tagName === "IMG") {
|
|
287
289
|
entry.tag = "img";
|
|
288
290
|
}
|
|
289
|
-
const src = mediaEl.getAttribute("src");
|
|
290
|
-
if (src) entry.src = src;
|
|
291
291
|
const vol = el.getAttribute("data-volume") ?? mediaEl.getAttribute("data-volume");
|
|
292
292
|
if (vol) entry.volume = parseFloat(vol);
|
|
293
293
|
applyMediaMetadataFromElement(entry, el);
|
|
294
|
+
// Override AFTER the helper (which sets the raw relative attribute) so the
|
|
295
|
+
// resolved absolute URL wins — the Studio can then fetch the asset
|
|
296
|
+
// regardless of whether the attribute value was relative or absolute.
|
|
297
|
+
const resolvedSrc = (mediaEl as HTMLMediaElement | HTMLImageElement).src || undefined;
|
|
298
|
+
if (resolvedSrc) entry.src = resolvedSrc;
|
|
294
299
|
}
|
|
295
300
|
|
|
296
301
|
if (el.hasAttribute("data-timeline-locked")) {
|
|
297
302
|
entry.timelineLocked = true;
|
|
298
303
|
}
|
|
299
304
|
|
|
305
|
+
const timelineRole = el.getAttribute("data-timeline-role");
|
|
306
|
+
if (timelineRole) entry.timelineRole = timelineRole;
|
|
307
|
+
|
|
300
308
|
// Sub-compositions
|
|
301
309
|
const compSrc =
|
|
302
310
|
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
|