1
0
mirror of https://github.com/mifi/lossless-cut.git synced 2024-11-25 11:43:17 +01:00

Implement a canvas for better playback of more formats #88

This commit is contained in:
Mikael Finstad 2020-04-26 23:22:44 +08:00
parent 25b9a04dde
commit b1bd2731b4
6 changed files with 238 additions and 107 deletions

View File

@ -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",
@ -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": {

View File

@ -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';
@ -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';
@ -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';
@ -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);
@ -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();
@ -235,7 +230,6 @@ const App = memo(() => {
firstUpdateRef.current = false;
}, []);
// Global state
const [helpVisible, setHelpVisible] = useState(false);
const [settingsVisible, setSettingsVisible] = useState(false);
@ -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(() => {
@ -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) {
@ -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) => {
@ -812,7 +776,6 @@ const App = memo(() => {
video.playbackRate = 1;
setFileNameTitle();
setFramePath();
setHtml5FriendlyPath();
setDummyVideoPath();
setWorking(false);
@ -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();
@ -1015,7 +977,7 @@ const App = memo(() => {
outFormat: fileFormat,
isCustomFormatSelected,
videoDuration: duration,
rotation: effectiveRotation,
rotation: isRotationSet ? effectiveRotation : undefined,
copyFileStreams,
keyframeCut,
segments: outSegments,
@ -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,
@ -1075,7 +1037,7 @@ 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}` });
@ -1083,7 +1045,7 @@ const App = memo(() => {
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;
@ -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>
)}

33
src/Canvas.jsx Normal file
View File

@ -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
src/CanvasPlayer.js Normal file
View File

@ -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,
};
};

View File

@ -217,7 +217,7 @@ async function cut({
...cutToArgs,
];
const rotationArgs = rotation !== undefined ? ['-metadata:s:v:0', `rotate=${rotation}`] : [];
const rotationArgs = rotation !== undefined ? ['-metadata:s:v:0', `rotate=${360 - rotation}`] : [];
const ffmpegArgs = [
'-hide_banner',
@ -310,6 +310,7 @@ export async function cutMultiple({
return outFiles;
}
// TODO merge with getFormatData
export async function getDuration(filePath) {
// https://superuser.com/questions/650291/how-to-get-video-duration-in-seconds
const { stdout } = await runFfprobe(['-i', filePath, '-show_entries', 'format=duration', '-print_format', 'json']);
@ -648,35 +649,6 @@ export async function renderWaveformPng({ filePath, aroundTime, window, color })
}
}
export async function renderFrame(timestamp, filePath, rotation) {
const transpose = {
90: 'transpose=2',
180: 'transpose=1,transpose=1',
270: 'transpose=1',
};
const args = [
'-ss', timestamp,
...(rotation !== undefined ? ['-noautorotate'] : []),
'-i', filePath,
// ...(rotation !== undefined ? ['-metadata:s:v:0', 'rotate=0'] : []), // Reset the rotation metadata first
...(rotation !== undefined && rotation > 0 ? ['-vf', `${transpose[rotation]}`] : []),
'-f', 'image2',
'-vframes', '1',
'-q:v', '10',
'-',
// '-y', outPath,
];
// console.time('ffmpeg');
const ffmpegPath = getFfmpegPath();
// console.timeEnd('ffmpeg');
// console.log('ffmpeg', args);
const { stdout } = await execa(ffmpegPath, args, { encoding: null });
const blob = new Blob([stdout], { type: 'image/jpeg' });
return URL.createObjectURL(blob);
}
export async function extractWaveform({ filePath, outPath }) {
const numSegs = 10;
const duration = 60 * 60;
@ -739,3 +711,66 @@ export function getStreamFps(stream) {
}
return undefined;
}
function createRawFfmpeg({ fps = 25, path, inWidth, inHeight, seekTo, oneFrameOnly, execaOpts }) {
// const fps = 25; // TODO
const aspectRatio = inWidth / inHeight;
let newWidth;
let newHeight;
if (inWidth > inHeight) {
newWidth = 320;
newHeight = Math.floor(newWidth / aspectRatio);
} else {
newHeight = 320;
newWidth = Math.floor(newHeight * aspectRatio);
}
const args = [
'-hide_banner', '-loglevel', 'panic',
'-re',
'-ss', seekTo,
'-noautorotate',
'-i', path,
'-vf', `fps=${fps},scale=${newWidth}:${newHeight}:flags=lanczos`,
'-map', 'v:0',
'-vcodec', 'rawvideo',
'-pix_fmt', 'rgba',
...(oneFrameOnly ? ['-frames:v', '1'] : []),
'-f', 'image2pipe',
'-',
];
// console.log(args);
return {
process: execa(getFfmpegPath(), args, execaOpts),
width: newWidth,
height: newHeight,
channels: 4,
};
}
export function getOneRawFrame({ path, inWidth, inHeight, seekTo }) {
const { process, width, height, channels } = createRawFfmpeg({ path, inWidth, inHeight, seekTo, oneFrameOnly: true, execaOpts: { encoding: null } });
return { process, width, height, channels };
}
export function encodeLiveRawStream({ path, inWidth, inHeight, seekTo }) {
const { process, width, height, channels } = createRawFfmpeg({ path, inWidth, inHeight, seekTo, execaOpts: { encoding: null, buffer: false } });
return {
process,
width,
height,
channels,
};
}

View File

@ -1544,6 +1544,11 @@
dependencies:
defer-to-connect "^1.0.1"
"@tokenizer/token@^0.1.1":
version "0.1.1"
resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.1.1.tgz#f0d92c12f87079ddfd1b29f614758b9696bc29e3"
integrity sha512-XO6INPbZCxdprl+9qa/AAbFFOMzzwqYxpjPgLICrMD6C2FCw6qfJOPcBk6JqqPLSaZ/Qx87qn4rpPmPMwaAK6w==
"@types/babel__core@^7.1.0":
version "7.1.6"
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.6.tgz#16ff42a5ae203c9af1c6e190ed1f30f83207b610"
@ -8973,14 +8978,6 @@ p-map@^3.0.0:
dependencies:
aggregate-error "^3.0.0"
p-queue@^6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.2.0.tgz#c8122b9514d2bbe5f16d8a47f17dc2f9a8ac7235"
integrity sha512-B2LXNONcyn/G6uz2UBFsGjmSa0e/br3jznlzhEyCXg56c7VhEpiT2pZxGOfv32Q3FSyugAdys9KGpsv3kV+Sbg==
dependencies:
eventemitter3 "^4.0.0"
p-timeout "^3.1.0"
p-reduce@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/p-reduce/-/p-reduce-1.0.0.tgz#18c2b0dd936a4690a529f8231f58a0fdb6a47dfa"
@ -8993,13 +8990,6 @@ p-retry@^3.0.1:
dependencies:
retry "^0.12.0"
p-timeout@^3.1.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe"
integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==
dependencies:
p-finally "^1.0.0"
p-try@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3"
@ -9243,6 +9233,11 @@ pbkdf2@^3.0.3:
safe-buffer "^5.0.1"
sha.js "^2.4.8"
peek-readable@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/peek-readable/-/peek-readable-3.1.0.tgz#250b08b7de09db8573d7fd8ea475215bbff14348"
integrity sha512-KGuODSTV6hcgdZvDrIDBUkN0utcAVj1LL7FfGbM0viKTtCHmtZcuEJ+lGqsp0fTFkGqesdtemV2yUSMeyy3ddA==
pend@~1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
@ -12026,6 +12021,16 @@ strong-data-uri@^1.0.5:
dependencies:
truncate "^2.0.1"
strtok3@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/strtok3/-/strtok3-6.0.0.tgz#d6b900863daeacfe6c1724c6e7bb36d7a58e83c8"
integrity sha512-ZXlmE22LZnIBvEU3n/kZGdh770fYFie65u5+2hLK9s74DoFtpkQIdBZVeYEzlolpGa+52G5IkzjUWn+iXynOEQ==
dependencies:
"@tokenizer/token" "^0.1.1"
"@types/debug" "^4.1.5"
debug "^4.1.1"
peek-readable "^3.1.0"
style-loader@0.23.1:
version "0.23.1"
resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-0.23.1.tgz#cb9154606f3e771ab6c4ab637026a1049174d925"