Skip to content

Commit

Permalink
Improvements:
Browse files Browse the repository at this point in the history
- Allow toggle sidebar
- Allow zoom with CTRL+mousewheel #254
- Improve performance
- modularize code
- remove standalone fontawesome
  • Loading branch information
mifi committed Feb 26, 2020
1 parent bf04116 commit b2705ba
Show file tree
Hide file tree
Showing 52 changed files with 841 additions and 7,101 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-react": "^7.14.3",
"eslint-plugin-react-hooks": "^1.7.0",
"svg2png": "^4.1.1"
"svg2png": "^4.1.1",
"use-trace-update": "^1.3.0"
},
"dependencies": {
"axios": "^0.19.2",
Expand Down
8 changes: 6 additions & 2 deletions src/HelpSheet.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ const HelpSheet = memo(({
<div><kbd>SPACE</kbd>, <kbd>k</kbd> Play/pause</div>
<div><kbd>J</kbd> Slow down video</div>
<div><kbd>L</kbd> Speed up video</div>
<div><kbd></kbd> Seek backward 5% of timeline</div>
<div><kbd></kbd> Seek forward 5% of timeline</div>
<div><kbd></kbd> Seek backward 5% of timeline (at current zoom)</div>
<div><kbd></kbd> Seek forward 5% of timeline (at current zoom)</div>
<div><kbd>,</kbd> Seek backward 1 frame</div>
<div><kbd>.</kbd> Seek forward 1 frame</div>
<div><kbd>I</kbd> Mark in / cut start point</div>
Expand All @@ -39,6 +39,10 @@ const HelpSheet = memo(({
<div><kbd>BACKSPACE</kbd> Remove current cut segment</div>
<div><kbd>D</kbd> Delete source file</div>

<h1>Mouse actions</h1>
<div><i>Mouse scroll up/down/left/right</i> - Seek / pan timeline</div>
<div><kbd>CTRL</kbd><i>+Mouse scroll up/down</i> - Zoom in/out timeline</div>

<p style={{ fontWeight: 'bold' }}>Hover mouse over buttons in the main interface to see which function they have.</p>

<Table style={{ marginTop: 40 }}>
Expand Down
10 changes: 5 additions & 5 deletions src/InverseCutSegment.jsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import React from 'react';
import React, { memo } from 'react';
import { motion } from 'framer-motion';
import { FaTrashAlt, FaSave } from 'react-icons/fa';

import { mySpring } from './animations';
import { saveColor } from './colors';

const InverseCutSegment = ({ seg, duration, invertCutSegments }) => (
const InverseCutSegment = memo(({ start, end, duration, invertCutSegments }) => (
<motion.div
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: `${(seg.start / duration) * 100}%`,
width: `${((seg.end - seg.start) / duration) * 100}%`,
left: `${(start / duration) * 100}%`,
width: `${((end - start) / duration) * 100}%`,
display: 'flex',
alignItems: 'center',
pointerEvents: 'none',
Expand All @@ -27,6 +27,6 @@ const InverseCutSegment = ({ seg, duration, invertCutSegments }) => (
)}
<div style={{ flexGrow: 1, borderBottom: '1px dashed rgba(255, 255, 255, 0.3)', marginLeft: 5, marginRight: 5 }} />
</motion.div>
);
));

export default InverseCutSegment;
37 changes: 37 additions & 0 deletions src/LeftMenu.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React, { memo } from 'react';
import { Select } from 'evergreen-ui';
import { motion } from 'framer-motion';
import { FaYinYang } from 'react-icons/fa';

const { withBlur } = require('./util');


const LeftMenu = memo(({ zoom, setZoom, invertCutSegments, setInvertCutSegments }) => (
<div className="no-user-select" style={{ position: 'absolute', left: 0, bottom: 0, padding: '.3em', display: 'flex', alignItems: 'center' }}>
<div style={{ marginLeft: 5 }}>
<motion.div
animate={{ rotateX: invertCutSegments ? 0 : 180, width: 26, height: 26 }}
transition={{ duration: 0.3 }}
>
<FaYinYang
size={26}
role="button"
title={invertCutSegments ? 'Discard selected segments' : 'Keep selected segments'}
onClick={withBlur(() => setInvertCutSegments(v => !v))}
/>
</motion.div>
</div>

<div style={{ marginRight: 5, marginLeft: 10 }} title="Zoom">{Math.floor(zoom)}x</div>
<Select height={20} style={{ width: 20 }} value={zoom.toString()} title="Zoom" onChange={withBlur(e => setZoom(parseInt(e.target.value, 10)))}>
{Array(13).fill().map((unused, z) => {
const val = 2 ** z;
return (
<option key={val} value={String(val)}>Zoom {val}x</option>
);
})}
</Select>
</div>
));

export default LeftMenu;
63 changes: 63 additions & 0 deletions src/RightMenu.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React, { memo } from 'react';
import { IoIosCamera } from 'react-icons/io';
import { FaTrashAlt, FaFileExport } from 'react-icons/fa';
import { MdRotate90DegreesCcw } from 'react-icons/md';
import { FiScissors } from 'react-icons/fi';

import { primaryColor } from './colors';


const RightMenu = memo(({
isRotationSet, rotation, areWeCutting, increaseRotation, deleteSource, renderCaptureFormatButton,
capture, cutClick, multipleCutSegments,
}) => {
const rotationStr = `${rotation}°`;
const CutIcon = areWeCutting ? FiScissors : FaFileExport;

return (
<div className="no-user-select" style={{ position: 'absolute', right: 0, bottom: 0, padding: '.3em', display: 'flex', alignItems: 'center' }}>
<div>
<span style={{ width: 40, textAlign: 'right', display: 'inline-block' }}>{isRotationSet && rotationStr}</span>
<MdRotate90DegreesCcw
size={26}
style={{ margin: '0 5px', verticalAlign: 'middle' }}
title={`Set output rotation. Current: ${isRotationSet ? rotationStr : 'Don\'t modify'}`}
onClick={increaseRotation}
role="button"
/>
</div>

<FaTrashAlt
title="Delete source file"
style={{ padding: '5px 10px' }}
size={16}
onClick={deleteSource}
role="button"
/>

{renderCaptureFormatButton({ height: 20 })}

<IoIosCamera
style={{ paddingLeft: 5, paddingRight: 15 }}
size={25}
title="Capture frame"
onClick={capture}
/>

<span
style={{ background: primaryColor, borderRadius: 5, padding: '3px 7px', fontSize: 14 }}
onClick={cutClick}
title={multipleCutSegments ? 'Export all segments' : 'Export selection'}
role="button"
>
<CutIcon
style={{ verticalAlign: 'middle', marginRight: 3 }}
size={16}
/>
Export
</span>
</div>
);
});

export default RightMenu;
19 changes: 15 additions & 4 deletions src/SegmentList.jsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import React, { memo, Fragment } from 'react';
import prettyMs from 'pretty-ms';
import { FaSave, FaPlus, FaMinus, FaTag, FaSortNumericDown } from 'react-icons/fa';
import { FaSave, FaPlus, FaMinus, FaTag, FaSortNumericDown, FaAngleRight } from 'react-icons/fa';
import { motion } from 'framer-motion';
import Swal from 'sweetalert2';

import { saveColor } from './colors';
import { getSegColors } from './util';

const SegmentList = memo(({
formatTimecode, cutSegments, getFrameCount, getSegColors, onSegClick,
formatTimecode, cutSegments, getFrameCount, onSegClick,
currentSegIndex, invertCutSegments,
updateCurrentSegOrder, addCutSegment, removeCutSegment,
setCurrentSegmentName, currentCutSeg,
setCurrentSegmentName, currentCutSeg, toggleSideBar,
}) => {
if (!cutSegments && invertCutSegments) {
return <div style={{ padding: '0 10px' }}>Make sure you have no overlapping segments.</div>;
Expand Down Expand Up @@ -60,7 +61,17 @@ const SegmentList = memo(({
return (
<Fragment>
<div style={{ padding: '0 10px', overflowY: 'scroll', flexGrow: 1 }}>
<div style={{ fontSize: 14, marginBottom: 10 }}>Segments to export:</div>
<div style={{ fontSize: 14, marginBottom: 10 }}>
<FaAngleRight
title="Close sidebar"
size={18}
style={{ verticalAlign: 'middle' }}
role="button"
onClick={toggleSideBar}
/>

Segments to export:
</div>

{cutSegments.map((seg, index) => {
const duration = seg.end - seg.start;
Expand Down
142 changes: 142 additions & 0 deletions src/Timeline.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import React, { memo, useRef, useMemo, useCallback, useEffect } from 'react';
import { motion } from 'framer-motion';
import Hammer from 'react-hammerjs';

import TimelineSeg from './TimelineSeg';
import InverseCutSegment from './InverseCutSegment';

import { timelineBackground } from './colors';

import { getSegColors } from './util';


const hammerOptions = { recognizers: {} };

const Timeline = memo(({
durationSafe, getCurrentTime, startTimeOffset, playerTime, commandedTime,
zoom, neighbouringFrames, seekAbs, seekRel, duration, apparentCutSegments, zoomRel,
setCurrentSegIndex, currentSegIndexSafe, invertCutSegments, inverseCutSegments, mainVideoStream, formatTimecode,
}) => {
const timelineScrollerRef = useRef();
const timelineScrollerSkipEventRef = useRef();
const timelineWrapperRef = useRef();

const offsetCurrentTime = (getCurrentTime() || 0) + startTimeOffset;

const calculateTimelinePos = useCallback((time) => (time !== undefined && time < durationSafe ? `${(time / durationSafe) * 100}%` : undefined), [durationSafe]);

const currentTimePos = useMemo(() => calculateTimelinePos(playerTime), [calculateTimelinePos, playerTime]);
const commandedTimePos = useMemo(() => calculateTimelinePos(commandedTime), [calculateTimelinePos, commandedTime]);

const zoomed = zoom > 1;

const currentTimeWidth = 1;
// Prevent it from overflowing (and causing scroll) when end of timeline

const shouldShowKeyframes = neighbouringFrames.length >= 2 && (neighbouringFrames[neighbouringFrames.length - 1].time - neighbouringFrames[0].time) / durationSafe > (0.1 / zoom);

useEffect(() => {
timelineScrollerSkipEventRef.current = true;
if (zoom > 1) {
timelineScrollerRef.current.scrollLeft = (getCurrentTime() / durationSafe)
* (timelineWrapperRef.current.offsetWidth - timelineScrollerRef.current.offsetWidth);
}
}, [zoom, durationSafe, getCurrentTime]);

const onTimelineScroll = useCallback((e) => {
if (timelineScrollerSkipEventRef.current) {
timelineScrollerSkipEventRef.current = false;
return;
}
if (!zoomed) return;
seekAbs((((e.target.scrollLeft + (timelineScrollerRef.current.offsetWidth / 2))
/ timelineWrapperRef.current.offsetWidth) * duration));
}, [duration, seekAbs, zoomed]);

const handleTap = useCallback((e) => {
const target = timelineWrapperRef.current;
const rect = target.getBoundingClientRect();
const relX = e.srcEvent.pageX - (rect.left + document.body.scrollLeft);
if (duration) seekAbs((relX / target.offsetWidth) * duration);
}, [duration, seekAbs]);

const onWheel = useCallback((e) => {
const combinedDelta = e.deltaX + e.deltaY;
if (e.ctrlKey) {
zoomRel(-e.deltaY / 15);
} else if (!zoomed) seekRel(combinedDelta / 15);
}, [seekRel, zoomRel, zoomed]);

return (
<Hammer
onTap={handleTap}
onPan={handleTap}
options={hammerOptions}
>
<div style={{ position: 'relative' }}>
<div
style={{ overflowX: 'scroll' }}
id="timeline-scroller"
onWheel={onWheel}
onScroll={onTimelineScroll}
ref={timelineScrollerRef}
>
<div
style={{ height: 36, width: `${zoom * 100}%`, position: 'relative', backgroundColor: timelineBackground }}
ref={timelineWrapperRef}
>
{currentTimePos !== undefined && <motion.div transition={{ type: 'spring', damping: 70, stiffness: 800 }} animate={{ left: currentTimePos }} style={{ position: 'absolute', bottom: 0, top: 0, zIndex: 3, backgroundColor: 'black', width: currentTimeWidth, pointerEvents: 'none' }} />}
{commandedTimePos !== undefined && <div style={{ left: commandedTimePos, position: 'absolute', bottom: 0, top: 0, zIndex: 4, backgroundColor: 'white', width: currentTimeWidth, pointerEvents: 'none' }} />}

{apparentCutSegments.map((seg, i) => {
const {
segBgColor, segActiveBgColor, segBorderColor,
} = getSegColors(seg);

return (
<TimelineSeg
key={seg.uuid}
segNum={i}
segBgColor={segBgColor}
segActiveBgColor={segActiveBgColor}
segBorderColor={segBorderColor}
onSegClick={setCurrentSegIndex}
isActive={i === currentSegIndexSafe}
duration={durationSafe}
name={seg.name}
cutStart={seg.start}
cutEnd={seg.end}
invertCutSegments={invertCutSegments}
zoomed={zoomed}
/>
);
})}

{inverseCutSegments && inverseCutSegments.map((seg) => (
<InverseCutSegment
// eslint-disable-next-line react/no-array-index-key
key={`${seg.start},${seg.end}`}
start={seg.start}
end={seg.end}
duration={durationSafe}
invertCutSegments={invertCutSegments}
/>
))}

{mainVideoStream && shouldShowKeyframes && neighbouringFrames.filter(f => f.keyframe).map((f) => (
<div key={f.time} style={{ position: 'absolute', top: 0, bottom: 0, left: `${(f.time / duration) * 100}%`, marginLeft: -1, width: 1, background: 'rgba(0,0,0,1)', pointerEvents: 'none' }} />
))}
</div>
</div>

<div style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}>
<div style={{ background: 'rgba(0,0,0,0.4)', borderRadius: 3, padding: '2px 4px', color: 'rgba(255, 255, 255, 0.8)' }}>
{formatTimecode(offsetCurrentTime)}
</div>
</div>
</div>
</Hammer>
);
});

export default Timeline;
Loading

0 comments on commit b2705ba

Please sign in to comment.