clipper/frontend/src/Waveform.tsx

367 lines
8.9 KiB
TypeScript
Raw Normal View History

2021-09-11 10:05:58 +00:00
import { useEffect, useState, useRef, MouseEvent } from 'react';
import { Waveform as WaveformOverview } from './Waveform/Overview';
2021-09-14 20:26:46 +00:00
import { Thumbnails } from './Waveform/Thumbnails';
import { Canvas as WaveformCanvas } from './Waveform/Canvas';
2021-09-13 10:16:23 +00:00
import {
secsToCanvasX,
canvasXToFrame,
mouseEventToCanvasX,
} from './Waveform/Helpers';
2021-09-06 14:25:23 +00:00
2021-09-11 16:42:37 +00:00
type Props = {
2021-09-06 14:25:23 +00:00
audioContext: AudioContext;
};
2021-09-14 20:26:46 +00:00
// Audio corresponds to media.Audio.
export type Audio = {
bytes: number;
channels: number;
frames: number;
sampleRate: number;
};
2021-09-14 20:26:46 +00:00
// Video corresponds to media.Video.
export type Video = {
bytes: number;
thumbnailWidth: number;
thumbnailHeight: number;
durationMillis: number;
};
// MediaSet corresponds to media.MediaSet.
export type MediaSet = {
id: string;
source: string;
audio: Audio;
video: Video;
};
2021-09-13 10:16:23 +00:00
export type Selection = {
x1: number;
x2: number;
};
2021-09-11 10:58:43 +00:00
type ZoomSettings = {
startFrame: number;
endFrame: number;
};
const defaultZoomSettings: ZoomSettings = { startFrame: 0, endFrame: 0 };
export const CanvasLogicalWidth = 2000;
export const CanvasLogicalHeight = 500;
2021-09-11 16:42:37 +00:00
export const Waveform: React.FC<Props> = ({ audioContext }: Props) => {
2021-09-14 20:26:46 +00:00
const [mediaSet, setMediaSet] = useState<MediaSet | null>(null);
const [currentTime, setCurrentTime] = useState(0);
2021-09-14 20:26:46 +00:00
// TODO: extract to player component.
const [audio, setAudio] = useState(new Audio());
2021-09-11 10:58:43 +00:00
const [zoomSettings, setZoomSettings] = useState(defaultZoomSettings);
2021-09-11 16:42:37 +00:00
const [waveformPeaks, setWaveformPeaks] = useState(null);
const [overviewPeaks, setOverviewPeaks] = useState(null);
const hudCanvasRef = useRef<HTMLCanvasElement>(null);
2021-09-14 20:26:46 +00:00
const videoRef = useRef<HTMLVideoElement>(null);
2021-09-11 16:42:37 +00:00
// TODO: error handling
2021-09-11 10:05:58 +00:00
const videoID = new URLSearchParams(window.location.search).get('video_id');
// effects
2021-09-11 10:05:58 +00:00
// setup player on page load:
useEffect(() => {
2021-09-11 10:05:58 +00:00
(async function () {
2021-09-14 20:26:46 +00:00
const video = videoRef.current;
if (video == null) {
return;
}
video.addEventListener('timeupdate', () => {
setCurrentTime(video.currentTime);
2021-09-11 10:05:58 +00:00
});
})();
2021-09-14 20:26:46 +00:00
});
2021-09-06 14:25:23 +00:00
2021-09-14 20:26:46 +00:00
// fetch mediaset on page load:
2021-09-06 14:25:23 +00:00
useEffect(() => {
2021-09-11 10:05:58 +00:00
(async function () {
2021-09-14 20:26:46 +00:00
console.log('fetching media...');
2021-09-06 14:25:23 +00:00
2021-09-11 10:05:58 +00:00
const resp = await fetch(
2021-09-25 17:00:19 +00:00
`http://localhost:8888/api/media_sets/${videoID}`
2021-09-11 10:05:58 +00:00
);
const respBody = await resp.json();
2021-09-06 14:25:23 +00:00
if (respBody.error) {
2021-09-14 20:26:46 +00:00
console.log('error fetching media set:', respBody.error);
return;
}
2021-09-06 14:25:23 +00:00
2021-09-14 20:26:46 +00:00
const mediaSet: MediaSet = {
id: respBody.id,
source: respBody.source,
audio: {
sampleRate: respBody.audio.sample_rate,
bytes: respBody.audio.bytes,
frames: respBody.audio.frames,
channels: respBody.audio.channels,
},
video: {
bytes: respBody.video.bytes,
thumbnailWidth: respBody.video.thumbnail_width,
thumbnailHeight: respBody.video.thumbnail_height,
durationMillis: Math.floor(respBody.video.duration / 1000 / 1000),
},
};
2021-09-06 14:25:23 +00:00
2021-09-14 20:26:46 +00:00
setMediaSet(mediaSet);
setZoomSettings({ startFrame: 0, endFrame: mediaSet.audio.frames });
2021-09-06 14:25:23 +00:00
})();
}, [audioContext]);
2021-09-14 20:26:46 +00:00
// load video when MediaSet is loaded:
useEffect(() => {
if (mediaSet == null) {
return;
}
const video = videoRef.current;
if (video == null) {
return;
}
2021-09-25 17:00:19 +00:00
const url = `http://localhost:8888/api/media_sets/${videoID}/video`;
2021-09-14 20:26:46 +00:00
video.src = url;
video.muted = false;
video.volume = 1;
video.controls = true;
}, [mediaSet]);
2021-09-11 16:42:37 +00:00
// fetch new waveform peaks when zoom settings are updated:
2021-09-06 14:25:23 +00:00
useEffect(() => {
2021-09-11 10:05:58 +00:00
(async function () {
2021-09-14 20:26:46 +00:00
if (mediaSet == null) {
2021-09-11 10:05:58 +00:00
return;
}
2021-09-11 10:58:43 +00:00
let endFrame = zoomSettings.endFrame;
if (endFrame <= zoomSettings.startFrame) {
2021-09-14 20:26:46 +00:00
endFrame = mediaSet.audio.frames;
2021-09-11 10:58:43 +00:00
}
2021-09-11 10:05:58 +00:00
const resp = await fetch(
2021-09-25 17:00:19 +00:00
`http://localhost:8888/api/media_sets/${videoID}/peaks?start=${zoomSettings.startFrame}&end=${endFrame}&bins=${CanvasLogicalWidth}`
2021-09-11 10:05:58 +00:00
);
const peaks = await resp.json();
2021-09-11 16:42:37 +00:00
setWaveformPeaks(peaks);
if (overviewPeaks == null) {
setOverviewPeaks(peaks);
2021-09-11 10:05:58 +00:00
}
})();
2021-09-11 10:58:43 +00:00
}, [zoomSettings]);
// redraw HUD
useEffect(() => {
2021-09-11 10:05:58 +00:00
(async function () {
const canvas = hudCanvasRef.current;
if (canvas == null) {
return;
}
2021-09-11 10:05:58 +00:00
const ctx = canvas.getContext('2d');
if (ctx == null) {
2021-09-11 10:05:58 +00:00
console.error('no hud 2d context available');
return;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
2021-09-14 20:26:46 +00:00
if (mediaSet == null) {
2021-09-11 10:58:43 +00:00
return;
}
2021-09-13 10:16:23 +00:00
const x = secsToCanvasX(
currentTime,
2021-09-14 20:26:46 +00:00
mediaSet.audio.sampleRate,
mediaSet.audio.frames
2021-09-13 10:16:23 +00:00
);
2021-09-06 14:25:23 +00:00
2021-09-11 10:05:58 +00:00
ctx.strokeStyle = 'red';
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
ctx.stroke();
2021-09-11 10:05:58 +00:00
})();
}, [currentTime]);
2021-09-14 20:26:46 +00:00
// end of hook configuration.
// TODO: render loading page here.
if (mediaSet == null) {
return null;
}
// callbacks
2021-09-11 10:05:58 +00:00
const handleMouseMove = (evt: MouseEvent<HTMLCanvasElement>) => {
2021-09-14 20:26:46 +00:00
if (mediaSet == null) {
2021-09-13 10:16:23 +00:00
return;
}
const canvasX = mouseEventToCanvasX(evt);
2021-09-13 10:16:23 +00:00
console.log(
'mousemove, x =',
canvasX,
'frame =',
2021-09-14 20:26:46 +00:00
canvasXToFrame(canvasX, mediaSet.audio.frames)
2021-09-13 10:16:23 +00:00
);
2021-09-11 10:05:58 +00:00
};
2021-09-11 10:58:43 +00:00
const handleMouseDown = () => {
return null;
};
2021-09-06 14:25:23 +00:00
const handleMouseUp = () => {
return null;
};
const handlePlay = async () => {
2021-09-14 20:26:46 +00:00
const video = videoRef.current;
if (video == null) {
return;
}
await video.play();
2021-09-11 10:05:58 +00:00
};
2021-09-06 14:25:23 +00:00
const handlePause = () => {
2021-09-14 20:26:46 +00:00
const video = videoRef.current;
if (video == null) {
return;
}
video.pause();
console.log('paused video');
2021-09-11 10:05:58 +00:00
};
const handleZoomIn = () => {
2021-09-14 20:26:46 +00:00
if (mediaSet == null) {
2021-09-11 10:58:43 +00:00
return;
}
2021-09-11 10:05:58 +00:00
console.log('zoom in');
2021-09-11 10:58:43 +00:00
const diff = zoomSettings.endFrame - zoomSettings.startFrame;
const endFrame = zoomSettings.startFrame + Math.floor(diff / 2);
const settings = { ...zoomSettings, endFrame: endFrame };
setZoomSettings(settings);
};
const handleZoomOut = () => {
2021-09-14 20:26:46 +00:00
if (mediaSet == null) {
2021-09-11 10:58:43 +00:00
return;
}
2021-09-11 10:05:58 +00:00
console.log('zoom out');
2021-09-11 10:58:43 +00:00
const diff = zoomSettings.endFrame - zoomSettings.startFrame;
const newDiff = diff * 2;
const endFrame = Math.min(
zoomSettings.endFrame + newDiff,
2021-09-14 20:26:46 +00:00
mediaSet.audio.frames
2021-09-11 10:58:43 +00:00
);
const settings = { ...zoomSettings, endFrame: endFrame };
setZoomSettings(settings);
};
2021-09-13 10:16:23 +00:00
const handleSelectionChange = (selection: Selection) => {
2021-09-14 20:26:46 +00:00
if (mediaSet == null) {
2021-09-13 10:16:23 +00:00
return;
}
const settings: ZoomSettings = {
2021-09-14 20:26:46 +00:00
startFrame: canvasXToFrame(selection.x1, mediaSet.audio.frames),
endFrame: canvasXToFrame(selection.x2, mediaSet.audio.frames),
2021-09-13 10:16:23 +00:00
};
setZoomSettings(settings);
};
// render component:
2021-09-06 14:25:23 +00:00
2021-09-11 10:05:58 +00:00
const wrapperProps = {
width: '90%',
2021-09-14 20:26:46 +00:00
height: '250px',
2021-09-11 10:05:58 +00:00
position: 'relative',
margin: '0 auto',
} as React.CSSProperties;
2021-09-11 10:58:43 +00:00
2021-09-11 10:05:58 +00:00
const waveformCanvasProps = {
width: '100%',
2021-09-11 16:42:37 +00:00
height: '100%',
2021-09-11 10:05:58 +00:00
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 0,
} as React.CSSProperties;
2021-09-11 10:58:43 +00:00
2021-09-11 10:05:58 +00:00
const hudCanvasProps = {
width: '100%',
2021-09-11 16:42:37 +00:00
height: '100%',
2021-09-11 10:05:58 +00:00
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 1,
} as React.CSSProperties;
2021-09-11 10:58:43 +00:00
2021-09-14 20:26:46 +00:00
const overviewStyles = { ...wrapperProps, height: '60px' };
2021-09-11 16:42:37 +00:00
// TODO: why is the margin needed?
const controlPanelStyles = { margin: '1em' } as React.CSSProperties;
2021-09-11 10:05:58 +00:00
const clockTextAreaProps = { color: '#999', width: '400px' };
2021-09-14 20:26:46 +00:00
const videoStyles = {
width: '30%',
height: 'auto',
margin: '10px auto 0 auto',
zIndex: 2,
} as React.CSSProperties;
const thumbnailStyles = {
width: '90%',
height: '35px',
margin: '10px auto 0 auto',
display: 'block',
};
2021-09-13 10:16:23 +00:00
2021-09-11 10:05:58 +00:00
return (
<>
2021-09-14 20:26:46 +00:00
<video ref={videoRef} style={videoStyles}></video>
<Thumbnails mediaSet={mediaSet} style={thumbnailStyles} />
<WaveformOverview
peaks={overviewPeaks}
numFrames={mediaSet.audio.frames}
style={overviewStyles}
onSelectionChange={handleSelectionChange}
></WaveformOverview>
2021-09-11 10:05:58 +00:00
<div style={wrapperProps}>
2021-09-11 16:42:37 +00:00
<WaveformCanvas
peaks={waveformPeaks}
fillStyle="black"
strokeStyle="green"
2021-09-11 10:05:58 +00:00
style={waveformCanvasProps}
2021-09-11 16:42:37 +00:00
></WaveformCanvas>
2021-09-11 10:05:58 +00:00
<canvas
ref={hudCanvasRef}
onMouseMove={handleMouseMove}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
style={hudCanvasProps}
width={CanvasLogicalWidth}
height={CanvasLogicalHeight}
2021-09-11 10:05:58 +00:00
></canvas>
</div>
<div style={controlPanelStyles}>
<button onClick={handlePlay}>Play</button>
<button onClick={handlePause}>Pause</button>
<button onClick={handleZoomIn}>+</button>
<button onClick={handleZoomOut}>-</button>
<input type="readonly" style={clockTextAreaProps} />
</div>
2021-09-11 10:05:58 +00:00
</>
);
};