mirror of
https://github.com/motion-canvas/motion-canvas.git
synced 2026-01-11 14:57:56 -05:00
feat: use Web Audio API for waveform generation
This commit is contained in:
161
package-lock.json
generated
161
package-lock.json
generated
@@ -9,7 +9,8 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"preact": "^10.7.1"
|
||||
"preact": "^10.7.1",
|
||||
"strongly-typed-events": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/wicg-file-system-access": "^2020.9.5",
|
||||
@@ -47,6 +48,97 @@
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ste-core": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ste-core/-/ste-core-3.0.2.tgz",
|
||||
"integrity": "sha512-jX6MwOP78lt5AJsDouGrXEAd4ivYF/xLgzUDa5bpVfScIVhs2K5yG+ZxGJzpyEZFFseYJYhyLvsLDCLETW4W/w==",
|
||||
"engines": {
|
||||
"node": ">=4.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ste-events": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ste-events/-/ste-events-3.0.2.tgz",
|
||||
"integrity": "sha512-lfNXJJLhDsZUyOLYkdldlFGzgWyI+FMza1x9VNzKKa6MV86yvUig6u6e8qOl0QMMXVreWqCf2jeFC3BczZwtcg==",
|
||||
"dependencies": {
|
||||
"ste-core": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ste-promise-events": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ste-promise-events/-/ste-promise-events-3.0.2.tgz",
|
||||
"integrity": "sha512-JUvHOYGzZgwyy6kg/FJH7cQKp179BgOqeKhlxS31gAjrzKpNLGmgMRnjW+/NPyOmuEBDpaA6uV3lbQ5rc3BDVg==",
|
||||
"dependencies": {
|
||||
"ste-core": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ste-promise-signals": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ste-promise-signals/-/ste-promise-signals-3.0.2.tgz",
|
||||
"integrity": "sha512-YbPJJFVxXEwvKXTguItJFhukz6kT3n0BsY/wzw9B4mKo6hQlxCMtYdR72r1WwthnMvk0NDI1gP4JV+f5rOHEvA==",
|
||||
"dependencies": {
|
||||
"ste-core": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ste-promise-simple-events": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ste-promise-simple-events/-/ste-promise-simple-events-3.0.2.tgz",
|
||||
"integrity": "sha512-DbIwA1Gz0sYk6ruUAew4M7i1p1U6ULAITy7a25/bAxxA7l2nMlk3Cd54CSNqGVdBHnrR8Nv4rC9e99LXs+BiFA==",
|
||||
"dependencies": {
|
||||
"ste-core": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ste-signals": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ste-signals/-/ste-signals-3.0.2.tgz",
|
||||
"integrity": "sha512-bWDtxCBs7cq3bModkFGyTRzAGm9QSAlrGXV7xxKmgyc2VMkF/E79Y4k3CcMEVT7Odj1ieL8Fg8m21gL43sOLSg==",
|
||||
"dependencies": {
|
||||
"ste-core": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ste-simple-events": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ste-simple-events/-/ste-simple-events-3.0.2.tgz",
|
||||
"integrity": "sha512-NvHH+JX6SjpMtgmcAwqMAsJ6nyGdGp1/oaGTdK/RVQGIAL0bLKRnHAyiH+ygMJ3P2SPPUPApIjqoq1VGK2uH8A==",
|
||||
"dependencies": {
|
||||
"ste-core": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/strongly-typed-events": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/strongly-typed-events/-/strongly-typed-events-3.0.2.tgz",
|
||||
"integrity": "sha512-9KKqQwqjXc6zbqi1iIFoAX+9jPoBIFcel43HCBWYskgEpg3g8R1J3hTzUH8GYfu1EOYwEIjL6Yw0KnceSdDjLw==",
|
||||
"dependencies": {
|
||||
"ste-core": "^3.0.2",
|
||||
"ste-events": "^3.0.2",
|
||||
"ste-promise-events": "^3.0.2",
|
||||
"ste-promise-signals": "^3.0.2",
|
||||
"ste-promise-simple-events": "^3.0.2",
|
||||
"ste-signals": "^3.0.2",
|
||||
"ste-simple-events": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "4.6.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz",
|
||||
@@ -79,6 +171,73 @@
|
||||
"integrity": "sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew==",
|
||||
"dev": true
|
||||
},
|
||||
"ste-core": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ste-core/-/ste-core-3.0.2.tgz",
|
||||
"integrity": "sha512-jX6MwOP78lt5AJsDouGrXEAd4ivYF/xLgzUDa5bpVfScIVhs2K5yG+ZxGJzpyEZFFseYJYhyLvsLDCLETW4W/w=="
|
||||
},
|
||||
"ste-events": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ste-events/-/ste-events-3.0.2.tgz",
|
||||
"integrity": "sha512-lfNXJJLhDsZUyOLYkdldlFGzgWyI+FMza1x9VNzKKa6MV86yvUig6u6e8qOl0QMMXVreWqCf2jeFC3BczZwtcg==",
|
||||
"requires": {
|
||||
"ste-core": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"ste-promise-events": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ste-promise-events/-/ste-promise-events-3.0.2.tgz",
|
||||
"integrity": "sha512-JUvHOYGzZgwyy6kg/FJH7cQKp179BgOqeKhlxS31gAjrzKpNLGmgMRnjW+/NPyOmuEBDpaA6uV3lbQ5rc3BDVg==",
|
||||
"requires": {
|
||||
"ste-core": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"ste-promise-signals": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ste-promise-signals/-/ste-promise-signals-3.0.2.tgz",
|
||||
"integrity": "sha512-YbPJJFVxXEwvKXTguItJFhukz6kT3n0BsY/wzw9B4mKo6hQlxCMtYdR72r1WwthnMvk0NDI1gP4JV+f5rOHEvA==",
|
||||
"requires": {
|
||||
"ste-core": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"ste-promise-simple-events": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ste-promise-simple-events/-/ste-promise-simple-events-3.0.2.tgz",
|
||||
"integrity": "sha512-DbIwA1Gz0sYk6ruUAew4M7i1p1U6ULAITy7a25/bAxxA7l2nMlk3Cd54CSNqGVdBHnrR8Nv4rC9e99LXs+BiFA==",
|
||||
"requires": {
|
||||
"ste-core": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"ste-signals": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ste-signals/-/ste-signals-3.0.2.tgz",
|
||||
"integrity": "sha512-bWDtxCBs7cq3bModkFGyTRzAGm9QSAlrGXV7xxKmgyc2VMkF/E79Y4k3CcMEVT7Odj1ieL8Fg8m21gL43sOLSg==",
|
||||
"requires": {
|
||||
"ste-core": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"ste-simple-events": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ste-simple-events/-/ste-simple-events-3.0.2.tgz",
|
||||
"integrity": "sha512-NvHH+JX6SjpMtgmcAwqMAsJ6nyGdGp1/oaGTdK/RVQGIAL0bLKRnHAyiH+ygMJ3P2SPPUPApIjqoq1VGK2uH8A==",
|
||||
"requires": {
|
||||
"ste-core": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"strongly-typed-events": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/strongly-typed-events/-/strongly-typed-events-3.0.2.tgz",
|
||||
"integrity": "sha512-9KKqQwqjXc6zbqi1iIFoAX+9jPoBIFcel43HCBWYskgEpg3g8R1J3hTzUH8GYfu1EOYwEIjL6Yw0KnceSdDjLw==",
|
||||
"requires": {
|
||||
"ste-core": "^3.0.2",
|
||||
"ste-events": "^3.0.2",
|
||||
"ste-promise-events": "^3.0.2",
|
||||
"ste-promise-signals": "^3.0.2",
|
||||
"ste-promise-simple-events": "^3.0.2",
|
||||
"ste-signals": "^3.0.2",
|
||||
"ste-simple-events": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"typescript": {
|
||||
"version": "4.6.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz",
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"preact": "^10.7.1"
|
||||
"preact": "^10.7.1",
|
||||
"strongly-typed-events": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/wicg-file-system-access": "^2020.9.5",
|
||||
|
||||
@@ -29,7 +29,7 @@ export function PlaybackControls() {
|
||||
player.toggleAudio();
|
||||
break;
|
||||
case 'l':
|
||||
player.updateState({loop: !state.loop});
|
||||
player.toggleLoop();
|
||||
break;
|
||||
}
|
||||
},
|
||||
@@ -48,7 +48,7 @@ export function PlaybackControls() {
|
||||
{value: 2, text: 'x2'},
|
||||
]}
|
||||
value={state.speed}
|
||||
onChange={speed => player.updateState({speed})}
|
||||
onChange={speed => player.setSpeed(speed)}
|
||||
/>
|
||||
<IconCheckbox
|
||||
id={'audio'}
|
||||
@@ -78,7 +78,7 @@ export function PlaybackControls() {
|
||||
iconOn={IconType.repeat}
|
||||
iconOff={IconType.repeat}
|
||||
checked={state.loop}
|
||||
onChange={value => player.updateState({loop: value})}
|
||||
onChange={() => player.toggleLoop()}
|
||||
/>
|
||||
<Framerate
|
||||
render={framerate => (
|
||||
|
||||
@@ -3,7 +3,7 @@ import styles from './Sidebar.module.scss';
|
||||
import type {PlayerRenderEvent} from '@motion-canvas/core/player/Player';
|
||||
import {IconType} from '../controls';
|
||||
import {Tabs} from '../tabs/Tabs';
|
||||
import {usePlayer, usePlayerState} from '../../hooks';
|
||||
import { useEventEffect, usePlayer, usePlayerState } from "../../hooks";
|
||||
import {useCallback, useEffect, useMemo, useState} from 'preact/hooks';
|
||||
import {Thread} from '@motion-canvas/core/threading';
|
||||
import {GeneratorHelper} from '@motion-canvas/core/helpers';
|
||||
@@ -53,23 +53,22 @@ function Rendering() {
|
||||
];
|
||||
}, [width, height]);
|
||||
|
||||
const handleRender = useCallback(async ({frame, data}: PlayerRenderEvent) => {
|
||||
try {
|
||||
const name = frame.toString().padStart(6, '0');
|
||||
await fetch(`/render/frame${name}.png`, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
player.toggleRendering(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
player.RenderChanged.subscribe(handleRender);
|
||||
return () => player.RenderChanged.unsubscribe(handleRender);
|
||||
}, [handleRender]);
|
||||
useEventEffect(
|
||||
player.FrameRendered,
|
||||
async ({frame, data}: PlayerRenderEvent) => {
|
||||
try {
|
||||
const name = frame.toString().padStart(6, '0');
|
||||
await fetch(`/render/frame${name}.png`, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
player.toggleRendering(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.pane}>
|
||||
@@ -82,9 +81,7 @@ function Rendering() {
|
||||
type={'number'}
|
||||
value={state.startFrame}
|
||||
onChange={event => {
|
||||
player.updateState({
|
||||
startFrame: parseInt((event.target as HTMLInputElement).value),
|
||||
});
|
||||
player.setRange(parseInt((event.target as HTMLInputElement).value));
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
@@ -94,9 +91,7 @@ function Rendering() {
|
||||
value={Math.min(state.duration, state.endFrame)}
|
||||
onChange={event => {
|
||||
const value = parseInt((event.target as HTMLInputElement).value);
|
||||
player.updateState({
|
||||
endFrame: value >= state.duration ? Infinity : value,
|
||||
});
|
||||
player.setRange(undefined, value);
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -2,55 +2,56 @@ import styles from './Timeline.module.scss';
|
||||
|
||||
import {useContext, useLayoutEffect, useMemo, useRef} from 'preact/hooks';
|
||||
import {TimelineContext} from './TimelineContext';
|
||||
import {usePlayer} from '../../hooks';
|
||||
import {usePlayer, useEventState} from '../../hooks';
|
||||
|
||||
const HEIGHT = 48;
|
||||
|
||||
export function AudioTrack() {
|
||||
const ref = useRef<HTMLCanvasElement>();
|
||||
const player = usePlayer();
|
||||
const {project, audio} = usePlayer();
|
||||
const context = useMemo(() => ref.current?.getContext('2d'), [ref.current]);
|
||||
const {viewLength, startFrame, endFrame, duration, density} =
|
||||
useContext(TimelineContext);
|
||||
|
||||
const audioData = useEventState(audio.DataChanged, () => audio.getData());
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!context) return;
|
||||
context.clearRect(0, 0, viewLength, HEIGHT * 2);
|
||||
if (!audioData) return;
|
||||
|
||||
const audio = player.audio.meta;
|
||||
const samplesPerSeconds = audio.sample_rate / audio.samples_per_pixel;
|
||||
const step = Math.ceil(density / 256);
|
||||
const start = Math.floor(
|
||||
(player.project.framesToSeconds(startFrame) + player.audio.offset) *
|
||||
samplesPerSeconds,
|
||||
);
|
||||
const end = Math.floor(
|
||||
(player.project.framesToSeconds(endFrame) + player.audio.offset) *
|
||||
samplesPerSeconds,
|
||||
);
|
||||
|
||||
context.clearRect(0, 0, viewLength, 64);
|
||||
context.strokeStyle = 'white';
|
||||
context.lineWidth = 1;
|
||||
context.beginPath();
|
||||
context.moveTo(0, HEIGHT);
|
||||
|
||||
const start =
|
||||
audio.toRelativeTime(project.framesToSeconds(startFrame)) *
|
||||
audioData.sampleRate;
|
||||
const end =
|
||||
audio.toRelativeTime(project.framesToSeconds(endFrame)) *
|
||||
audioData.sampleRate;
|
||||
|
||||
const flooredStart = ~~start;
|
||||
const padding = flooredStart - start;
|
||||
const length = end - start;
|
||||
for (let offset = 0; offset < length; offset += step) {
|
||||
const sample = (start + offset) * 2;
|
||||
if (sample >= audio.data.length) break;
|
||||
const step = Math.ceil(density);
|
||||
for (let offset = 0; offset < length; offset += step * 2) {
|
||||
const sample = flooredStart + offset;
|
||||
if (sample >= audioData.peaks.length) break;
|
||||
|
||||
context.lineTo(
|
||||
(offset / length) * viewLength,
|
||||
(audio.data[sample] / 32767) * HEIGHT + HEIGHT,
|
||||
((padding + offset) / length) * viewLength,
|
||||
(audioData.peaks[sample] / audioData.absoluteMax) * HEIGHT + HEIGHT,
|
||||
);
|
||||
context.lineTo(
|
||||
((offset + 0.5) / length) * viewLength,
|
||||
(audio.data[sample + 1] / 32767) * HEIGHT + HEIGHT,
|
||||
((padding + offset + step) / length) * viewLength,
|
||||
(audioData.peaks[sample + 1] / audioData.absoluteMax) * HEIGHT + HEIGHT,
|
||||
);
|
||||
}
|
||||
|
||||
context.stroke();
|
||||
}, [context, density, viewLength, startFrame, endFrame]);
|
||||
}, [context, audioData, density, viewLength, startFrame, endFrame]);
|
||||
|
||||
const style = useMemo(
|
||||
() => ({
|
||||
@@ -65,7 +66,7 @@ export function AudioTrack() {
|
||||
<canvas
|
||||
style={style}
|
||||
width={viewLength}
|
||||
height={64}
|
||||
height={HEIGHT * 2}
|
||||
ref={ref}
|
||||
className={styles.audioTrack}
|
||||
/>
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import type {Scene} from '@motion-canvas/core/Scene';
|
||||
import {useEffect, useState} from 'preact/hooks';
|
||||
import {Label} from './Label';
|
||||
import {useEventState} from '../../hooks';
|
||||
|
||||
interface LabelGroupProps {
|
||||
scene: Scene;
|
||||
}
|
||||
|
||||
export function LabelGroup({scene}: LabelGroupProps) {
|
||||
const [events, setEvents] = useState(scene.timeEvents);
|
||||
|
||||
useEffect(() => {
|
||||
setEvents(scene.timeEvents);
|
||||
scene.TimeEventsChanged.subscribe(setEvents);
|
||||
return () => scene.TimeEventsChanged.unsubscribe(setEvents);
|
||||
}, [scene]);
|
||||
const events = useEventState(scene.TimeEventsChanged, () => scene.timeEvents);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -14,17 +14,7 @@ export function RangeTrack() {
|
||||
const [end, setEnd] = useState(state.endFrame);
|
||||
|
||||
const onDrop = useCallback(() => {
|
||||
let startFrame = Math.max(0, Math.floor(start));
|
||||
let endFrame =
|
||||
end >= state.duration
|
||||
? Infinity
|
||||
: Math.min(state.duration, Math.floor(end));
|
||||
|
||||
if (startFrame > endFrame) {
|
||||
[startFrame, endFrame] = [endFrame, startFrame];
|
||||
}
|
||||
|
||||
player.updateState({startFrame, endFrame});
|
||||
player.setRange(Math.floor(start), Math.floor(end));
|
||||
}, [start, end, state.duration]);
|
||||
|
||||
const [handleDragStart] = useDrag(
|
||||
@@ -79,16 +69,15 @@ export function RangeTrack() {
|
||||
style={{
|
||||
flexDirection: start > end ? 'row-reverse' : 'row',
|
||||
left: `${(Math.max(0, normalizedStart) / state.duration) * 100}%`,
|
||||
right: `${100 - Math.min(1, (normalizedEnd + 1) / state.duration) * 100}%`,
|
||||
right: `${
|
||||
100 - Math.min(1, (normalizedEnd + 1) / state.duration) * 100
|
||||
}%`,
|
||||
}}
|
||||
className={styles.range}
|
||||
onMouseDown={event => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault();
|
||||
player.updateState({
|
||||
startFrame: 0,
|
||||
endFrame: Infinity,
|
||||
});
|
||||
player.setRange(0, Infinity);
|
||||
} else {
|
||||
handleDrag(event);
|
||||
}
|
||||
@@ -99,6 +88,7 @@ export function RangeTrack() {
|
||||
className={styles.handle}
|
||||
type={IconType.dragIndicator}
|
||||
/>
|
||||
<div class={styles.handleSpacer}/>
|
||||
<Icon
|
||||
onMouseDown={handleDragEnd}
|
||||
onDblClick={console.log}
|
||||
|
||||
@@ -25,10 +25,7 @@ export function SceneTrack() {
|
||||
onMouseUp={event => {
|
||||
if (event.button === 1) {
|
||||
event.stopPropagation();
|
||||
player.updateState({
|
||||
startFrame: scene.firstFrame,
|
||||
endFrame: scene.lastFrame - 1,
|
||||
});
|
||||
player.setRange(scene.firstFrame, scene.lastFrame - 1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -223,10 +223,6 @@
|
||||
cursor: pointer;
|
||||
padding: 8px 2px;
|
||||
|
||||
&:first-child {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
&:after {
|
||||
background-color: rgba(255, 255, 255, 0);
|
||||
}
|
||||
@@ -243,6 +239,12 @@
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.handleSpacer {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.audioTrack {
|
||||
opacity: 0.16;
|
||||
margin: 24px 0;
|
||||
}
|
||||
@@ -45,16 +45,15 @@ export function Timeline() {
|
||||
|
||||
const state = useMemo<TimelineState>(() => {
|
||||
const fullLength = rect.width * scale;
|
||||
const power = Math.pow(
|
||||
2,
|
||||
Math.round(Math.log2(duration / scale / rect.width)),
|
||||
);
|
||||
const density = Math.max(1, Math.floor(128 * power));
|
||||
const density = Math.pow(2, Math.round(Math.log2(duration / fullLength)));
|
||||
const segmentDensity = Math.max(1, Math.floor(128 * density));
|
||||
const startFrame =
|
||||
Math.floor(((offset / fullLength) * duration) / density) * density;
|
||||
Math.floor(((offset / fullLength) * duration) / segmentDensity) *
|
||||
segmentDensity;
|
||||
const endFrame =
|
||||
Math.ceil((((offset + rect.width) / fullLength) * duration) / density) *
|
||||
density;
|
||||
Math.ceil(
|
||||
(((offset + rect.width) / fullLength) * duration) / segmentDensity,
|
||||
) * segmentDensity;
|
||||
|
||||
return {
|
||||
scale,
|
||||
@@ -64,6 +63,7 @@ export function Timeline() {
|
||||
startFrame,
|
||||
endFrame,
|
||||
density,
|
||||
segmentDensity,
|
||||
duration,
|
||||
};
|
||||
}, [rect.width, scale, duration, offset]);
|
||||
@@ -127,12 +127,18 @@ export function Timeline() {
|
||||
const pointer = offset + event.x - rect.x;
|
||||
const newTrackSize = rect.width * newScale;
|
||||
const maxOffset = newTrackSize - rect.width;
|
||||
const newOffset = clamp(0, maxOffset, offset - pointer + pointer * ratio);
|
||||
const newOffset = clamp(
|
||||
0,
|
||||
maxOffset,
|
||||
offset - pointer + pointer * ratio,
|
||||
);
|
||||
|
||||
containerRef.current.scrollLeft = newOffset;
|
||||
setScale(newScale);
|
||||
setOffset(newOffset);
|
||||
playheadRef.current.style.left = `${event.x - rect.x + newOffset}px`;
|
||||
playheadRef.current.style.left = `${
|
||||
event.x - rect.x + newOffset
|
||||
}px`;
|
||||
}}
|
||||
onMouseUp={event => {
|
||||
if (event.button === 0) {
|
||||
@@ -155,7 +161,7 @@ export function Timeline() {
|
||||
<TimestampTrack />
|
||||
<SceneTrack />
|
||||
<LabelTrack />
|
||||
{player.audio && <AudioTrack />}
|
||||
<AudioTrack />
|
||||
</div>
|
||||
<div ref={playheadRef} className={styles.playheadPreview} />
|
||||
<Playhead />
|
||||
|
||||
@@ -1,13 +1,41 @@
|
||||
import {createContext} from 'preact';
|
||||
|
||||
export interface TimelineState {
|
||||
/**
|
||||
* Length of the entire timeline in pixels
|
||||
*/
|
||||
fullLength: number;
|
||||
/**
|
||||
* Length of the visible area in pixels.
|
||||
*/
|
||||
viewLength: number;
|
||||
/**
|
||||
* The left offset of the view in pixels.
|
||||
*/
|
||||
offset: number;
|
||||
/**
|
||||
* How zoomed in the timeline is.
|
||||
*/
|
||||
scale: number;
|
||||
/**
|
||||
* First frame covered by the infinite scroll.
|
||||
*/
|
||||
startFrame: number;
|
||||
/**
|
||||
* Last frame covered by the infinite scroll.
|
||||
*/
|
||||
endFrame: number;
|
||||
/**
|
||||
* Frames per pixel rounded to the closest power of two.
|
||||
*/
|
||||
density: number;
|
||||
/**
|
||||
* Frames per timeline segment.
|
||||
*/
|
||||
segmentDensity: number;
|
||||
/**
|
||||
* Animation duration in frames.
|
||||
*/
|
||||
duration: number;
|
||||
}
|
||||
|
||||
@@ -17,6 +45,7 @@ const TimelineContext = createContext<TimelineState>({
|
||||
offset: 0,
|
||||
scale: 1,
|
||||
density: 1,
|
||||
segmentDensity: 1,
|
||||
duration: 0,
|
||||
endFrame: 0,
|
||||
startFrame: 0,
|
||||
|
||||
@@ -4,19 +4,19 @@ import {useContext, useMemo} from 'preact/hooks';
|
||||
import {TimelineContext} from './TimelineContext';
|
||||
|
||||
export function TimestampTrack() {
|
||||
const {fullLength, startFrame, endFrame, density, duration} =
|
||||
const {fullLength, startFrame, endFrame, segmentDensity, duration} =
|
||||
useContext(TimelineContext);
|
||||
|
||||
const timestamps = useMemo(() => {
|
||||
const timestamps = [];
|
||||
for (let i = startFrame; i < endFrame; i += density) {
|
||||
for (let i = startFrame; i < endFrame; i += segmentDensity) {
|
||||
timestamps.push({
|
||||
time: i,
|
||||
style: {left: `${(i / duration) * fullLength}px`},
|
||||
});
|
||||
}
|
||||
return timestamps;
|
||||
}, [startFrame, endFrame, duration, fullLength, density]);
|
||||
}, [startFrame, endFrame, duration, fullLength, segmentDensity]);
|
||||
|
||||
return (
|
||||
<div className={styles.timestampTrack}>
|
||||
|
||||
@@ -2,6 +2,7 @@ import styles from './Viewport.module.scss';
|
||||
import {
|
||||
useDocumentEvent,
|
||||
useDrag,
|
||||
useEventEffect,
|
||||
usePlayer,
|
||||
useSize,
|
||||
useStorage,
|
||||
@@ -61,8 +62,9 @@ export function View() {
|
||||
return () => konvaContainer.remove();
|
||||
}, [viewportRef.current]);
|
||||
|
||||
useEffect(() => {
|
||||
const animation = () =>
|
||||
useEventEffect(
|
||||
player.Reloaded,
|
||||
() =>
|
||||
overlayRef.current.animate(
|
||||
[
|
||||
{
|
||||
@@ -78,11 +80,9 @@ export function View() {
|
||||
{
|
||||
duration: 300,
|
||||
},
|
||||
);
|
||||
|
||||
player.Reloaded.subscribe(animation);
|
||||
return () => player.Reloaded.unsubscribe(animation);
|
||||
});
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
useDocumentEvent(
|
||||
'keydown',
|
||||
@@ -107,12 +107,12 @@ export function View() {
|
||||
case "'":
|
||||
setState({...state, grid: !state.grid});
|
||||
break;
|
||||
case "ArrowUp":
|
||||
case 'ArrowUp':
|
||||
if (node?.parent) {
|
||||
setNode(node.parent);
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
case 'ArrowDown':
|
||||
if (node?.children?.length) {
|
||||
setNode(node.children.at(-1));
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
export * from './useStorage';
|
||||
export * from './useDocumentEvent';
|
||||
export * from './useDrag';
|
||||
export * from './useEventEffect';
|
||||
export * from './useEventState';
|
||||
export * from './usePlayer';
|
||||
export * from './usePlayerState';
|
||||
export * from './usePlayerTime';
|
||||
export * from './useDocumentEvent';
|
||||
export * from './useSize';
|
||||
export * from './useDrag';
|
||||
export * from './useScenes';
|
||||
export * from './useSize';
|
||||
export * from './useStorage';
|
||||
|
||||
13
src/hooks/useEventEffect.ts
Normal file
13
src/hooks/useEventEffect.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import {ISubscribable} from 'strongly-typed-events';
|
||||
import {Inputs, useEffect} from 'preact/hooks';
|
||||
|
||||
export function useEventEffect<THandler extends Function>(
|
||||
subscribable: ISubscribable<THandler>,
|
||||
callback: THandler,
|
||||
inputs: Inputs,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
subscribable.subscribe(callback);
|
||||
return () => subscribable.unsubscribe(callback);
|
||||
}, inputs);
|
||||
}
|
||||
16
src/hooks/useEventState.ts
Normal file
16
src/hooks/useEventState.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import {ISubscribable} from 'strongly-typed-events';
|
||||
import {useEffect, useState} from 'preact/hooks';
|
||||
|
||||
export function useEventState<Type>(
|
||||
subscribable: ISubscribable<(args: Type) => void>,
|
||||
getState: () => Type,
|
||||
): Type {
|
||||
const [state, setState] = useState(getState());
|
||||
useEffect(() => {
|
||||
setState(getState());
|
||||
subscribable.subscribe(setState);
|
||||
return () => subscribable.unsubscribe(setState);
|
||||
}, [subscribable]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import { PlayerState } from "@motion-canvas/core/player/Player";
|
||||
import { usePlayer } from "./usePlayer";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import {PlayerState} from '@motion-canvas/core/player/Player';
|
||||
import {usePlayer} from './usePlayer';
|
||||
import {useEventState} from './useEventState';
|
||||
|
||||
const player = usePlayer();
|
||||
const storageKey = `${player.project.name()}-player-state`;
|
||||
const savedState = localStorage.getItem(storageKey);
|
||||
if (savedState) {
|
||||
const state = JSON.parse(savedState) as PlayerState;
|
||||
player.updateState(state);
|
||||
}
|
||||
|
||||
player.StateChanged.subscribe(state => {
|
||||
localStorage.setItem(storageKey, JSON.stringify(state));
|
||||
});
|
||||
|
||||
export function usePlayerState(): PlayerState {
|
||||
const player = usePlayer();
|
||||
const [state, setState] = useState<PlayerState>(player.getState());
|
||||
useEffect(() => {
|
||||
setState(player.getState());
|
||||
player.StateChanged.subscribe(setState);
|
||||
return () => player.StateChanged.unsubscribe(setState);
|
||||
}, [player]);
|
||||
|
||||
return state;
|
||||
return useEventState(player.StateChanged, () => player.getState());
|
||||
}
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import {PlayerTime} from '@motion-canvas/core/player/Player';
|
||||
import {usePlayer} from './usePlayer';
|
||||
import {useEffect, useState} from 'preact/hooks';
|
||||
import {useEventState} from './useEventState';
|
||||
|
||||
export function usePlayerTime(): PlayerTime {
|
||||
const player = usePlayer();
|
||||
const [state, setState] = useState<PlayerTime>(player.getTime());
|
||||
useEffect(() => {
|
||||
setState(player.getTime());
|
||||
player.TimeChanged.subscribe(setState);
|
||||
return () => player.TimeChanged.unsubscribe(setState);
|
||||
}, [player]);
|
||||
|
||||
return state;
|
||||
return useEventState(player.TimeChanged, () => player.getTime());
|
||||
}
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import {useCallback, useMemo, useState} from 'preact/hooks';
|
||||
import {usePlayer} from './usePlayer';
|
||||
|
||||
export function useStorage<T>(
|
||||
id: string,
|
||||
initialState: T = null,
|
||||
): [T, (newState: T) => void] {
|
||||
const name = usePlayer().project.name();
|
||||
const key = `${name}-${id}`;
|
||||
const savedState = useMemo(() => {
|
||||
const savedState = localStorage.getItem(id);
|
||||
const savedState = localStorage.getItem(key);
|
||||
return savedState ? JSON.parse(savedState) : initialState;
|
||||
}, [id]);
|
||||
}, [key]);
|
||||
const [state, setState] = useState<T>(savedState);
|
||||
|
||||
const updateState = useCallback(
|
||||
(newState: T) => {
|
||||
if (id) {
|
||||
localStorage.setItem(id, JSON.stringify(newState));
|
||||
if (key) {
|
||||
localStorage.setItem(key, JSON.stringify(newState));
|
||||
}
|
||||
setState(newState);
|
||||
},
|
||||
[setState, id],
|
||||
[setState, key],
|
||||
);
|
||||
|
||||
return [state, updateState];
|
||||
|
||||
Reference in New Issue
Block a user