Skip to content

Commit

Permalink
Implement a canvas for better playback of more formats #88
Browse files Browse the repository at this point in the history
  • Loading branch information
mifi committed Apr 26, 2020
1 parent 25b9a04 commit b1bd273
Show file tree
Hide file tree
Showing 6 changed files with 238 additions and 107 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@
"moment": "^2.18.1",
"mousetrap": "^1.6.1",
"p-map": "^3.0.0",
"p-queue": "^6.2.0",
"patch-package": "^6.2.1",
"pretty-ms": "^6.0.0",
"react": "^16.12.0",
Expand Down Expand Up @@ -96,6 +95,7 @@
"read-chunk": "^2.0.0",
"semver": "^7.1.3",
"string-to-stream": "^1.1.1",
"strtok3": "^6.0.0",
"trash": "^6.1.1"
},
"eslintConfig": {
Expand Down
75 changes: 14 additions & 61 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import Lottie from 'react-lottie';
import { SideSheet, Button, Position, SegmentedControl, Select } from 'evergreen-ui';
import { useStateWithHistory } from 'react-use/lib/useStateWithHistory';
import useDebounce from 'react-use/lib/useDebounce';
import PQueue from 'p-queue';
import filePathToUrl from 'file-url';
import Mousetrap from 'mousetrap';
import uuid from 'uuid';
Expand All @@ -22,6 +21,7 @@ import flatMap from 'lodash/flatMap';
import isEqual from 'lodash/isEqual';


import Canvas from './Canvas';
import TopMenu from './TopMenu';
import HelpSheet from './HelpSheet';
import SettingsSheet from './SettingsSheet';
Expand All @@ -39,7 +39,7 @@ import allOutFormats from './outFormats';
import { captureFrameFromTag, captureFrameFfmpeg } from './capture-frame';
import {
defaultProcessedCodecTypes, getStreamFps, isCuttingStart, isCuttingEnd,
getDefaultOutFormat, getFormatData, renderFrame, mergeFiles as ffmpegMergeFiles, renderThumbnails as ffmpegRenderThumbnails,
getDefaultOutFormat, getFormatData, mergeFiles as ffmpegMergeFiles, renderThumbnails as ffmpegRenderThumbnails,
readFrames, renderWaveformPng, html5ifyDummy, cutMultiple, extractStreams, autoMergeSegments, getAllStreams,
findNearestKeyFrameTime, html5ify as ffmpegHtml5ify, isStreamThumbnail,
} from './ffmpeg';
Expand Down Expand Up @@ -121,12 +121,8 @@ const zoomMax = 2 ** 14;
const videoStyle = { width: '100%', height: '100%', objectFit: 'contain' };


const queue = new PQueue({ concurrency: 1 });


const App = memo(() => {
// Per project state
const [framePath, setFramePath] = useState();
const [waveform, setWaveform] = useState();
const [html5FriendlyPath, setHtml5FriendlyPath] = useState();
const [working, setWorking] = useState(false);
Expand All @@ -140,7 +136,6 @@ const App = memo(() => {
const [rotation, setRotation] = useState(360);
const [cutProgress, setCutProgress] = useState();
const [startTimeOffset, setStartTimeOffset] = useState(0);
const [rotationPreviewRequested, setRotationPreviewRequested] = useState(false);
const [filePath, setFilePath] = useState('');
const [externalStreamFiles, setExternalStreamFiles] = useState([]);
const [detectedFps, setDetectedFps] = useState();
Expand Down Expand Up @@ -235,7 +230,6 @@ const App = memo(() => {
firstUpdateRef.current = false;
}, []);


// Global state
const [helpVisible, setHelpVisible] = useState(false);
const [settingsVisible, setSettingsVisible] = useState(false);
Expand Down Expand Up @@ -326,8 +320,12 @@ const App = memo(() => {
if (dummyVideoPath) unlink(dummyVideoPath).catch(console.error);
}, [dummyVideoPath]);

// 360 means we don't modify rotation
const isRotationSet = rotation !== 360;
const effectiveRotation = isRotationSet ? rotation : (mainVideoStream && mainVideoStream.tags && mainVideoStream.tags.rotate && parseInt(mainVideoStream.tags.rotate, 10));

const zoomRel = useCallback((rel) => setZoom(z => Math.min(Math.max(z + rel, 1), zoomMax)), []);
const frameRenderEnabled = !!(rotationPreviewRequested || dummyVideoPath);
const canvasPlayerEnabled = !!(mainVideoStream && (isRotationSet || dummyVideoPath));

const comfortZoom = duration ? Math.max(duration / 100, 1) : undefined;
const toggleComfortZoom = useCallback(() => {
Expand Down Expand Up @@ -565,38 +563,6 @@ const App = memo(() => {
save();
}, [debouncedCutSegments, edlFilePath, autoSaveProjectFile]);

// 360 means we don't modify rotation
const isRotationSet = rotation !== 360;
const effectiveRotation = isRotationSet ? rotation : undefined;

useEffect(() => {
async function throttledRender() {
if (queue.size < 2) {
queue.add(async () => {
if (!frameRenderEnabled) return;

if (playerTime == null || !filePath) return;

try {
const framePathNew = await renderFrame(playerTime, filePath, effectiveRotation);
setFramePath(framePathNew);
} catch (err) {
console.error(err);
}
});
}

await queue.onIdle();
}

throttledRender();
}, [
filePath, playerTime, frameRenderEnabled, effectiveRotation,
]);

// Cleanup old
useEffect(() => () => URL.revokeObjectURL(framePath), [framePath]);

function onPlayingChange(val) {
setPlaying(val);
if (!val) {
Expand All @@ -614,13 +580,11 @@ const App = memo(() => {
const onTimeUpdate = useCallback((e) => {
const { currentTime } = e.target;
if (playerTime === currentTime) return;
setRotationPreviewRequested(false); // Reset this
setPlayerTime(currentTime);
}, [playerTime]);

const increaseRotation = useCallback(() => {
setRotation((r) => (r + 90) % 450);
setRotationPreviewRequested(true);
}, []);

const assureOutDirAccess = useCallback(async (outFilePath) => {
Expand Down Expand Up @@ -812,7 +776,6 @@ const App = memo(() => {
video.playbackRate = 1;

setFileNameTitle();
setFramePath();
setHtml5FriendlyPath();
setDummyVideoPath();
setWorking(false);
Expand All @@ -830,7 +793,6 @@ const App = memo(() => {
setRotation(360);
setCutProgress();
setStartTimeOffset(0);
setRotationPreviewRequested(false);
setFilePath(''); // Setting video src="" prevents memory leak in chromium
setExternalStreamFiles([]);
setDetectedFps();
Expand Down Expand Up @@ -1015,7 +977,7 @@ const App = memo(() => {
outFormat: fileFormat,
isCustomFormatSelected,
videoDuration: duration,
rotation: effectiveRotation,
rotation: isRotationSet ? effectiveRotation : undefined,
copyFileStreams,
keyframeCut,
segments: outSegments,
Expand Down Expand Up @@ -1062,7 +1024,7 @@ const App = memo(() => {
setWorking(false);
}
}, [
effectiveRotation, outSegments, handleCutFailed,
effectiveRotation, outSegments, handleCutFailed, isRotationSet,
working, duration, filePath, keyframeCut,
autoMerge, customOutDir, fileFormat, haveInvalidSegs, copyFileStreams, numStreamsToCopy,
exportExtraStreams, nonCopiedExtraStreams, outputDir, shortestFlag, isCustomFormatSelected,
Expand All @@ -1075,15 +1037,15 @@ const App = memo(() => {
const currentTime = currentTimeRef.current;
const video = videoRef.current;
const outPath = mustCaptureFfmpeg
? await captureFrameFfmpeg({ customOutDir, videoPath: filePath, currentTime, captureFormat, duration: video.duration })
? await captureFrameFfmpeg({ customOutDir, videoPath: filePath, currentTime, captureFormat, duration })
: await captureFrameFromTag({ customOutDir, filePath, video, currentTime, captureFormat });

openDirToast({ dirPath: outputDir, text: `${i18n.t('Screenshot captured to:')} ${outPath}` });
} catch (err) {
console.error(err);
errorToast(i18n.t('Failed to capture frame'));
}
}, [filePath, captureFormat, customOutDir, html5FriendlyPath, dummyVideoPath, outputDir]);
}, [filePath, captureFormat, customOutDir, html5FriendlyPath, dummyVideoPath, outputDir, duration]);

const changePlaybackRate = useCallback((dir) => {
const video = videoRef.current;
Expand Down Expand Up @@ -1825,24 +1787,15 @@ const App = memo(() => {
onError={onVideoError}
/>

{framePath && frameRenderEnabled && (
<img
draggable={false}
style={{
width: '100%', height: '100%', objectFit: 'contain', left: 0, right: 0, top: 0, bottom: 0, position: 'absolute', background: 'black',
}}
src={framePath}
alt=""
/>
)}
{canvasPlayerEnabled && <Canvas rotate={effectiveRotation} filePath={filePath} width={mainVideoStream.width} height={mainVideoStream.height} playerTime={playerTime} commandedTime={commandedTime} playing={playing} />}
</div>

{rotationPreviewRequested && (
{isRotationSet && (
<div style={{
position: 'absolute', top: topBarHeight, marginTop: '1em', marginRight: '1em', right: sideBarWidth, color: 'white',
}}
>
{t('Lossless rotation preview')}
{t('Rotation preview')}
</div>
)}

Expand Down
33 changes: 33 additions & 0 deletions src/Canvas.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@

import React, { memo, useEffect, useRef, useMemo } from 'react';

import CanvasPlayer from './CanvasPlayer';

const Canvas = memo(({ rotate, filePath, width, height, playerTime, commandedTime, playing }) => {
const canvasRef = useRef();

const canvasPlayer = useMemo(() => CanvasPlayer({ path: filePath, width, height }),
[filePath, width, height]);

useEffect(() => {
canvasPlayer.setCanvas(canvasRef.current);

return () => {
canvasPlayer.setCanvas();
if (canvasPlayer) canvasPlayer.dispose();
};
}, [canvasPlayer]);

useEffect(() => {
if (playing) canvasPlayer.play(commandedTime);
else canvasPlayer.pause(playerTime);
}, [canvasPlayer, commandedTime, playerTime, playing]);

return (
<div style={{ width: '100%', height: '100%', left: 0, right: 0, top: 0, bottom: 0, position: 'absolute', overflow: 'hidden', background: 'black' }}>
<canvas ref={canvasRef} style={{ display: 'block', width: '100%', height: '100%', objectFit: 'contain', transform: `rotate(${rotate}deg)` }} />
</div>
);
});

export default Canvas;
105 changes: 105 additions & 0 deletions src/CanvasPlayer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { encodeLiveRawStream, getOneRawFrame } from './ffmpeg';

// TODO keep everything in electron land?
const strtok3 = window.require('strtok3');

export default ({ path, width: inWidth, height: inHeight }) => {
let canvas;

let terminated;
let cancel;
let commandedTime;
let playing;

function drawOnCanvas(rgbaImage, width, height) {
if (!canvas || rgbaImage.length === 0) return;

canvas.width = width;
canvas.height = height;

const ctx = canvas.getContext('2d');
// https://developer.mozilla.org/en-US/docs/Web/API/ImageData/ImageData
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/putImageData
ctx.putImageData(new ImageData(Uint8ClampedArray.from(rgbaImage), width, height), 0, 0);
}

async function run() {
let process;
let cancelled;

cancel = () => {
cancelled = true;
if (process) process.cancel();
cancel = undefined;
};

if (playing) {
try {
const { process: processIn, channels, width, height } = encodeLiveRawStream({ path, inWidth, inHeight, seekTo: commandedTime });
process = processIn;

// process.stderr.on('data', data => console.log(data.toString('utf-8')));

const tokenizer = await strtok3.fromStream(process.stdout);

const size = width * height * channels;
const buf = Buffer.allocUnsafe(size);

while (!cancelled) {
// eslint-disable-next-line no-await-in-loop
await tokenizer.readBuffer(buf, { length: size });
if (!cancelled) drawOnCanvas(buf, width, height);
}
} catch (err) {
if (!err.isCanceled) console.warn(err.message);
}
} else {
try {
const { process: processIn, width, height } = getOneRawFrame({ path, inWidth, inHeight, seekTo: commandedTime });
process = processIn;
const { stdout: rgbaImage } = await process;

if (!cancelled) drawOnCanvas(rgbaImage, width, height);
} catch (err) {
if (!err.isCanceled) console.warn(err.message);
}
}
}

function command() {
if (cancel) cancel();
run();
}

function pause(seekTo) {
if (terminated) return;
if (!playing && commandedTime === seekTo) return;
playing = false;
commandedTime = seekTo;
command();
}

function play(playFrom) {
if (terminated) return;
if (playing && commandedTime === playFrom) return;
playing = true;
commandedTime = playFrom;
command();
}

function setCanvas(c) {
canvas = c;
}

function dispose() {
terminated = true;
if (cancel) cancel();
}

return {
play,
pause,
setCanvas,
dispose,
};
};
Loading

0 comments on commit b1bd273

Please sign in to comment.