Skip to content

Commit

Permalink
improve waveform #260
Browse files Browse the repository at this point in the history
and type
  • Loading branch information
mifi committed Mar 20, 2024
1 parent 2e7d746 commit 8a7c1f8
Show file tree
Hide file tree
Showing 15 changed files with 233 additions and 114 deletions.
4 changes: 0 additions & 4 deletions public/ffmpeg.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,10 +201,6 @@ async function renderWaveformPng({ filePath, start, duration, color, streamIndex

return {
buffer: stdout,
from: start,
to: start + duration,
duration,
createdAt: new Date(),
};
} catch (err) {
if (ps1) ps1.kill();
Expand Down
11 changes: 7 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ function App() {
return formatDuration({ seconds, shorten, fileNameFriendly });
}, [detectedFps, timecodeFormat, getFrameCount]);

const formatTimeAndFrames = useCallback((seconds) => {
const formatTimeAndFrames = useCallback((seconds: number) => {
const frameCount = getFrameCount(seconds);

const timeStr = timecodeFormat === 'timecodeWithFramesFraction'
Expand Down Expand Up @@ -628,6 +628,7 @@ function App() {
if (!subtitleStream || workingRef.current) return;
try {
setWorking({ text: i18n.t('Loading subtitle') });
invariant(filePath != null);
const url = await extractSubtitleTrack(filePath, index);
setSubtitlesByStreamId((old) => ({ ...old, [index]: { url, lang: subtitleStream.tags && subtitleStream.tags.language } }));
setActiveSubtitleStreamIndex(index);
Expand Down Expand Up @@ -713,6 +714,8 @@ function App() {

try {
setThumbnails([]);
invariant(filePath != null);
invariant(zoomedDuration != null);
const promise = ffmpegRenderThumbnails({ filePath, from: zoomWindowStartTime, duration: zoomedDuration, onThumbnail: addThumbnail });
thumnailsRenderingPromiseRef.current = promise;
await promise;
Expand Down Expand Up @@ -1205,6 +1208,8 @@ function App() {
), [customOutDir, filePath, mergedOutFileName]);

const onExportConfirm = useCallback(async () => {
invariant(filePath != null);

if (numStreamsToCopy === 0) {
errorToast(i18n.t('No tracks selected for export'));
return;
Expand Down Expand Up @@ -1453,7 +1458,7 @@ function App() {

const storeProjectInSourceDir = !storeProjectInWorkingDir;

async function tryFindAndLoadProjectFile({ chapters, cod }) {
async function tryFindAndLoadProjectFile({ chapters, cod }: { chapters, cod: string | undefined }) {
try {
// First try to open from from working dir
if (await tryOpenProjectPath(getEdlFilePath(fp, cod), 'llc')) return;
Expand Down Expand Up @@ -2620,7 +2625,6 @@ function App() {

<div className="no-user-select" style={bottomStyle}>
<Timeline
// @ts-expect-error todo
shouldShowKeyframes={shouldShowKeyframes}
waveforms={waveforms}
shouldShowWaveform={shouldShowWaveform}
Expand All @@ -2631,7 +2635,6 @@ function App() {
playerTime={playerTime}
commandedTime={commandedTime}
relevantTime={relevantTime}
getRelevantTime={getRelevantTime}
commandedTimeRef={commandedTimeRef}
startTimeOffset={startTimeOffset}
zoom={zoom}
Expand Down
2 changes: 1 addition & 1 deletion src/BetweenSegments.jsx → src/BetweenSegments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { FaTrashAlt, FaSave } from 'react-icons/fa';
import { mySpring } from './animations';
import { saveColor } from './colors';

const BetweenSegments = memo(({ start, end, duration, invertCutSegments }) => {
const BetweenSegments = memo(({ start, end, duration, invertCutSegments }: { start: number, end: number, duration: number, invertCutSegments: boolean }) => {
const left = `${(start / duration) * 100}%`;

return (
Expand Down
118 changes: 92 additions & 26 deletions src/Timeline.jsx → src/Timeline.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { memo, useRef, useMemo, useCallback, useEffect, useState } from 'react';
import { memo, useRef, useMemo, useCallback, useEffect, useState, MutableRefObject, CSSProperties, WheelEventHandler } from 'react';
import { motion, useMotionValue, useSpring } from 'framer-motion';
import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next';
import { FaCaretDown, FaCaretUp } from 'react-icons/fa';
import invariant from 'tiny-invariant';

import TimelineSeg from './TimelineSeg';
import BetweenSegments from './BetweenSegments';
Expand All @@ -11,11 +12,18 @@ import useUserSettings from './hooks/useUserSettings';


import { timelineBackground, darkModeTransition } from './colors';
import { Frame } from './ffmpeg';
import { ApparentCutSegment, FormatTimecode, InverseCutSegment, RenderableWaveform, Thumbnail } from './types';


type CalculateTimelinePercent = (time: number) => string | undefined;

const currentTimeWidth = 1;

const Waveform = memo(({ waveform, calculateTimelinePercent, durationSafe }) => {
const [style, setStyle] = useState({ display: 'none' });
const Waveform = memo(({ waveform, calculateTimelinePercent, durationSafe }: {
waveform: RenderableWaveform, calculateTimelinePercent: CalculateTimelinePercent, durationSafe: number,
}) => {
const [style, setStyle] = useState<CSSProperties>({ display: 'none' });

const leftPos = calculateTimelinePercent(waveform.from);

Expand All @@ -27,22 +35,27 @@ const Waveform = memo(({ waveform, calculateTimelinePercent, durationSafe }) =>
position: 'absolute', height: '100%', left: leftPos, width: `${((toTruncated - waveform.from) / durationSafe) * 100}%`,
});
}

if (waveform.url == null) return null;

return (
<img src={waveform.url} draggable={false} style={style} alt="" onLoad={onLoad} />
);
});

const Waveforms = memo(({ calculateTimelinePercent, durationSafe, waveforms, zoom, height }) => (
const Waveforms = memo(({ calculateTimelinePercent, durationSafe, waveforms, zoom, height }: {
calculateTimelinePercent: CalculateTimelinePercent, durationSafe: number, waveforms: RenderableWaveform[], zoom: number, height: number,
}) => (
<div style={{ height, width: `${zoom * 100}%`, position: 'relative' }}>
{waveforms.map((waveform) => (
<Waveform key={`${waveform.from}-${waveform.to}`} waveform={waveform} calculateTimelinePercent={calculateTimelinePercent} durationSafe={durationSafe} />
))}
</div>
));

const CommandedTime = memo(({ commandedTimePercent }) => {
const CommandedTime = memo(({ commandedTimePercent }: { commandedTimePercent: string }) => {
const color = 'var(--gray12)';
const commonStyle = { left: commandedTimePercent, position: 'absolute', pointerEvents: 'none' };
const commonStyle: CSSProperties = { left: commandedTimePercent, position: 'absolute', pointerEvents: 'none' };
return (
<>
<FaCaretDown style={{ ...commonStyle, top: 0, color, fontSize: 14, marginLeft: -7, marginTop: -6 }} />
Expand All @@ -54,27 +67,76 @@ const CommandedTime = memo(({ commandedTimePercent }) => {

const timelineHeight = 36;

const timeWrapperStyle = { position: 'absolute', height: timelineHeight, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' };
const timeStyle = { background: 'rgba(0,0,0,0.4)', borderRadius: 3, padding: '2px 4px', color: 'rgba(255, 255, 255, 0.8)' };
const timeWrapperStyle: CSSProperties = { position: 'absolute', height: timelineHeight, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' };
const timeStyle: CSSProperties = { background: 'rgba(0,0,0,0.4)', borderRadius: 3, padding: '2px 4px', color: 'rgba(255, 255, 255, 0.8)' };

const Timeline = memo(({
durationSafe, startTimeOffset, playerTime, commandedTime, relevantTime,
zoom, neighbouringKeyFrames, seekAbs, apparentCutSegments,
setCurrentSegIndex, currentSegIndexSafe, inverseCutSegments, formatTimecode, formatTimeAndFrames,
waveforms, shouldShowWaveform, shouldShowKeyframes, thumbnails,
onZoomWindowStartTimeChange, waveformEnabled, showThumbnails,
playing, isFileOpened, onWheel, commandedTimeRef, goToTimecode, isSegmentSelected,
durationSafe,
startTimeOffset,
playerTime,
commandedTime,
relevantTime,
zoom,
neighbouringKeyFrames,
seekAbs,
apparentCutSegments,
setCurrentSegIndex,
currentSegIndexSafe,
inverseCutSegments,
formatTimecode,
formatTimeAndFrames,
waveforms,
shouldShowWaveform,
shouldShowKeyframes,
thumbnails,
onZoomWindowStartTimeChange,
waveformEnabled,
showThumbnails,
playing,
isFileOpened,
onWheel,
commandedTimeRef,
goToTimecode,
isSegmentSelected,
} : {
durationSafe: number,
startTimeOffset: number,
playerTime: number | undefined,
commandedTime: number,
relevantTime: number,
zoom: number,
neighbouringKeyFrames: Frame[],
seekAbs: (a: number) => void,
apparentCutSegments: ApparentCutSegment[],
setCurrentSegIndex: (a: number) => void,
currentSegIndexSafe: number,
inverseCutSegments: InverseCutSegment[],
formatTimecode: FormatTimecode,
formatTimeAndFrames: (a: number) => string,
waveforms: RenderableWaveform[],
shouldShowWaveform: boolean,
shouldShowKeyframes: boolean,
thumbnails: Thumbnail[],
onZoomWindowStartTimeChange: (a: number) => void,
waveformEnabled: boolean,
showThumbnails: boolean,
playing: boolean,
isFileOpened: boolean,
onWheel: WheelEventHandler,
commandedTimeRef: MutableRefObject<number>,
goToTimecode: () => void,
isSegmentSelected: (a: { segId: string }) => boolean,
}) => {
const { t } = useTranslation();

const { invertCutSegments } = useUserSettings();

const timelineScrollerRef = useRef();
const timelineScrollerSkipEventRef = useRef();
const timelineScrollerSkipEventDebounce = useRef();
const timelineWrapperRef = useRef();
const timelineScrollerRef = useRef<HTMLDivElement>(null);
const timelineScrollerSkipEventRef = useRef<boolean>(false);
const timelineScrollerSkipEventDebounce = useRef<() => void>();
const timelineWrapperRef = useRef<HTMLDivElement>(null);

const [hoveringTime, setHoveringTime] = useState();
const [hoveringTime, setHoveringTime] = useState<number>();

const displayTime = (hoveringTime != null && isFileOpened && !playing ? hoveringTime : relevantTime) + startTimeOffset;
const displayTimePercent = useMemo(() => `${Math.round((displayTime / durationSafe) * 100)}%`, [displayTime, durationSafe]);
Expand All @@ -99,12 +161,12 @@ const Timeline = memo(({
const timeOfInterestPosPixels = useMemo(() => {
// https://github.com/mifi/lossless-cut/issues/676
const pos = calculateTimelinePos(relevantTime);
if (pos != null && timelineScrollerRef.current) return pos * zoom * timelineScrollerRef.current.offsetWidth;
if (pos != null && timelineScrollerRef.current) return pos * zoom * timelineScrollerRef.current!.offsetWidth;
return undefined;
}, [calculateTimelinePos, relevantTime, zoom]);

const calcZoomWindowStartTime = useCallback(() => (timelineScrollerRef.current
? (timelineScrollerRef.current.scrollLeft / (timelineScrollerRef.current.offsetWidth * zoom)) * durationSafe
? (timelineScrollerRef.current.scrollLeft / (timelineScrollerRef.current!.offsetWidth * zoom)) * durationSafe
: 0), [durationSafe, zoom]);

// const zoomWindowStartTime = calcZoomWindowStartTime(duration, zoom);
Expand All @@ -117,7 +179,7 @@ const Timeline = memo(({

function suppressScrollerEvents() {
timelineScrollerSkipEventRef.current = true;
timelineScrollerSkipEventDebounce.current();
timelineScrollerSkipEventDebounce.current?.();
}

const scrollLeftMotion = useMotionValue(0);
Expand All @@ -127,16 +189,17 @@ const Timeline = memo(({
useEffect(() => {
spring.on('change', (value) => {
if (timelineScrollerSkipEventRef.current) return; // Don't animate while zooming
timelineScrollerRef.current.scrollLeft = value;
timelineScrollerRef.current!.scrollLeft = value;
});
}, [spring]);

// Pan timeline when cursor moves out of timeline window
useEffect(() => {
if (timeOfInterestPosPixels == null || timelineScrollerSkipEventRef.current) return;

invariant(timelineScrollerRef.current != null);
if (timeOfInterestPosPixels > timelineScrollerRef.current.scrollLeft + timelineScrollerRef.current.offsetWidth) {
const timelineWidth = timelineWrapperRef.current.offsetWidth;
const timelineWidth = timelineWrapperRef.current!.offsetWidth;
const scrollLeft = timeOfInterestPosPixels - (timelineScrollerRef.current.offsetWidth * 0.1);
scrollLeftMotion.set(Math.min(scrollLeft, timelineWidth - timelineScrollerRef.current.offsetWidth));
} else if (timeOfInterestPosPixels < timelineScrollerRef.current.scrollLeft) {
Expand All @@ -150,6 +213,7 @@ const Timeline = memo(({
suppressScrollerEvents();

if (isZoomed) {
invariant(timelineScrollerRef.current != null);
const zoomedTargetWidth = timelineScrollerRef.current.offsetWidth * zoom;

const scrollLeft = Math.max((commandedTimeRef.current / durationSafe) * zoomedTargetWidth - timelineScrollerRef.current.offsetWidth / 2, 0);
Expand All @@ -163,6 +227,7 @@ const Timeline = memo(({
const cancelWheel = (event) => event.preventDefault();

const scroller = timelineScrollerRef.current;
invariant(scroller != null);
scroller.addEventListener('wheel', cancelWheel, { passive: false });

return () => {
Expand All @@ -186,6 +251,7 @@ const Timeline = memo(({

const getMouseTimelinePos = useCallback((e) => {
const target = timelineWrapperRef.current;
invariant(target != null);
const rect = target.getBoundingClientRect();
const relX = e.pageX - (rect.left + document.body.scrollLeft);
return (relX / target.offsetWidth) * durationSafe;
Expand All @@ -196,7 +262,7 @@ const Timeline = memo(({
const handleScrub = useCallback((e) => seekAbs((getMouseTimelinePos(e))), [seekAbs, getMouseTimelinePos]);

useEffect(() => {
setHoveringTime();
setHoveringTime(undefined);
}, [relevantTime]);

const onMouseDown = useCallback((e) => {
Expand Down Expand Up @@ -231,7 +297,7 @@ const Timeline = memo(({
e.preventDefault();
}, [getMouseTimelinePos]);

const onMouseOut = useCallback(() => setHoveringTime(), []);
const onMouseOut = useCallback(() => setHoveringTime(undefined), []);

const contextMenuTemplate = useMemo(() => [
{ label: t('Seek to timecode'), click: goToTimecode },
Expand Down
9 changes: 6 additions & 3 deletions src/TimelineSeg.jsx → src/TimelineSeg.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { memo, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence, MotionStyle } from 'framer-motion';
import { FaTrashAlt } from 'react-icons/fa';

import { mySpring } from './animations';
import useUserSettings from './hooks/useUserSettings';
import { useSegColors } from './contexts';
import { ApparentCutSegment, FormatTimecode } from './types';


const TimelineSeg = memo(({
seg, duration, isActive, segNum, onSegClick, invertCutSegments, formatTimecode, selected,
} : {
seg: ApparentCutSegment, duration: number, isActive: boolean, segNum: number, onSegClick: (a: number) => void, invertCutSegments: boolean, formatTimecode: FormatTimecode, selected: boolean,
}) => {
const { darkMode } = useUserSettings();
const { getSegColor } = useSegColors();
Expand All @@ -34,7 +37,7 @@ const TimelineSeg = memo(({
}, [darkMode, invertCutSegments, isActive, segColor, selected]);
const markerBorderRadius = 5;

const wrapperStyle = {
const wrapperStyle: MotionStyle = {
position: 'absolute',
top: 0,
bottom: 0,
Expand All @@ -59,7 +62,7 @@ const TimelineSeg = memo(({

const onThisSegClick = () => onSegClick(segNum);

const title = [];
const title: string[] = [];
if (cutEnd > cutStart) title.push(`${formatTimecode({ seconds: cutEnd - cutStart, shorten: true })}`);
if (name) title.push(name);

Expand Down
Loading

0 comments on commit 8a7c1f8

Please sign in to comment.