diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5a10ea5..e077cc4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,7 +21,7 @@ import { first, map } from 'rxjs/operators'; const thumbnailWidth = 177; const thumbnailHeight = 100; -const initialViewportSeconds = 10; +const initialViewportCanvasPixels = 100; const apiURL = process.env.REACT_APP_API_URL || 'http://localhost:8888'; @@ -147,7 +147,8 @@ function App(): JSX.Element { } const numFrames = Math.min( - mediaSet.audioSampleRate * initialViewportSeconds, + Math.round(mediaSet.audioFrames / CanvasLogicalWidth) * + initialViewportCanvasPixels, mediaSet.audioFrames ); diff --git a/frontend/src/HudCanvas.tsx b/frontend/src/HudCanvas.tsx new file mode 100644 index 0000000..d9a1229 --- /dev/null +++ b/frontend/src/HudCanvas.tsx @@ -0,0 +1,289 @@ +import { useState, useEffect, useRef, MouseEvent } from 'react'; +import { VideoPosition } from './App'; + +interface Props { + width: number; + height: number; + zIndex: number; + position: VideoPosition; + selection: Selection; + onSelectionChange: (selection: Selection) => void; +} + +enum Mode { + Normal, + Selecting, + Dragging, + ResizingStart, + ResizingEnd, +} + +enum HoverState { + Normal, + OverSelectionStart, + OverSelectionEnd, + OverSelection, +} + +export interface Selection { + start: number; + end: number; +} + +const emptySelection: Selection = { start: 0, end: 0 }; + +export const HudCanvas: React.FC = ({ + width, + height, + zIndex, + position, + selection, + onSelectionChange, +}: Props) => { + // selection and newSelection are in canvas logical pixels: + const [newSelection, setNewSelection] = useState({ + ...emptySelection, + }); + const [mode, setMode] = useState(Mode.Normal); + const [hoverState, setHoverState] = useState(HoverState.Normal); + const [cursor, setCursor] = useState('auto'); + + const canvasRef = useRef(null); + const moveOffsetX = useRef(0); + + // side effects + + // handle global mouse up + useEffect(() => { + window.addEventListener('mouseup', handleMouseUp); + return () => { + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [mode, newSelection]); + + // draw the overview HUD + useEffect(() => { + requestAnimationFrame(() => { + const canvas = canvasRef.current; + if (canvas == null) { + console.error('no hud canvas ref available'); + return; + } + const ctx = canvas.getContext('2d'); + if (ctx == null) { + console.error('no hud 2d context available'); + return; + } + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // draw selection + + let currentSelection: Selection; + if ( + mode == Mode.Selecting || + mode == Mode.Dragging || + mode == Mode.ResizingStart || + mode == Mode.ResizingEnd + ) { + currentSelection = newSelection; + } else { + currentSelection = selection; + } + + ctx.beginPath(); + ctx.strokeStyle = 'red'; + ctx.lineWidth = 4; + const alpha = hoverState == HoverState.OverSelection ? '0.15' : '0.13'; + ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`; + ctx.rect( + currentSelection.start, + 2, + currentSelection.end - currentSelection.start, + canvas.height - 10 + ); + ctx.fill(); + ctx.stroke(); + + // draw position marker + + const markerX = canvas.width * (position.percent / 100); + ctx.beginPath(); + ctx.moveTo(markerX, 0); + ctx.lineWidth = 4; + ctx.lineTo(markerX, canvas.height - 4); + ctx.stroke(); + }); + }, [selection, newSelection, position]); + + // handlers + + const hoverOffset = 10; + + const isHoveringSelectionStart = (x: number): boolean => { + return ( + x > selection.start - hoverOffset && x < selection.start + hoverOffset + ); + }; + + const isHoveringSelectionEnd = (x: number): boolean => { + return x > selection.end - hoverOffset && x < selection.end + hoverOffset; + }; + + const isHoveringSelection = (x: number): boolean => { + return x >= selection.start && x <= selection.end; + }; + + const getCanvasX = (evt: MouseEvent): number => { + const rect = evt.currentTarget.getBoundingClientRect(); + const x = Math.round(((evt.clientX - rect.left) / rect.width) * width); + return constrainXToCanvas(x); + }; + + const constrainXToCanvas = (x: number): number => { + if (x < 0) { + return 0; + } + if (x > width) { + return width; + } + return x; + }; + + const handleMouseDown = (evt: MouseEvent) => { + if (mode != Mode.Normal) { + return; + } + + const x = getCanvasX(evt); + + if (isHoveringSelectionStart(x)) { + setMode(Mode.ResizingStart); + moveOffsetX.current = x; + } else if (isHoveringSelectionEnd(x)) { + setMode(Mode.ResizingEnd); + moveOffsetX.current = x; + } else if (isHoveringSelection(x)) { + setMode(Mode.Dragging); + setCursor('pointer'); + moveOffsetX.current = x; + } else { + setMode(Mode.Selecting); + setCursor('col-resize'); + moveOffsetX.current = x; + setNewSelection({ start: x, end: x }); + } + }; + + const handleMouseMove = (evt: MouseEvent) => { + const x = getCanvasX(evt); + + switch (mode) { + case Mode.Normal: { + if (isHoveringSelectionStart(x)) { + setHoverState(HoverState.OverSelectionStart); + setCursor('col-resize'); + } else if (isHoveringSelectionEnd(x)) { + setHoverState(HoverState.OverSelectionEnd); + setCursor('col-resize'); + } else if (isHoveringSelection(x)) { + setHoverState(HoverState.OverSelection); + setCursor('pointer'); + } else { + setCursor('auto'); + } + break; + } + case Mode.ResizingStart: { + const diff = x - moveOffsetX.current; + const start = constrainXToCanvas(selection.start + diff); + + if (start > selection.end) { + setNewSelection({ start: selection.end, end: start }); + break; + } + + setNewSelection({ ...newSelection, start: start }); + break; + } + case Mode.ResizingEnd: { + const diff = x - moveOffsetX.current; + const start = constrainXToCanvas(selection.end + diff); + + if (start < selection.start) { + setNewSelection({ start: Math.max(0, start), end: selection.start }); + break; + } + + setNewSelection({ ...newSelection, end: start }); + break; + } + case Mode.Dragging: { + const diff = x - moveOffsetX.current; + const selectionWidth = selection.end - selection.start; + let start = Math.max(0, selection.start + diff); + let end = start + selectionWidth; + if (end > width) { + end = width; + start = end - selectionWidth; + } + + setNewSelection({ start: start, end: end }); + break; + } + case Mode.Selecting: { + if (x < moveOffsetX.current) { + setNewSelection({ + start: x, + end: moveOffsetX.current, + }); + } else { + setNewSelection({ start: moveOffsetX.current, end: x }); + } + break; + } + } + }; + + const handleMouseUp = () => { + if (mode == Mode.Normal) { + return; + } + + setMode(Mode.Normal); + setCursor('auto'); + + if (newSelection.start == newSelection.end) { + setNewSelection({ start: 0, end: 0 }); + return; + } + + onSelectionChange({ ...newSelection }); + }; + + const handleMouseLeave = (_evt: MouseEvent) => { + setHoverState(HoverState.Normal); + }; + + const canvasStyles = { + display: 'block', + position: 'absolute', + width: '100%', + height: '100%', + zIndex: zIndex, + cursor: cursor, + } as React.CSSProperties; + + return ( + <> + + + ); +}; diff --git a/frontend/src/Overview.tsx b/frontend/src/Overview.tsx index 61dd332..1239fe8 100644 --- a/frontend/src/Overview.tsx +++ b/frontend/src/Overview.tsx @@ -1,7 +1,8 @@ -import { useState, useEffect, useRef, MouseEvent } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { MediaSet } from './generated/media_set'; import { Frames, VideoPosition } from './App'; import { WaveformCanvas } from './WaveformCanvas'; +import { HudCanvas } from './HudCanvas'; import { Observable } from 'rxjs'; export interface Selection { @@ -16,29 +17,12 @@ interface Props { offsetPixels: number; position: VideoPosition; viewport: Frames; - onSelectionChange: (selection: Frames) => void; -} - -enum Mode { - Normal, - Selecting, - Dragging, - ResizingStart, - ResizingEnd, -} - -enum HoverState { - Normal, - OverSelectionStart, - OverSelectionEnd, - OverSelection, + onSelectionChange: (selection: Selection) => void; } export const CanvasLogicalWidth = 2_000; export const CanvasLogicalHeight = 500; -const emptySelection = { start: 0, end: 0 }; - export const Overview: React.FC = ({ peaks, mediaSet, @@ -48,36 +32,13 @@ export const Overview: React.FC = ({ viewport, onSelectionChange, }: Props) => { - const hudCanvasRef = useRef(null); - const [mode, setMode] = useState(Mode.Normal); - const [hoverState, setHoverState] = useState(HoverState.Normal); - const [cursor, setCursor] = useState('auto'); - - // selection and newSelection relate to canvas logical pixels: - const [selection, setSelection] = useState({ ...emptySelection }); - const [newSelection, setNewSelection] = useState({ - ...emptySelection, - }); - - const moveOffsetX = useRef(0); + const [selectedPixels, setSelectedPixels] = useState({ start: 0, end: 0 }); // side effects - // handle global mouse up. + // convert viewport from frames to canvas pixels. useEffect(() => { - window.addEventListener('mouseup', handleMouseUp); - return () => { - window.removeEventListener('mouseup', handleMouseUp); - }; - }, [mode, newSelection]); - - // set selection state on viewport change - useEffect(() => { - if (mediaSet == null) { - return; - } - - setSelection({ + setSelectedPixels({ start: Math.round( (viewport.start / mediaSet.audioFrames) * CanvasLogicalWidth ), @@ -85,235 +46,20 @@ export const Overview: React.FC = ({ (viewport.end / mediaSet.audioFrames) * CanvasLogicalWidth ), }); - }, [mediaSet, viewport]); - - // load peaks on mediaset change - useEffect(() => { - (async function () { - if (mediaSet == null) { - return; - } - const canvas = hudCanvasRef.current; - if (canvas == null) { - console.error('no hud canvas ref available'); - return; - } - const ctx = canvas.getContext('2d'); - if (ctx == null) { - console.error('no hud 2d context available'); - return; - } - })(); - }, [mediaSet]); - - // draw the overview HUD - useEffect(() => { - requestAnimationFrame(() => { - const canvas = hudCanvasRef.current; - if (canvas == null) { - console.error('no hud canvas ref available'); - return; - } - const ctx = canvas.getContext('2d'); - if (ctx == null) { - console.error('no hud 2d context available'); - return; - } - ctx.clearRect(0, 0, canvas.width, canvas.height); - - // draw selection - - let currentSelection: Selection; - if ( - mode == Mode.Selecting || - mode == Mode.Dragging || - mode == Mode.ResizingStart || - mode == Mode.ResizingEnd - ) { - currentSelection = newSelection; - } else { - currentSelection = selection; - } - - ctx.beginPath(); - ctx.strokeStyle = 'red'; - ctx.lineWidth = 4; - const alpha = hoverState == HoverState.OverSelection ? '0.15' : '0.13'; - ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`; - ctx.rect( - currentSelection.start, - 2, - currentSelection.end - currentSelection.start, - canvas.height - 10 - ); - ctx.fill(); - ctx.stroke(); - - // draw position marker - - const markerX = canvas.width * (position.percent / 100); - ctx.beginPath(); - ctx.moveTo(markerX, 0); - ctx.lineWidth = 4; - ctx.lineTo(markerX, canvas.height - 4); - ctx.stroke(); - }); - }, [mediaSet, selection, newSelection, position]); + }, [viewport, mediaSet]); // handlers - const hoverOffset = 10; - - const isHoveringSelectionStart = (x: number): boolean => { - return ( - x > selection.start - hoverOffset && x < selection.start + hoverOffset - ); - }; - - const isHoveringSelectionEnd = (x: number): boolean => { - return x > selection.end - hoverOffset && x < selection.end + hoverOffset; - }; - - const isHoveringSelection = (x: number): boolean => { - return x >= selection.start && x <= selection.end; - }; - - const getCanvasX = (evt: MouseEvent): number => { - const rect = evt.currentTarget.getBoundingClientRect(); - const x = Math.round( - ((evt.clientX - rect.left) / rect.width) * CanvasLogicalWidth - ); - return constrainXToCanvas(x); - }; - - const constrainXToCanvas = (x: number): number => { - if (x < 0) { - return 0; - } - if (x > CanvasLogicalWidth) { - return CanvasLogicalWidth; - } - return x; - }; - - const handleMouseDown = (evt: MouseEvent) => { - if (mode != Mode.Normal) { - return; - } - - const x = getCanvasX(evt); - - if (isHoveringSelectionStart(x)) { - setMode(Mode.ResizingStart); - moveOffsetX.current = x; - return; - } else if (isHoveringSelectionEnd(x)) { - setMode(Mode.ResizingEnd); - moveOffsetX.current = x; - return; - } else if (isHoveringSelection(x)) { - setMode(Mode.Dragging); - setCursor('pointer'); - moveOffsetX.current = x; - return; - } - - setMode(Mode.Selecting); - setCursor('col-resize'); - moveOffsetX.current = x; - setNewSelection({ start: x, end: x }); - }; - - const handleMouseMove = (evt: MouseEvent) => { - const x = getCanvasX(evt); - - switch (mode) { - case Mode.Normal: { - if (isHoveringSelectionStart(x)) { - setHoverState(HoverState.OverSelectionStart); - setCursor('col-resize'); - } else if (isHoveringSelectionEnd(x)) { - setHoverState(HoverState.OverSelectionEnd); - setCursor('col-resize'); - } else if (isHoveringSelection(x)) { - setHoverState(HoverState.OverSelection); - setCursor('pointer'); - } else { - setCursor('auto'); - } - break; - } - case Mode.ResizingStart: { - const diff = x - moveOffsetX.current; - const start = constrainXToCanvas(selection.start + diff); - - if (start > selection.end) { - setNewSelection({ start: selection.end, end: start }); - break; - } - - setNewSelection({ ...newSelection, start: start }); - break; - } - case Mode.ResizingEnd: { - const diff = x - moveOffsetX.current; - const start = constrainXToCanvas(selection.end + diff); - - if (start < selection.start) { - setNewSelection({ start: Math.max(0, start), end: selection.start }); - break; - } - - setNewSelection({ ...newSelection, end: start }); - break; - } - case Mode.Dragging: { - const diff = x - moveOffsetX.current; - const width = selection.end - selection.start; - let start = Math.max(0, selection.start + diff); - let end = start + width; - if (end > CanvasLogicalWidth) { - end = CanvasLogicalWidth; - start = end - width; - } - - setNewSelection({ start: start, end: end }); - break; - } - case Mode.Selecting: { - if (x < moveOffsetX.current) { - setNewSelection({ - start: x, - end: moveOffsetX.current, - }); - } else { - setNewSelection({ start: moveOffsetX.current, end: x }); - } - break; - } - } - }; - - const handleMouseUp = () => { - if (mode == Mode.Normal) { - return; - } - - setMode(Mode.Normal); - setCursor('auto'); - - const start = Math.round( - (newSelection.start / CanvasLogicalWidth) * mediaSet.audioFrames - ); - const end = Math.round( - (newSelection.end / CanvasLogicalWidth) * mediaSet.audioFrames - ); - onSelectionChange({ start, end }); - }; - - const handleMouseLeave = (_evt: MouseEvent) => { - setHoverState(HoverState.Normal); - }; + // convert selection change from canvas pixels to frames, and trigger callback. + const handleSelectionChange = useCallback( + ({ start, end }: Selection) => { + onSelectionChange({ + start: Math.round((start / CanvasLogicalWidth) * mediaSet.audioFrames), + end: Math.round((end / CanvasLogicalWidth) * mediaSet.audioFrames), + }); + }, + [mediaSet] + ); // render component @@ -324,15 +70,6 @@ export const Overview: React.FC = ({ height: `${height}px`, } as React.CSSProperties; - const hudCanvasStyles = { - position: 'absolute', - width: '100%', - height: '100%', - display: 'block', - zIndex: 2, - cursor: cursor, - } as React.CSSProperties; - return ( <>
@@ -346,15 +83,14 @@ export const Overview: React.FC = ({ zIndex={1} alpha={1} > - + zIndex={1} + position={position} + selection={selectedPixels} + onSelectionChange={handleSelectionChange} + />
);