feat: time events

This commit is contained in:
aarthificial
2022-04-20 19:10:45 +02:00
parent ed2d78dbba
commit 026a2840a3
10 changed files with 294 additions and 61 deletions

View 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}%`,
}}
/>
</>
);
}

View 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}
/>
))}
</>
);
}

View File

@@ -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>
);

View File

@@ -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}

View File

@@ -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>
);

View File

@@ -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 {

View File

@@ -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} />

View File

@@ -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,
);

View File

@@ -5,3 +5,4 @@ export * from './usePlayerTime';
export * from './useDocumentEvent';
export * from './useSize';
export * from './useDrag';
export * from './useScenes';

View File

@@ -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];
}
}