mirror of
https://github.com/motion-canvas/motion-canvas.git
synced 2026-01-11 23:07:57 -05:00
feat: audio waveform track
This commit is contained in:
71
src/components/timeline/AudioTrack.tsx
Normal file
71
src/components/timeline/AudioTrack.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import styles from './Timeline.module.scss';
|
||||
|
||||
import {useContext, useLayoutEffect, useMemo, useRef} from 'preact/hooks';
|
||||
import {TimelineContext} from './TimelineContext';
|
||||
import {usePlayer} from '../../hooks';
|
||||
|
||||
const HEIGHT = 48;
|
||||
|
||||
export function AudioTrack() {
|
||||
const ref = useRef<HTMLCanvasElement>();
|
||||
const player = usePlayer();
|
||||
const context = useMemo(() => ref.current?.getContext('2d'), [ref.current]);
|
||||
const {viewLength, startFrame, endFrame, duration, density} =
|
||||
useContext(TimelineContext);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!context) 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) * samplesPerSeconds,
|
||||
);
|
||||
const end = Math.floor(
|
||||
player.project.framesToSeconds(endFrame) * samplesPerSeconds,
|
||||
);
|
||||
|
||||
context.clearRect(0, 0, viewLength, 64);
|
||||
context.strokeStyle = 'white';
|
||||
context.lineWidth = 1;
|
||||
context.beginPath();
|
||||
context.moveTo(0, HEIGHT);
|
||||
|
||||
const length = end - start;
|
||||
for (let offset = 0; offset < length; offset += step) {
|
||||
const sample = (start + offset) * 2;
|
||||
if (sample >= audio.data.length) break;
|
||||
|
||||
context.lineTo(
|
||||
(offset / length) * viewLength,
|
||||
(audio.data[sample] / 32767) * HEIGHT + HEIGHT,
|
||||
);
|
||||
context.lineTo(
|
||||
((offset + 0.5) / length) * viewLength,
|
||||
(audio.data[sample + 1] / 32767) * HEIGHT + HEIGHT,
|
||||
);
|
||||
}
|
||||
|
||||
context.stroke();
|
||||
}, [context, density, viewLength, startFrame, endFrame]);
|
||||
|
||||
const style = useMemo(
|
||||
() => ({
|
||||
marginLeft: `${(startFrame / duration) * 100}%`,
|
||||
width: `${((endFrame - startFrame) / duration) * 100}%`,
|
||||
height: `${HEIGHT * 2}px`,
|
||||
}),
|
||||
[startFrame, endFrame, duration],
|
||||
);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
style={style}
|
||||
width={viewLength}
|
||||
height={64}
|
||||
ref={ref}
|
||||
className={styles.audioTrack}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,22 @@
|
||||
background-color: #242424;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
@@ -155,7 +169,7 @@
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
.root:hover & {
|
||||
.timeline:hover & {
|
||||
opacity: 0.24;
|
||||
}
|
||||
}
|
||||
@@ -190,3 +204,7 @@
|
||||
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.audioTrack {
|
||||
opacity: 0.16;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {SceneTrack} from './SceneTrack';
|
||||
import {RangeTrack} from './RangeTrack';
|
||||
import {TimelineContext, TimelineState} from './TimelineContext';
|
||||
import {clamp} from '@motion-canvas/core/tweening';
|
||||
import {AudioTrack} from './AudioTrack';
|
||||
|
||||
const ZOOM_SPEED = 0.1;
|
||||
|
||||
@@ -110,44 +111,54 @@ export function Timeline() {
|
||||
|
||||
return (
|
||||
<TimelineContext.Provider value={state}>
|
||||
<div
|
||||
className={styles.root}
|
||||
ref={containerRef}
|
||||
onScroll={event => setOffset((event.target as HTMLElement).scrollLeft)}
|
||||
onWheel={event => {
|
||||
const ratio = 1 - Math.sign(event.deltaY) * ZOOM_SPEED;
|
||||
const newScale = scale * ratio < 1 ? 1 : scale * ratio;
|
||||
|
||||
const pointer = offset + event.x - rect.x;
|
||||
const newOffset = offset - pointer + pointer * ratio;
|
||||
const newTrackSize = rect.width * newScale;
|
||||
const maxOffset = newTrackSize - rect.width;
|
||||
|
||||
containerRef.current.scrollLeft = newOffset;
|
||||
setScale(newScale);
|
||||
setOffset(clamp(0, maxOffset, newOffset));
|
||||
}}
|
||||
onMouseUp={event => {
|
||||
if (event.button === 0) {
|
||||
player.requestSeek(
|
||||
Math.floor(
|
||||
((offset + event.x - rect.x) / state.fullLength) * duration,
|
||||
),
|
||||
);
|
||||
<div className={styles.root}>
|
||||
<div
|
||||
className={styles.timeline}
|
||||
ref={containerRef}
|
||||
onScroll={event =>
|
||||
setOffset((event.target as HTMLElement).scrollLeft)
|
||||
}
|
||||
}}
|
||||
onMouseMove={event => {
|
||||
playheadRef.current.style.left = `${event.x - rect.x + offset}px`;
|
||||
}}
|
||||
>
|
||||
<div className={styles.track} style={{width: `${state.fullLength}px`}}>
|
||||
<RangeTrack />
|
||||
<TimestampTrack />
|
||||
<SceneTrack />
|
||||
<LabelTrack />
|
||||
onWheel={event => {
|
||||
if (event.shiftKey) return;
|
||||
|
||||
const ratio = 1 - Math.sign(event.deltaY) * ZOOM_SPEED;
|
||||
const newScale = scale * ratio < 1 ? 1 : scale * ratio;
|
||||
|
||||
const pointer = offset + event.x - rect.x;
|
||||
const newOffset = offset - pointer + pointer * ratio;
|
||||
const newTrackSize = rect.width * newScale;
|
||||
const maxOffset = newTrackSize - rect.width;
|
||||
|
||||
containerRef.current.scrollLeft = newOffset;
|
||||
setScale(newScale);
|
||||
setOffset(clamp(0, maxOffset, newOffset));
|
||||
}}
|
||||
onMouseUp={event => {
|
||||
if (event.button === 0) {
|
||||
player.requestSeek(
|
||||
Math.floor(
|
||||
((offset + event.x - rect.x) / state.fullLength) * duration,
|
||||
),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onMouseMove={event => {
|
||||
playheadRef.current.style.left = `${event.x - rect.x + offset}px`;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.track}
|
||||
style={{width: `${state.fullLength}px`}}
|
||||
>
|
||||
<RangeTrack />
|
||||
<TimestampTrack />
|
||||
<SceneTrack />
|
||||
<LabelTrack />
|
||||
<AudioTrack />
|
||||
</div>
|
||||
<div ref={playheadRef} className={styles.playheadPreview} />
|
||||
<Playhead />
|
||||
</div>
|
||||
<div ref={playheadRef} className={styles.playheadPreview} />
|
||||
<Playhead />
|
||||
</div>
|
||||
</TimelineContext.Provider>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,10 @@ export function useSize<T extends Element>(
|
||||
): DOMRectReadOnly {
|
||||
const [rect, setRect] = useState<DOMRect>(new DOMRect());
|
||||
const observer = useMemo(
|
||||
() => new ResizeObserver(entries => setRect(entries[0].contentRect)),
|
||||
() =>
|
||||
new ResizeObserver(() =>
|
||||
setRect(ref.current.getBoundingClientRect()),
|
||||
),
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user