mirror of
https://github.com/motion-canvas/motion-canvas.git
synced 2026-01-11 14:57:56 -05:00
feat: time events
This commit is contained in:
65
src/components/timeline/Label.tsx
Normal file
65
src/components/timeline/Label.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import styles from './Timeline.module.scss';
|
||||
|
||||
import type {Scene, TimeEvent} from '@motion-canvas/core/Scene';
|
||||
import {useDrag, usePlayer} from '../../hooks';
|
||||
import {useCallback, useEffect, useState} from 'preact/hooks';
|
||||
|
||||
interface LabelProps {
|
||||
event: TimeEvent;
|
||||
scene: Scene;
|
||||
fullLength: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export function Label({event, scene, duration, fullLength}: LabelProps) {
|
||||
const player = usePlayer();
|
||||
const [frame, setFrame] = useState(event.offset);
|
||||
const [handleDrag, isDragging] = useDrag(
|
||||
useCallback(
|
||||
dx => {
|
||||
setFrame(frame + (dx / fullLength) * duration);
|
||||
},
|
||||
[frame, fullLength, duration],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFrame(event.offset);
|
||||
}, [event]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) return;
|
||||
const newFrame = Math.max(0, Math.floor(frame));
|
||||
setFrame(newFrame);
|
||||
if (event.offset !== newFrame) {
|
||||
scene.setFrameEvent(event.name, newFrame);
|
||||
player.reload();
|
||||
}
|
||||
}, [isDragging, frame, event]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onMouseDown={handleDrag}
|
||||
className={styles.labelClip}
|
||||
data-name={event.name}
|
||||
style={{
|
||||
left: `${
|
||||
((scene.firstFrame + event.initialFrame + Math.max(0, frame)) /
|
||||
duration) *
|
||||
100
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={styles.labelClipStart}
|
||||
style={{
|
||||
left: `${
|
||||
((scene.firstFrame + event.initialFrame) / duration) * 100
|
||||
}%`,
|
||||
width: `${(Math.max(0, frame) / duration) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
src/components/timeline/LabelGroup.tsx
Normal file
34
src/components/timeline/LabelGroup.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type {Scene} from '@motion-canvas/core/Scene';
|
||||
import {usePlayerState} from '../../hooks';
|
||||
import {useEffect, useState} from 'preact/hooks';
|
||||
import {Label} from './Label';
|
||||
|
||||
interface LabelGroupProps {
|
||||
scene: Scene;
|
||||
fullLength: number;
|
||||
}
|
||||
|
||||
export function LabelGroup({scene, fullLength}: LabelGroupProps) {
|
||||
const state = usePlayerState();
|
||||
const [events, setEvents] = useState(scene.timeEvents);
|
||||
|
||||
useEffect(() => {
|
||||
setEvents(scene.timeEvents);
|
||||
scene.TimeEventsChanged.subscribe(setEvents);
|
||||
return () => scene.TimeEventsChanged.unsubscribe(setEvents);
|
||||
}, [scene]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{events.map(event => (
|
||||
<Label
|
||||
key={event.name}
|
||||
event={event}
|
||||
scene={scene}
|
||||
fullLength={fullLength}
|
||||
duration={state.duration}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,50 @@
|
||||
import styles from './Timeline.module.scss';
|
||||
|
||||
import {usePlayer, usePlayerState} from '../../hooks';
|
||||
import {usePlayerState, useScenes} from '../../hooks';
|
||||
import {useMemo} from 'preact/hooks';
|
||||
import {LabelGroup} from './LabelGroup';
|
||||
|
||||
export function LabelTrack() {
|
||||
const player = usePlayer();
|
||||
interface LabelTrackProps {
|
||||
fullLength: number;
|
||||
viewLength: number;
|
||||
offset: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export function LabelTrack({
|
||||
fullLength,
|
||||
viewLength,
|
||||
offset,
|
||||
scale,
|
||||
}: LabelTrackProps) {
|
||||
const scenes = useScenes();
|
||||
const state = usePlayerState();
|
||||
|
||||
// FIXME Use Context
|
||||
const power = Math.pow(
|
||||
2,
|
||||
Math.round(Math.log2(state.duration / scale / viewLength)),
|
||||
);
|
||||
const density = Math.max(1, Math.floor(128 * power));
|
||||
const startFrame =
|
||||
Math.floor(((offset / fullLength) * state.duration) / density) * density;
|
||||
const endFrame =
|
||||
Math.ceil(
|
||||
(((offset + viewLength) / fullLength) * state.duration) / density,
|
||||
) * density;
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
scenes.filter(
|
||||
scene => scene.lastFrame >= startFrame && scene.firstFrame <= endFrame,
|
||||
),
|
||||
[scenes, startFrame, endFrame],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.labelTrack}>
|
||||
{Object.entries(player.labels).map(([name, time]) => (
|
||||
<div
|
||||
className={styles.labelClip}
|
||||
data-name={name}
|
||||
style={{
|
||||
left: `${
|
||||
(player.project.secondsToFrames(time) / state.duration) * 100
|
||||
}%`,
|
||||
}}
|
||||
onClick={event => {
|
||||
event.stopPropagation();
|
||||
player.requestSeek(player.project.secondsToFrames(time));
|
||||
}}
|
||||
/>
|
||||
{filtered.map(scene => (
|
||||
<LabelGroup key={scene.name()} scene={scene} fullLength={fullLength} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@ export function RangeTrack({fullLength}: RangeTrackProps) {
|
||||
const [handleDragEnd, isDraggingEnd] = useDrag(
|
||||
useCallback(
|
||||
dx => {
|
||||
setEnd(end + (dx / fullLength) * state.duration);
|
||||
setEnd(Math.min(state.duration, end) + (dx / fullLength) * state.duration);
|
||||
},
|
||||
[end, setEnd, fullLength, state.duration],
|
||||
),
|
||||
@@ -39,11 +39,10 @@ export function RangeTrack({fullLength}: RangeTrackProps) {
|
||||
useCallback(
|
||||
dx => {
|
||||
setStart(start + (dx / fullLength) * state.duration);
|
||||
setEnd(end + (dx / fullLength) * state.duration);
|
||||
setEnd(Math.min(state.duration, end) + (dx / fullLength) * state.duration);
|
||||
},
|
||||
[start, end, fullLength, state.duration, setStart, setEnd],
|
||||
),
|
||||
1,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -54,13 +53,16 @@ export function RangeTrack({fullLength}: RangeTrackProps) {
|
||||
useEffect(() => {
|
||||
if (!isDragging && !isDraggingStart && !isDraggingEnd) {
|
||||
const correctedStart = Math.max(0, Math.floor(start));
|
||||
const correctedEnd = Math.min(state.duration, Math.floor(end));
|
||||
const correctedEnd =
|
||||
end >= state.duration
|
||||
? Infinity
|
||||
: Math.min(state.duration, Math.floor(end));
|
||||
setStart(correctedStart);
|
||||
setEnd(correctedEnd);
|
||||
|
||||
player.updateState({
|
||||
startFrame: correctedStart,
|
||||
endFrame: end === Infinity ? Infinity : correctedEnd,
|
||||
endFrame: correctedEnd,
|
||||
});
|
||||
}
|
||||
}, [isDragging, isDraggingStart, isDraggingEnd, start, end]);
|
||||
@@ -68,11 +70,19 @@ export function RangeTrack({fullLength}: RangeTrackProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
left: `${(Math.floor(start) / state.duration) * 100}%`,
|
||||
right: `${100 - (Math.floor(end + 1) / state.duration) * 100}%`,
|
||||
left: `${(Math.max(0, start) / state.duration) * 100}%`,
|
||||
right: `${100 - Math.min(1, (end + 1) / state.duration) * 100}%`,
|
||||
}}
|
||||
className={styles.range}
|
||||
onMouseDown={handleDrag}
|
||||
onMouseDown={event => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault();
|
||||
setStart(0);
|
||||
setEnd(Infinity);
|
||||
} else {
|
||||
handleDrag(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
onMouseDown={handleDragStart}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import styles from './Timeline.module.scss';
|
||||
|
||||
import {usePlayer, usePlayerState} from '../../hooks';
|
||||
import {useScenes} from '../../hooks/useScenes';
|
||||
import {usePlayer, usePlayerState, useScenes} from '../../hooks';
|
||||
|
||||
export function SceneTrack() {
|
||||
const scenes = useScenes();
|
||||
@@ -15,11 +14,14 @@ export function SceneTrack() {
|
||||
className={styles.sceneClip}
|
||||
data-name={scene.name()}
|
||||
style={{
|
||||
width: `${
|
||||
((scene.lastFrame - scene.firstFrame) / state.duration) * 100
|
||||
}%`,
|
||||
width: `${(scene.duration / state.duration) * 100}%`,
|
||||
left: `${(scene.firstFrame / state.duration) * 100}%`,
|
||||
}}
|
||||
onMouseDown={event => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onMouseUp={event => {
|
||||
if (event.button === 1) {
|
||||
event.stopPropagation();
|
||||
@@ -29,7 +31,19 @@ export function SceneTrack() {
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<div className={styles.scene}>
|
||||
<div className={styles.sceneName}>{scene.name()}</div>
|
||||
</div>
|
||||
{scene.transitionDuration > 0 && (
|
||||
<div
|
||||
style={{
|
||||
width: `${(scene.transitionDuration / scene.duration) * 100}%`,
|
||||
}}
|
||||
className={styles.transition}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -51,20 +51,41 @@
|
||||
}
|
||||
|
||||
.sceneClip {
|
||||
$padding: 4px;
|
||||
|
||||
position: absolute;
|
||||
height: 48px;
|
||||
padding: 4px;
|
||||
|
||||
&::before {
|
||||
box-sizing: border-box;
|
||||
padding: 0 12px;
|
||||
content: attr(data-name);
|
||||
display: block;
|
||||
line-height: 42px;
|
||||
.scene {
|
||||
position: absolute;
|
||||
top: $padding;
|
||||
bottom: $padding;
|
||||
left: $padding;
|
||||
right: $padding;
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(255, 255, 255, 0.16);
|
||||
|
||||
.sceneName {
|
||||
line-height: 24px;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 8px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.transition {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: $padding;
|
||||
bottom: $padding;
|
||||
left: $padding;
|
||||
border-radius: 4px 0 0 4px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(36, 36, 36, 1) 0%,
|
||||
rgba(36, 36, 36, 0) 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +112,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
.labelClipStart {
|
||||
box-sizing: content-box;
|
||||
position: absolute;
|
||||
height: 24px;
|
||||
margin-top: 4px;
|
||||
margin-left: 4px;
|
||||
padding-right: 24px;
|
||||
cursor: pointer;
|
||||
border-radius: 12px 0 12px 12px;
|
||||
background-color: var(--theme-overlay);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.playhead {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
@@ -98,6 +132,18 @@
|
||||
bottom: 0;
|
||||
background-color: var(--theme);
|
||||
pointer-events: none;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
display: block;
|
||||
left: -8px;
|
||||
top: 0;
|
||||
width: 16px;
|
||||
height: 8px;
|
||||
border-radius: 0 0 4px 4px;
|
||||
background-color: var(--theme);
|
||||
}
|
||||
}
|
||||
|
||||
.playheadPreview {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import styles from './Timeline.module.scss';
|
||||
|
||||
import {useCallback, useLayoutEffect, useRef, useState} from 'preact/hooks';
|
||||
import {
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'preact/hooks';
|
||||
import {
|
||||
useDocumentEvent,
|
||||
usePlayer,
|
||||
@@ -15,6 +20,16 @@ import {RangeTrack} from './RangeTrack';
|
||||
|
||||
const ZOOM_SPEED = 0.1;
|
||||
|
||||
function useStateChange<T>(state: T, onChange: (prev: T, next: T) => any) {
|
||||
const [cached, setCached] = useState(state);
|
||||
useLayoutEffect(() => {
|
||||
if (state !== cached) {
|
||||
onChange(cached, state);
|
||||
setCached(state);
|
||||
}
|
||||
}, [cached, state, onChange]);
|
||||
}
|
||||
|
||||
export function Timeline() {
|
||||
const player = usePlayer();
|
||||
const state = usePlayerState();
|
||||
@@ -26,6 +41,26 @@ export function Timeline() {
|
||||
|
||||
const trackSize = rect.width * scale;
|
||||
|
||||
useStateChange(
|
||||
state.duration,
|
||||
useCallback(
|
||||
(prev, next) => setScale(Math.max(1, (scale / prev) * next)),
|
||||
[scale],
|
||||
),
|
||||
);
|
||||
|
||||
useStateChange(
|
||||
rect.width,
|
||||
useCallback(
|
||||
(prev, next) => {
|
||||
if (next !== 0 && prev !== 0) {
|
||||
setScale(Math.max(1, (scale / next) * prev));
|
||||
}
|
||||
},
|
||||
[scale],
|
||||
),
|
||||
);
|
||||
|
||||
useDocumentEvent(
|
||||
'keydown',
|
||||
useCallback(
|
||||
@@ -68,13 +103,15 @@ export function Timeline() {
|
||||
newScroll < 0 ? 0 : newScroll > maxOffset ? maxOffset : newScroll,
|
||||
);
|
||||
}}
|
||||
onMouseUp={event =>
|
||||
player.requestSeek(
|
||||
Math.floor(
|
||||
((scroll + event.x - rect.x) / trackSize) * state.duration,
|
||||
),
|
||||
)
|
||||
}
|
||||
onMouseUp={event => {
|
||||
if (event.button === 0) {
|
||||
player.requestSeek(
|
||||
Math.floor(
|
||||
((scroll + event.x - rect.x) / trackSize) * state.duration,
|
||||
),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onMouseMove={event => {
|
||||
playheadRef.current.style.left = `${event.x - rect.x + scroll}px`;
|
||||
}}
|
||||
@@ -93,7 +130,12 @@ export function Timeline() {
|
||||
scale={scale}
|
||||
/>
|
||||
<SceneTrack />
|
||||
<LabelTrack />
|
||||
<LabelTrack
|
||||
fullLength={trackSize}
|
||||
viewLength={rect.width}
|
||||
offset={scroll}
|
||||
scale={scale}
|
||||
/>
|
||||
</div>
|
||||
<div ref={playheadRef} className={styles.playheadPreview} />
|
||||
<Playhead trackSize={trackSize} />
|
||||
|
||||
@@ -17,12 +17,12 @@ export function TimestampTrack({
|
||||
scale,
|
||||
}: TimestampTrackProps) {
|
||||
const state = usePlayerState();
|
||||
const power = Math.pow(2, Math.round(Math.log2(scale)));
|
||||
const density = Math.max(
|
||||
1,
|
||||
Math.floor((Math.floor((state.duration * 20) / viewLength) * 10) / power),
|
||||
const power = Math.pow(
|
||||
2,
|
||||
Math.round(Math.log2(state.duration / scale / viewLength)),
|
||||
);
|
||||
|
||||
const density = Math.max(1, Math.floor(128 * power));
|
||||
const startFrame = Math.floor(
|
||||
((offset / fullLength) * state.duration) / density,
|
||||
);
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from './usePlayerTime';
|
||||
export * from './useDocumentEvent';
|
||||
export * from './useSize';
|
||||
export * from './useDrag';
|
||||
export * from './useScenes';
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useCallback, useState } from "preact/hooks";
|
||||
import {useCallback, useMemo, useState} from 'preact/hooks';
|
||||
|
||||
export function useStorage<T>(
|
||||
id: string,
|
||||
initialState?: T,
|
||||
): [T, (newState: T) => void] {
|
||||
if (id) {
|
||||
const savedState = useMemo(() => {
|
||||
const savedState = localStorage.getItem(id);
|
||||
if (savedState) {
|
||||
initialState = JSON.parse(savedState);
|
||||
}
|
||||
}
|
||||
const [state, setState] = useState<T>(initialState ?? null);
|
||||
return savedState ? JSON.parse(savedState) : null;
|
||||
}, [id]);
|
||||
const [state, setState] = useState<T>(savedState ?? initialState ?? null);
|
||||
|
||||
const updateState = useCallback(
|
||||
(newState: T) => {
|
||||
@@ -23,4 +21,4 @@ export function useStorage<T>(
|
||||
);
|
||||
|
||||
return [state, updateState];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user