diff --git a/package.json b/package.json
index 7d5996cd..e1988c1c 100644
--- a/package.json
+++ b/package.json
@@ -67,8 +67,8 @@
"i18next-parser": "^7.6.0",
"icon-gen": "^3.0.0",
"ky": "^0.33.1",
+ "luxon": "^3.3.0",
"mkdirp": "^1.0.3",
- "moment": "^2.29.4",
"mousetrap": "^1.6.5",
"p-map": "^5.5.0",
"patch-package": "^6.2.1",
@@ -103,7 +103,7 @@
"electron-is-dev": "^2.0.0",
"electron-store": "5.1.1",
"electron-unhandled": "^4.0.1",
- "execa": "^5.0.0",
+ "execa": "^7.1.1",
"file-type": "16",
"file-url": "^3.0.0",
"fs-extra": "^8.1.0",
diff --git a/public/canvasPlayer.js b/public/canvasPlayer.js
new file mode 100644
index 00000000..427f59d7
--- /dev/null
+++ b/public/canvasPlayer.js
@@ -0,0 +1,66 @@
+const strtok3 = require('strtok3');
+
+const { getOneRawFrame, encodeLiveRawStream } = require('./ffmpeg');
+
+
+let aborters = [];
+
+async function command({ path, inWidth, inHeight, streamIndex, seekTo: commandedTime, onFrame, playing }) {
+ let process;
+ let aborted = false;
+
+ function killProcess() {
+ if (process) {
+ process.kill();
+ process = undefined;
+ }
+ }
+
+ function abort() {
+ aborted = true;
+ killProcess();
+ aborters = aborters.filter(((aborter) => aborter !== abort));
+ }
+ aborters.push(abort);
+
+ try {
+ if (playing) {
+ const { process: processIn, channels, width, height } = encodeLiveRawStream({ path, inWidth, inHeight, streamIndex, seekTo: commandedTime });
+ process = processIn;
+
+ // process.stderr.on('data', data => console.log(data.toString('utf-8')));
+
+ const tokenizer = await strtok3.fromStream(process.stdout);
+ if (aborted) return;
+
+ const size = width * height * channels;
+ const rgbaImage = Buffer.allocUnsafe(size);
+
+ while (!aborted) {
+ // eslint-disable-next-line no-await-in-loop
+ await tokenizer.readBuffer(rgbaImage, { length: size });
+ if (aborted) return;
+ onFrame(rgbaImage, width, height);
+ }
+ } else {
+ const { process: processIn, width, height } = getOneRawFrame({ path, inWidth, inHeight, streamIndex, seekTo: commandedTime, outSize: 1000 });
+ process = processIn;
+ const { stdout: rgbaImage } = await process;
+ if (aborted) return;
+ onFrame(rgbaImage, width, height);
+ }
+ } catch (err) {
+ if (!err.killed) console.warn(err.message);
+ } finally {
+ killProcess();
+ }
+}
+
+function abortAll() {
+ aborters.forEach((aborter) => aborter());
+}
+
+module.exports = {
+ command,
+ abortAll,
+};
diff --git a/public/electron.js b/public/electron.js
index 324f9a00..7bcc63a3 100644
--- a/public/electron.js
+++ b/public/electron.js
@@ -10,6 +10,7 @@ const yargsParser = require('yargs-parser');
const JSON5 = require('json5');
const remote = require('@electron/remote/main');
const { stat } = require('fs/promises');
+const ffmpeg = require('./ffmpeg');
const logger = require('./logger');
const menu = require('./menu');
@@ -253,6 +254,8 @@ const readyPromise = app.whenReady();
(async () => {
try {
+ await ffmpeg.whenImported;
+
logger.info('Initializing config store');
await configStore.init();
diff --git a/public/ffmpeg.js b/public/ffmpeg.js
new file mode 100644
index 00000000..17bbde3d
--- /dev/null
+++ b/public/ffmpeg.js
@@ -0,0 +1,520 @@
+const { join } = require('path');
+const isDev = require('electron-is-dev');
+const readline = require('readline');
+const stringToStream = require('string-to-stream');
+
+const { platform, arch, isWindows, isMac } = require('./util');
+
+const execaPromise = import('execa');
+
+let execa;
+execaPromise.then((execaNew) => {
+ ({ execa } = execaNew);
+}).catch((err) => console.error(err));
+
+
+const runningFfmpegs = new Set();
+// setInterval(() => console.log(runningFfmpegs.size), 1000);
+
+let customFfPath;
+
+// Note that this does not work on MAS because of sandbox restrictions
+function setCustomFfPath(path) {
+ customFfPath = path;
+}
+
+function getFfCommandLine(cmd, args) {
+ const mapArg = arg => (/[^0-9a-zA-Z-_]/.test(arg) ? `'${arg}'` : arg);
+ return `${cmd} ${args.map(mapArg).join(' ')}`;
+}
+
+function getFfPath(cmd) {
+ const exeName = isWindows ? `${cmd}.exe` : cmd;
+
+ if (customFfPath) return join(customFfPath, exeName);
+ if (isDev) return join('ffmpeg', `${platform}-${arch}`, exeName);
+ return join(window.process.resourcesPath, exeName);
+}
+
+const getFfprobePath = () => getFfPath('ffprobe');
+const getFfmpegPath = () => getFfPath('ffmpeg');
+
+function abortFfmpegs() {
+ console.log('Aborting', runningFfmpegs.size, 'ffmpeg process(es)');
+ runningFfmpegs.forEach((process) => {
+ process.kill('SIGTERM', { forceKillAfterTimeout: 10000 });
+ });
+}
+
+function handleProgress(process, durationIn, onProgress, customMatcher = () => {}) {
+ if (!onProgress) return;
+ onProgress(0);
+
+ const rl = readline.createInterface({ input: process.stderr });
+ rl.on('line', (line) => {
+ // console.log('progress', line);
+
+ try {
+ let match = line.match(/frame=\s*[^\s]+\s+fps=\s*[^\s]+\s+q=\s*[^\s]+\s+(?:size|Lsize)=\s*[^\s]+\s+time=\s*([^\s]+)\s+/);
+ // Audio only looks like this: "line size= 233422kB time=01:45:50.68 bitrate= 301.1kbits/s speed= 353x "
+ if (!match) match = line.match(/(?:size|Lsize)=\s*[^\s]+\s+time=\s*([^\s]+)\s+/);
+ if (!match) {
+ customMatcher(line);
+ return;
+ }
+
+ const timeStr = match[1];
+ console.log(timeStr);
+ const match2 = timeStr.match(/^(\d+):(\d+):(\d+)\.(\d+)$/);
+
+ const h = parseInt(match2[1], 10);
+ const m = parseInt(match2[2], 10);
+ const s = parseInt(match2[3], 10);
+ const cs = parseInt(match2[4], 10);
+ const time = (((h * 60) + m) * 60 + s) + cs / 100;
+ console.log(time);
+
+ const progressTime = Math.max(0, time);
+ // console.log(progressTime);
+
+ if (durationIn == null) return;
+ const duration = Math.max(0, durationIn);
+ if (duration === 0) return;
+ const progress = duration ? Math.min(progressTime / duration, 1) : 0; // sometimes progressTime will be greater than cutDuration
+ onProgress(progress);
+ } catch (err) {
+ console.log('Failed to parse ffmpeg progress line', err);
+ }
+ });
+}
+
+// todo collect warnings from ffmpeg output and show them after export? example: https://github.com/mifi/lossless-cut/issues/1469
+function runFfmpegProcess(args, execaOptions, { logCli = true } = {}) {
+ const ffmpegPath = getFfmpegPath();
+ if (logCli) console.log(getFfCommandLine('ffmpeg', args));
+ const process = execa(ffmpegPath, args, execaOptions);
+
+ (async () => {
+ runningFfmpegs.add(process);
+ try {
+ await process;
+ } catch (err) {
+ // ignored here
+ } finally {
+ runningFfmpegs.delete(process);
+ }
+ })();
+ return process;
+}
+
+async function runFfmpegConcat({ ffmpegArgs, concatTxt, totalDuration, onProgress }) {
+ const process = runFfmpegProcess(ffmpegArgs);
+
+ handleProgress(process, totalDuration, onProgress);
+
+ stringToStream(concatTxt).pipe(process.stdin);
+
+ return process;
+}
+
+async function runFfmpegWithProgress({ ffmpegArgs, duration, onProgress }) {
+ const process = runFfmpegProcess(ffmpegArgs);
+ handleProgress(process, duration, onProgress);
+ return process;
+}
+
+async function runFfprobe(args, { timeout = isDev ? 10000 : 30000 } = {}) {
+ const ffprobePath = getFfprobePath();
+ console.log(getFfCommandLine('ffprobe', args));
+ const ps = execa(ffprobePath, args);
+ const timer = setTimeout(() => {
+ console.warn('killing timed out ffprobe');
+ ps.kill();
+ }, timeout);
+ try {
+ return await ps;
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+async function renderWaveformPng({ filePath, start, duration, color }) {
+ const args1 = [
+ '-hide_banner',
+ '-i', filePath,
+ '-ss', start,
+ '-t', duration,
+ '-c', 'copy',
+ '-vn',
+ '-map', 'a:0',
+ '-f', 'matroska', // mpegts doesn't support vorbis etc
+ '-',
+ ];
+
+ const args2 = [
+ '-hide_banner',
+ '-i', '-',
+ '-filter_complex', `showwavespic=s=2000x300:scale=lin:filter=peak:split_channels=1:colors=${color}`,
+ '-frames:v', '1',
+ '-vcodec', 'png',
+ '-f', 'image2',
+ '-',
+ ];
+
+ console.log(getFfCommandLine('ffmpeg1', args1));
+ console.log('|', getFfCommandLine('ffmpeg2', args2));
+
+ let ps1;
+ let ps2;
+ try {
+ ps1 = runFfmpegProcess(args1, { encoding: null, buffer: false }, { logCli: false });
+ ps2 = runFfmpegProcess(args2, { encoding: null }, { logCli: false });
+ ps1.stdout.pipe(ps2.stdin);
+
+ const timer = setTimeout(() => {
+ ps1.kill();
+ ps2.kill();
+ console.warn('ffmpeg timed out');
+ }, 10000);
+
+ let stdout;
+ try {
+ ({ stdout } = await ps2);
+ } finally {
+ clearTimeout(timer);
+ }
+
+ return {
+ buffer: stdout,
+ from: start,
+ to: start + duration,
+ duration,
+ createdAt: new Date(),
+ };
+ } catch (err) {
+ if (ps1) ps1.kill();
+ if (ps2) ps2.kill();
+ throw err;
+ }
+}
+
+const getInputSeekArgs = ({ filePath, from, to }) => [
+ ...(from != null ? ['-ss', from.toFixed(5)] : []),
+ '-i', filePath,
+ ...(to != null ? ['-t', (to - from).toFixed(5)] : []),
+];
+
+function mapTimesToSegments(times) {
+ const segments = [];
+ for (let i = 0; i < times.length; i += 1) {
+ const start = times[i];
+ const end = times[i + 1];
+ if (start != null) segments.push({ start, end }); // end undefined is allowed (means until end of video)
+ }
+ return segments;
+}
+
+const getSegmentOffset = (from) => (from != null ? from : 0);
+
+function adjustSegmentsWithOffset({ segments, from }) {
+ const offset = getSegmentOffset(from);
+ return segments.map(({ start, end }) => ({ start: start + offset, end: end != null ? end + offset : end }));
+}
+
+// https://stackoverflow.com/questions/35675529/using-ffmpeg-how-to-do-a-scene-change-detection-with-timecode
+async function detectSceneChanges({ filePath, minChange, onProgress, from, to }) {
+ const args = [
+ '-hide_banner',
+ ...getInputSeekArgs({ filePath, from, to }),
+ '-filter_complex', `select='gt(scene,${minChange})',metadata=print:file=-`,
+ '-f', 'null', '-',
+ ];
+ const process = runFfmpegProcess(args, { encoding: null, buffer: false });
+
+ const times = [0];
+
+ handleProgress(process, to - from, onProgress);
+ const rl = readline.createInterface({ input: process.stdout });
+ rl.on('line', (line) => {
+ const match = line.match(/^frame:\d+\s+pts:\d+\s+pts_time:([\d.]+)/);
+ if (!match) return;
+ const time = parseFloat(match[1]);
+ if (Number.isNaN(time) || time <= times[times.length - 1]) return;
+ times.push(time);
+ });
+
+ await process;
+
+ const segments = mapTimesToSegments(times);
+
+ return adjustSegmentsWithOffset({ segments, from });
+}
+
+async function detectIntervals({ filePath, customArgs, onProgress, from, to, matchLineTokens }) {
+ const args = [
+ '-hide_banner',
+ ...getInputSeekArgs({ filePath, from, to }),
+ ...customArgs,
+ '-f', 'null', '-',
+ ];
+ const process = runFfmpegProcess(args, { encoding: null, buffer: false });
+
+ const segments = [];
+
+ function customMatcher(line) {
+ const { start: startStr, end: endStr } = matchLineTokens(line);
+ const start = parseFloat(startStr);
+ const end = parseFloat(endStr);
+ if (start == null || end == null || Number.isNaN(start) || Number.isNaN(end)) return;
+ segments.push({ start, end });
+ }
+ handleProgress(process, to - from, onProgress, customMatcher);
+
+ await process;
+ return adjustSegmentsWithOffset({ segments, from });
+}
+
+function getFffmpegJpegQuality(quality) {
+ // Normal range for JPEG is 2-31 with 31 being the worst quality.
+ const qMin = 2;
+ const qMax = 31;
+ return Math.min(Math.max(qMin, quality, Math.round((1 - quality) * (qMax - qMin) + qMin)), qMax);
+}
+
+async function captureFrames({ from, to, videoPath, outPathTemplate, quality, filter, framePts, onProgress }) {
+ const ffmpegQuality = getFffmpegJpegQuality(quality);
+
+ const args = [
+ '-ss', from,
+ '-i', videoPath,
+ '-t', Math.max(0, to - from),
+ '-q:v', ffmpegQuality,
+ ...(filter != null ? ['-vf', filter] : []),
+ // https://superuser.com/questions/1336285/use-ffmpeg-for-thumbnail-selections
+ ...(framePts ? ['-frame_pts', '1'] : []),
+ '-vsync', '0', // else we get a ton of duplicates (thumbnail filter)
+ '-y', outPathTemplate,
+ ];
+
+ const process = runFfmpegProcess(args, { encoding: null, buffer: false });
+
+ handleProgress(process, to - from, onProgress);
+
+ await process;
+}
+
+async function captureFrame({ timestamp, videoPath, outPath, quality }) {
+ const ffmpegQuality = getFffmpegJpegQuality(quality);
+ await runFfmpegProcess([
+ '-ss', timestamp,
+ '-i', videoPath,
+ '-vframes', '1',
+ '-q:v', ffmpegQuality,
+ '-y', outPath,
+ ]);
+}
+
+
+async function readFormatData(filePath) {
+ console.log('readFormatData', filePath);
+
+ const { stdout } = await runFfprobe([
+ '-of', 'json', '-show_format', '-i', filePath, '-hide_banner',
+ ]);
+ return JSON.parse(stdout).format;
+}
+
+async function getDuration(filePath) {
+ return parseFloat((await readFormatData(filePath)).duration);
+}
+
+async function html5ify({ outPath, filePath: filePathArg, speed, hasAudio, hasVideo, onProgress }) {
+ let audio;
+ if (hasAudio) {
+ if (speed === 'slowest') audio = 'hq';
+ else if (['slow-audio', 'fast-audio', 'fastest-audio'].includes(speed)) audio = 'lq';
+ else if (['fast-audio-remux', 'fastest-audio-remux'].includes(speed)) audio = 'copy';
+ }
+
+ let video;
+ if (hasVideo) {
+ if (speed === 'slowest') video = 'hq';
+ else if (['slow-audio', 'slow'].includes(speed)) video = 'lq';
+ else video = 'copy';
+ }
+
+ console.log('Making HTML5 friendly version', { filePathArg, outPath, video, audio });
+
+ let videoArgs;
+ let audioArgs;
+
+ // h264/aac_at: No licensing when using HW encoder (Video/Audio Toolbox on Mac)
+ // https://github.com/mifi/lossless-cut/issues/372#issuecomment-810766512
+
+ const targetHeight = 400;
+
+ switch (video) {
+ case 'hq': {
+ if (isMac) {
+ videoArgs = ['-vf', 'format=yuv420p', '-allow_sw', '1', '-vcodec', 'h264', '-b:v', '15M'];
+ } else {
+ // AV1 is very slow
+ // videoArgs = ['-vf', 'format=yuv420p', '-sws_flags', 'neighbor', '-vcodec', 'libaom-av1', '-crf', '30', '-cpu-used', '8'];
+ // Theora is a bit faster but not that much
+ // videoArgs = ['-vf', '-c:v', 'libtheora', '-qscale:v', '1'];
+ // videoArgs = ['-vf', 'format=yuv420p', '-c:v', 'libvpx-vp9', '-crf', '30', '-b:v', '0', '-row-mt', '1'];
+ // x264 can only be used in GPL projects
+ videoArgs = ['-vf', 'format=yuv420p', '-c:v', 'libx264', '-profile:v', 'high', '-preset:v', 'slow', '-crf', '17'];
+ }
+ break;
+ }
+ case 'lq': {
+ if (isMac) {
+ videoArgs = ['-vf', `scale=-2:${targetHeight},format=yuv420p`, '-allow_sw', '1', '-sws_flags', 'lanczos', '-vcodec', 'h264', '-b:v', '1500k'];
+ } else {
+ // videoArgs = ['-vf', `scale=-2:${targetHeight},format=yuv420p`, '-sws_flags', 'neighbor', '-c:v', 'libtheora', '-qscale:v', '1'];
+ // x264 can only be used in GPL projects
+ videoArgs = ['-vf', `scale=-2:${targetHeight},format=yuv420p`, '-sws_flags', 'neighbor', '-c:v', 'libx264', '-profile:v', 'baseline', '-x264opts', 'level=3.0', '-preset:v', 'ultrafast', '-crf', '28'];
+ }
+ break;
+ }
+ case 'copy': {
+ videoArgs = ['-vcodec', 'copy'];
+ break;
+ }
+ default: {
+ videoArgs = ['-vn'];
+ }
+ }
+
+ switch (audio) {
+ case 'hq': {
+ if (isMac) {
+ audioArgs = ['-acodec', 'aac_at', '-b:a', '192k'];
+ } else {
+ audioArgs = ['-acodec', 'flac'];
+ }
+ break;
+ }
+ case 'lq': {
+ if (isMac) {
+ audioArgs = ['-acodec', 'aac_at', '-ar', '44100', '-ac', '2', '-b:a', '96k'];
+ } else {
+ audioArgs = ['-acodec', 'flac', '-ar', '11025', '-ac', '2'];
+ }
+ break;
+ }
+ case 'copy': {
+ audioArgs = ['-acodec', 'copy'];
+ break;
+ }
+ default: {
+ audioArgs = ['-an'];
+ }
+ }
+
+ const ffmpegArgs = [
+ '-hide_banner',
+
+ '-i', filePathArg,
+ ...videoArgs,
+ ...audioArgs,
+ '-sn',
+ '-y', outPath,
+ ];
+
+ const duration = await getDuration(filePathArg);
+ const process = runFfmpegProcess(ffmpegArgs);
+ if (duration) handleProgress(process, duration, onProgress);
+
+ const { stdout } = await process;
+ console.log(stdout);
+}
+
+function createRawFfmpeg({ fps = 25, path, inWidth, inHeight, seekTo, oneFrameOnly, execaOpts, streamIndex, outSize = 320 }) {
+ // const fps = 25; // TODO
+
+ const aspectRatio = inWidth / inHeight;
+
+ let newWidth;
+ let newHeight;
+ if (inWidth > inHeight) {
+ newWidth = outSize;
+ newHeight = Math.floor(newWidth / aspectRatio);
+ } else {
+ newHeight = outSize;
+ 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', `0:${streamIndex}`,
+ '-vcodec', 'rawvideo',
+ '-pix_fmt', 'rgba',
+
+ ...(oneFrameOnly ? ['-frames:v', '1'] : []),
+
+ '-f', 'image2pipe',
+ '-',
+ ];
+
+ // console.log(args);
+
+ return {
+ process: runFfmpegProcess(args, execaOpts, { logCli: false }),
+ width: newWidth,
+ height: newHeight,
+ channels: 4,
+ };
+}
+
+function getOneRawFrame({ path, inWidth, inHeight, seekTo, streamIndex, outSize }) {
+ const { process, width, height, channels } = createRawFfmpeg({ path, inWidth, inHeight, seekTo, streamIndex, oneFrameOnly: true, execaOpts: { encoding: null }, outSize });
+ return { process, width, height, channels };
+}
+
+function encodeLiveRawStream({ path, inWidth, inHeight, seekTo, streamIndex }) {
+ const { process, width, height, channels } = createRawFfmpeg({ path, inWidth, inHeight, seekTo, streamIndex, execaOpts: { encoding: null, buffer: false } });
+
+ return {
+ process,
+ width,
+ height,
+ channels,
+ };
+}
+
+// Don't pass complex objects over the bridge
+const runFfmpeg = async (...args) => runFfmpegProcess(...args);
+
+module.exports = {
+ whenImported: execaPromise,
+ setCustomFfPath,
+ abortFfmpegs,
+ getFfmpegPath,
+ runFfprobe,
+ runFfmpeg,
+ runFfmpegConcat,
+ runFfmpegWithProgress,
+ renderWaveformPng,
+ mapTimesToSegments,
+ detectSceneChanges,
+ detectIntervals,
+ captureFrames,
+ captureFrame,
+ getFfCommandLine,
+ html5ify,
+ getDuration,
+ getOneRawFrame,
+ encodeLiveRawStream,
+};
diff --git a/public/util.js b/public/util.js
index 4b5d996b..94056679 100644
--- a/public/util.js
+++ b/public/util.js
@@ -1,5 +1,18 @@
+const os = require('os');
+
const frontendBuildDir = 'vite-dist';
+// todo dedupe between renderer and main
+const platform = os.platform();
+const arch = os.arch();
+
+const isWindows = platform === 'win32';
+const isMac = platform === 'darwin';
+
module.exports = {
frontendBuildDir,
+ isWindows,
+ isMac,
+ platform,
+ arch,
};
diff --git a/script/postversion.mjs b/script/postversion.mjs
index e7b6c3e0..a96932a0 100644
--- a/script/postversion.mjs
+++ b/script/postversion.mjs
@@ -1,6 +1,6 @@
import { readFile, writeFile } from 'fs/promises';
import { XMLParser, XMLBuilder } from 'fast-xml-parser';
-import moment from 'moment';
+import { DateTime } from 'luxon';
const xmlUrl = new URL('../no.mifi.losslesscut.appdata.xml', import.meta.url);
const xmlData = await readFile(xmlUrl);
@@ -13,7 +13,7 @@ const xml = parser.parse(xmlData);
const { version } = packageJson;
-xml.component.releases.release = [{ '@_version': version, '@_date': moment().format('YYYY-MM-DD') }, ...xml.component.releases.release];
+xml.component.releases.release = [{ '@_version': version, '@_date': DateTime.now().toISODate() }, ...xml.component.releases.release];
const builder = new XMLBuilder({ format: true, ignoreAttributes: false, suppressEmptyNode: true });
await writeFile(xmlUrl, builder.build(xml));
diff --git a/script/xcrun-wrapper.mjs b/script/xcrun-wrapper.mjs
index c128ac19..18a6a5c4 100644
--- a/script/xcrun-wrapper.mjs
+++ b/script/xcrun-wrapper.mjs
@@ -1,4 +1,4 @@
-import execa from 'execa';
+import { execa } from 'execa';
import { readFile } from 'fs/promises';
// we need a wrapper script because altool tends to error out very often
diff --git a/src/App.jsx b/src/App.jsx
index c13c8127..fb9c353a 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -2263,7 +2263,7 @@ const App = memo(() => {
)}
- {working && }
+ {working && abortFfmpegs()} />}
{tunerVisible && setTunerVisible()} />}
diff --git a/src/CanvasPlayer.js b/src/CanvasPlayer.js
index feb17854..9928626b 100644
--- a/src/CanvasPlayer.js
+++ b/src/CanvasPlayer.js
@@ -1,13 +1,9 @@
-import { encodeLiveRawStream, getOneRawFrame } from './ffmpeg';
+const remote = window.require('@electron/remote');
-// TODO keep everything in electron land?
-const strtok3 = window.require('strtok3');
+const { command, abortAll } = remote.require('./canvasPlayer');
export default ({ path, width: inWidth, height: inHeight, streamIndex, getCanvas }) => {
let terminated;
- let aborters = [];
- let commandedTime;
- let playing;
function drawOnCanvas(rgbaImage, width, height) {
const canvas = getCanvas();
@@ -22,77 +18,16 @@ export default ({ path, width: inWidth, height: inHeight, streamIndex, getCanvas
ctx.putImageData(new ImageData(Uint8ClampedArray.from(rgbaImage), width, height), 0, 0);
}
- async function command() {
- let process;
- let aborted = false;
-
- function killProcess() {
- if (process) {
- process.kill();
- process = undefined;
- }
- }
-
- function abort() {
- aborted = true;
- killProcess();
- aborters = aborters.filter(((aborter) => aborter !== abort));
- }
- aborters.push(abort);
-
- try {
- if (playing) {
- const { process: processIn, channels, width, height } = encodeLiveRawStream({ path, inWidth, inHeight, streamIndex, seekTo: commandedTime });
- process = processIn;
-
- // process.stderr.on('data', data => console.log(data.toString('utf-8')));
-
- const tokenizer = await strtok3.fromStream(process.stdout);
- if (aborted) return;
-
- const size = width * height * channels;
- const rgbaImage = Buffer.allocUnsafe(size);
-
- while (!aborted) {
- // eslint-disable-next-line no-await-in-loop
- await tokenizer.readBuffer(rgbaImage, { length: size });
- if (aborted) return;
- drawOnCanvas(rgbaImage, width, height);
- }
- } else {
- const { process: processIn, width, height } = getOneRawFrame({ path, inWidth, inHeight, streamIndex, seekTo: commandedTime, outSize: 1000 });
- process = processIn;
- const { stdout: rgbaImage } = await process;
- if (aborted) return;
- drawOnCanvas(rgbaImage, width, height);
- }
- } catch (err) {
- if (!err.killed) console.warn(err.message);
- } finally {
- killProcess();
- }
- }
-
- function abortAll() {
- aborters.forEach((aborter) => aborter());
- }
-
function pause(seekTo) {
if (terminated) return;
- playing = false;
- commandedTime = seekTo;
-
abortAll();
- command();
+ command({ path, inWidth, inHeight, streamIndex, seekTo, onFrame: drawOnCanvas, playing: false });
}
function play(playFrom) {
if (terminated) return;
- playing = true;
- commandedTime = playFrom;
-
abortAll();
- command();
+ command({ path, inWidth, inHeight, streamIndex, seekTo: playFrom, onFrame: drawOnCanvas, playing: true });
}
function terminate() {
diff --git a/src/ffmpeg.js b/src/ffmpeg.js
index d91b58d9..2045096c 100644
--- a/src/ffmpeg.js
+++ b/src/ffmpeg.js
@@ -1,85 +1,26 @@
import pMap from 'p-map';
import sortBy from 'lodash/sortBy';
-import moment from 'moment';
import i18n from 'i18next';
import Timecode from 'smpte-timecode';
import minBy from 'lodash/minBy';
import { pcmAudioCodecs, getMapStreamsArgs, isMov } from './util/streams';
-import { getSuffixedOutPath, isWindows, isMac, platform, arch, isExecaFailure } from './util';
+import { getSuffixedOutPath, isExecaFailure } from './util';
import { isDurationValid } from './segments';
-import isDev from './isDev';
-
-const execa = window.require('execa');
-const { join } = window.require('path');
const FileType = window.require('file-type');
-const readline = window.require('readline');
const { pathExists } = window.require('fs-extra');
-let customFfPath;
+const remote = window.require('@electron/remote');
-const runningFfmpegs = new Set();
-// setInterval(() => console.log(runningFfmpegs.size), 1000);
+const { renderWaveformPng, mapTimesToSegments, detectSceneChanges, detectIntervals, captureFrames, captureFrame, getFfCommandLine, runFfmpegConcat, runFfmpegWithProgress, html5ify, getDuration, abortFfmpegs, runFfmpeg, runFfprobe, getFfmpegPath, setCustomFfPath } = remote.require('./ffmpeg');
+
+
+export { renderWaveformPng, mapTimesToSegments, detectSceneChanges, captureFrames, captureFrame, getFfCommandLine, runFfmpegConcat, runFfmpegWithProgress, html5ify, getDuration, abortFfmpegs, runFfprobe, getFfmpegPath, setCustomFfPath };
export class RefuseOverwriteError extends Error {}
-// Note that this does not work on MAS because of sandbox restrictions
-export function setCustomFfPath(path) {
- customFfPath = path;
-}
-
-export function getFfCommandLine(cmd, args) {
- const mapArg = arg => (/[^0-9a-zA-Z-_]/.test(arg) ? `'${arg}'` : arg);
- return `${cmd} ${args.map(mapArg).join(' ')}`;
-}
-
-function getFfPath(cmd) {
- const exeName = isWindows ? `${cmd}.exe` : cmd;
-
- if (customFfPath) return join(customFfPath, exeName);
- if (isDev) return join('ffmpeg', `${platform}-${arch}`, exeName);
- return join(window.process.resourcesPath, exeName);
-}
-
-export const getFfmpegPath = () => getFfPath('ffmpeg');
-export const getFfprobePath = () => getFfPath('ffprobe');
-
-export async function runFfprobe(args, { timeout = isDev ? 10000 : 30000 } = {}) {
- const ffprobePath = getFfprobePath();
- console.log(getFfCommandLine('ffprobe', args));
- const ps = execa(ffprobePath, args);
- const timer = setTimeout(() => {
- console.warn('killing timed out ffprobe');
- ps.kill();
- }, timeout);
- try {
- return await ps;
- } finally {
- clearTimeout(timer);
- }
-}
-
-// todo collect warnings from ffmpeg output and show them after export? example: https://github.com/mifi/lossless-cut/issues/1469
-export function runFfmpeg(args, execaOptions, { logCli = true } = {}) {
- const ffmpegPath = getFfmpegPath();
- if (logCli) console.log(getFfCommandLine('ffmpeg', args));
- const process = execa(ffmpegPath, args, execaOptions);
-
- (async () => {
- runningFfmpegs.add(process);
- try {
- await process;
- } catch (err) {
- // ignored here
- } finally {
- runningFfmpegs.delete(process);
- }
- })();
- return process;
-}
-
export function logStdoutStderr({ stdout, stderr }) {
if (stdout.length > 0) {
console.log('%cSTDOUT:', 'color: green; font-weight: bold');
@@ -91,45 +32,6 @@ export function logStdoutStderr({ stdout, stderr }) {
}
}
-export function abortFfmpegs() {
- runningFfmpegs.forEach((process) => {
- process.kill('SIGTERM', { forceKillAfterTimeout: 10000 });
- });
-}
-
-export function handleProgress(process, durationIn, onProgress, customMatcher = () => {}) {
- if (!onProgress) return;
- onProgress(0);
-
- const rl = readline.createInterface({ input: process.stderr });
- rl.on('line', (line) => {
- // console.log('progress', line);
-
- try {
- let match = line.match(/frame=\s*[^\s]+\s+fps=\s*[^\s]+\s+q=\s*[^\s]+\s+(?:size|Lsize)=\s*[^\s]+\s+time=\s*([^\s]+)\s+/);
- // Audio only looks like this: "line size= 233422kB time=01:45:50.68 bitrate= 301.1kbits/s speed= 353x "
- if (!match) match = line.match(/(?:size|Lsize)=\s*[^\s]+\s+time=\s*([^\s]+)\s+/);
- if (!match) {
- customMatcher(line);
- return;
- }
-
- const str = match[1];
- // console.log(str);
- const progressTime = Math.max(0, moment.duration(str).asSeconds());
- // console.log(progressTime);
-
- if (durationIn == null) return;
- const duration = Math.max(0, durationIn);
- if (duration === 0) return;
- const progress = duration ? Math.min(progressTime / duration, 1) : 0; // sometimes progressTime will be greater than cutDuration
- onProgress(progress);
- } catch (err) {
- console.log('Failed to parse ffmpeg progress line', err);
- }
- });
-}
-
export function isCuttingStart(cutFrom) {
return cutFrom > 0;
}
@@ -278,20 +180,6 @@ export async function tryMapChaptersToEdl(chapters) {
}
}
-async function readFormatData(filePath) {
- console.log('readFormatData', filePath);
-
- const { stdout } = await runFfprobe([
- '-of', 'json', '-show_format', '-i', filePath, '-hide_banner',
- ]);
- return JSON.parse(stdout).format;
-}
-
-
-export async function getDuration(filePath) {
- return parseFloat((await readFormatData(filePath)).duration);
-}
-
export async function createChaptersFromSegments({ segmentPaths, chapterNames }) {
if (!chapterNames) return undefined;
try {
@@ -596,145 +484,6 @@ export async function renderThumbnails({ filePath, from, duration, onThumbnail }
}
-export async function renderWaveformPng({ filePath, start, duration, color }) {
- const args1 = [
- '-hide_banner',
- '-i', filePath,
- '-ss', start,
- '-t', duration,
- '-c', 'copy',
- '-vn',
- '-map', 'a:0',
- '-f', 'matroska', // mpegts doesn't support vorbis etc
- '-',
- ];
-
- const args2 = [
- '-hide_banner',
- '-i', '-',
- '-filter_complex', `showwavespic=s=2000x300:scale=lin:filter=peak:split_channels=1:colors=${color}`,
- '-frames:v', '1',
- '-vcodec', 'png',
- '-f', 'image2',
- '-',
- ];
-
- console.log(getFfCommandLine('ffmpeg1', args1));
- console.log('|', getFfCommandLine('ffmpeg2', args2));
-
- let ps1;
- let ps2;
- try {
- ps1 = runFfmpeg(args1, { encoding: null, buffer: false }, { logCli: false });
- ps2 = runFfmpeg(args2, { encoding: null }, { logCli: false });
- ps1.stdout.pipe(ps2.stdin);
-
- const timer = setTimeout(() => {
- ps1.kill();
- ps2.kill();
- console.warn('ffmpeg timed out');
- }, 10000);
-
- let stdout;
- try {
- ({ stdout } = await ps2);
- } finally {
- clearTimeout(timer);
- }
-
- const blob = new Blob([stdout], { type: 'image/png' });
-
- return {
- url: URL.createObjectURL(blob),
- from: start,
- to: start + duration,
- duration,
- createdAt: new Date(),
- };
- } catch (err) {
- if (ps1) ps1.kill();
- if (ps2) ps2.kill();
- throw err;
- }
-}
-
-const getInputSeekArgs = ({ filePath, from, to }) => [
- ...(from != null ? ['-ss', from.toFixed(5)] : []),
- '-i', filePath,
- ...(to != null ? ['-t', (to - from).toFixed(5)] : []),
-];
-
-const getSegmentOffset = (from) => (from != null ? from : 0);
-
-function adjustSegmentsWithOffset({ segments, from }) {
- const offset = getSegmentOffset(from);
- return segments.map(({ start, end }) => ({ start: start + offset, end: end != null ? end + offset : end }));
-}
-
-export function mapTimesToSegments(times) {
- const segments = [];
- for (let i = 0; i < times.length; i += 1) {
- const start = times[i];
- const end = times[i + 1];
- if (start != null) segments.push({ start, end }); // end undefined is allowed (means until end of video)
- }
- return segments;
-}
-
-// https://stackoverflow.com/questions/35675529/using-ffmpeg-how-to-do-a-scene-change-detection-with-timecode
-export async function detectSceneChanges({ filePath, minChange, onProgress, from, to }) {
- const args = [
- '-hide_banner',
- ...getInputSeekArgs({ filePath, from, to }),
- '-filter_complex', `select='gt(scene,${minChange})',metadata=print:file=-`,
- '-f', 'null', '-',
- ];
- const process = runFfmpeg(args, { encoding: null, buffer: false });
-
- const times = [0];
-
- handleProgress(process, to - from, onProgress);
- const rl = readline.createInterface({ input: process.stdout });
- rl.on('line', (line) => {
- const match = line.match(/^frame:\d+\s+pts:\d+\s+pts_time:([\d.]+)/);
- if (!match) return;
- const time = parseFloat(match[1]);
- if (Number.isNaN(time) || time <= times[times.length - 1]) return;
- times.push(time);
- });
-
- await process;
-
- const segments = mapTimesToSegments(times);
-
- return adjustSegmentsWithOffset({ segments, from });
-}
-
-
-export async function detectIntervals({ filePath, customArgs, onProgress, from, to, matchLineTokens }) {
- const args = [
- '-hide_banner',
- ...getInputSeekArgs({ filePath, from, to }),
- ...customArgs,
- '-f', 'null', '-',
- ];
- const process = runFfmpeg(args, { encoding: null, buffer: false });
-
- const segments = [];
-
- function customMatcher(line) {
- const { start: startStr, end: endStr } = matchLineTokens(line);
- const start = parseFloat(startStr);
- const end = parseFloat(endStr);
- if (start == null || end == null || Number.isNaN(start) || Number.isNaN(end)) return;
- segments.push({ start, end });
- }
- handleProgress(process, to - from, onProgress, customMatcher);
-
- await process;
- return adjustSegmentsWithOffset({ segments, from });
-}
-
const mapFilterOptions = (options) => Object.entries(options).map(([key, value]) => `${key}=${value}`).join(':');
export async function blackDetect({ filePath, filterOptions, onProgress, from, to }) {
@@ -795,46 +544,6 @@ export async function extractWaveform({ filePath, outPath }) {
console.timeEnd('ffmpeg');
}
-function getFffmpegJpegQuality(quality) {
- // Normal range for JPEG is 2-31 with 31 being the worst quality.
- const qMin = 2;
- const qMax = 31;
- return Math.min(Math.max(qMin, quality, Math.round((1 - quality) * (qMax - qMin) + qMin)), qMax);
-}
-
-export async function captureFrame({ timestamp, videoPath, outPath, quality }) {
- const ffmpegQuality = getFffmpegJpegQuality(quality);
- await runFfmpeg([
- '-ss', timestamp,
- '-i', videoPath,
- '-vframes', '1',
- '-q:v', ffmpegQuality,
- '-y', outPath,
- ]);
-}
-
-export async function captureFrames({ from, to, videoPath, outPathTemplate, quality, filter, framePts, onProgress }) {
- const ffmpegQuality = getFffmpegJpegQuality(quality);
-
- const args = [
- '-ss', from,
- '-i', videoPath,
- '-t', Math.max(0, to - from),
- '-q:v', ffmpegQuality,
- ...(filter != null ? ['-vf', filter] : []),
- // https://superuser.com/questions/1336285/use-ffmpeg-for-thumbnail-selections
- ...(framePts ? ['-frame_pts', '1'] : []),
- '-vsync', '0', // else we get a ton of duplicates (thumbnail filter)
- '-y', outPathTemplate,
- ];
-
- const process = runFfmpeg(args, { encoding: null, buffer: false });
-
- handleProgress(process, to - from, onProgress);
-
- await process;
-}
-
export function isIphoneHevc(format, streams) {
if (!streams.some((s) => s.codec_name === 'hevc')) return false;
const makeTag = format.tags && format.tags['com.apple.quicktime.make'];
@@ -858,68 +567,6 @@ export function getStreamFps(stream) {
return undefined;
}
-function createRawFfmpeg({ fps = 25, path, inWidth, inHeight, seekTo, oneFrameOnly, execaOpts, streamIndex, outSize = 320 }) {
- // const fps = 25; // TODO
-
- const aspectRatio = inWidth / inHeight;
-
- let newWidth;
- let newHeight;
- if (inWidth > inHeight) {
- newWidth = outSize;
- newHeight = Math.floor(newWidth / aspectRatio);
- } else {
- newHeight = outSize;
- 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', `0:${streamIndex}`,
- '-vcodec', 'rawvideo',
- '-pix_fmt', 'rgba',
-
- ...(oneFrameOnly ? ['-frames:v', '1'] : []),
-
- '-f', 'image2pipe',
- '-',
- ];
-
- // console.log(args);
-
- return {
- process: runFfmpeg(args, execaOpts, { logCli: false }),
- width: newWidth,
- height: newHeight,
- channels: 4,
- };
-}
-
-export function getOneRawFrame({ path, inWidth, inHeight, seekTo, streamIndex, outSize }) {
- const { process, width, height, channels } = createRawFfmpeg({ path, inWidth, inHeight, seekTo, streamIndex, oneFrameOnly: true, execaOpts: { encoding: null }, outSize });
- return { process, width, height, channels };
-}
-
-export function encodeLiveRawStream({ path, inWidth, inHeight, seekTo, streamIndex }) {
- const { process, width, height, channels } = createRawFfmpeg({ path, inWidth, inHeight, seekTo, streamIndex, execaOpts: { encoding: null, buffer: false } });
-
- return {
- process,
- width,
- height,
- channels,
- };
-}
function parseTimecode(str, frameRate) {
// console.log(str, frameRate);
@@ -954,109 +601,6 @@ export async function runFfmpegStartupCheck() {
await runFfmpeg(['-hide_banner', '-f', 'lavfi', '-i', 'nullsrc=s=256x256:d=1', '-f', 'null', '-']);
}
-export async function html5ify({ outPath, filePath: filePathArg, speed, hasAudio, hasVideo, onProgress }) {
- let audio;
- if (hasAudio) {
- if (speed === 'slowest') audio = 'hq';
- else if (['slow-audio', 'fast-audio', 'fastest-audio'].includes(speed)) audio = 'lq';
- else if (['fast-audio-remux', 'fastest-audio-remux'].includes(speed)) audio = 'copy';
- }
-
- let video;
- if (hasVideo) {
- if (speed === 'slowest') video = 'hq';
- else if (['slow-audio', 'slow'].includes(speed)) video = 'lq';
- else video = 'copy';
- }
-
- console.log('Making HTML5 friendly version', { filePathArg, outPath, video, audio });
-
- let videoArgs;
- let audioArgs;
-
- // h264/aac_at: No licensing when using HW encoder (Video/Audio Toolbox on Mac)
- // https://github.com/mifi/lossless-cut/issues/372#issuecomment-810766512
-
- const targetHeight = 400;
-
- switch (video) {
- case 'hq': {
- if (isMac) {
- videoArgs = ['-vf', 'format=yuv420p', '-allow_sw', '1', '-vcodec', 'h264', '-b:v', '15M'];
- } else {
- // AV1 is very slow
- // videoArgs = ['-vf', 'format=yuv420p', '-sws_flags', 'neighbor', '-vcodec', 'libaom-av1', '-crf', '30', '-cpu-used', '8'];
- // Theora is a bit faster but not that much
- // videoArgs = ['-vf', '-c:v', 'libtheora', '-qscale:v', '1'];
- // videoArgs = ['-vf', 'format=yuv420p', '-c:v', 'libvpx-vp9', '-crf', '30', '-b:v', '0', '-row-mt', '1'];
- // x264 can only be used in GPL projects
- videoArgs = ['-vf', 'format=yuv420p', '-c:v', 'libx264', '-profile:v', 'high', '-preset:v', 'slow', '-crf', '17'];
- }
- break;
- }
- case 'lq': {
- if (isMac) {
- videoArgs = ['-vf', `scale=-2:${targetHeight},format=yuv420p`, '-allow_sw', '1', '-sws_flags', 'lanczos', '-vcodec', 'h264', '-b:v', '1500k'];
- } else {
- // videoArgs = ['-vf', `scale=-2:${targetHeight},format=yuv420p`, '-sws_flags', 'neighbor', '-c:v', 'libtheora', '-qscale:v', '1'];
- // x264 can only be used in GPL projects
- videoArgs = ['-vf', `scale=-2:${targetHeight},format=yuv420p`, '-sws_flags', 'neighbor', '-c:v', 'libx264', '-profile:v', 'baseline', '-x264opts', 'level=3.0', '-preset:v', 'ultrafast', '-crf', '28'];
- }
- break;
- }
- case 'copy': {
- videoArgs = ['-vcodec', 'copy'];
- break;
- }
- default: {
- videoArgs = ['-vn'];
- }
- }
-
- switch (audio) {
- case 'hq': {
- if (isMac) {
- audioArgs = ['-acodec', 'aac_at', '-b:a', '192k'];
- } else {
- audioArgs = ['-acodec', 'flac'];
- }
- break;
- }
- case 'lq': {
- if (isMac) {
- audioArgs = ['-acodec', 'aac_at', '-ar', '44100', '-ac', '2', '-b:a', '96k'];
- } else {
- audioArgs = ['-acodec', 'flac', '-ar', '11025', '-ac', '2'];
- }
- break;
- }
- case 'copy': {
- audioArgs = ['-acodec', 'copy'];
- break;
- }
- default: {
- audioArgs = ['-an'];
- }
- }
-
- const ffmpegArgs = [
- '-hide_banner',
-
- '-i', filePathArg,
- ...videoArgs,
- ...audioArgs,
- '-sn',
- '-y', outPath,
- ];
-
- const duration = await getDuration(filePathArg);
- const process = runFfmpeg(ffmpegArgs);
- if (duration) handleProgress(process, duration, onProgress);
-
- const { stdout } = await process;
- console.log(stdout);
-}
-
// https://superuser.com/questions/543589/information-about-ffmpeg-command-line-options
export const getExperimentalArgs = (ffmpegExperimental) => (ffmpegExperimental ? ['-strict', 'experimental'] : []);
diff --git a/src/hooks/useFfmpegOperations.js b/src/hooks/useFfmpegOperations.js
index bd7af6d4..bb1e3ca6 100644
--- a/src/hooks/useFfmpegOperations.js
+++ b/src/hooks/useFfmpegOperations.js
@@ -4,14 +4,13 @@ import sum from 'lodash/sum';
import pMap from 'p-map';
import { getSuffixedOutPath, transferTimestamps, getOutFileExtension, getOutDir, deleteDispositionValue, getHtml5ifiedPath } from '../util';
-import { isCuttingStart, isCuttingEnd, handleProgress, getFfCommandLine, getDuration, runFfmpeg, createChaptersFromSegments, readFileMeta, cutEncodeSmartPart, getExperimentalArgs, html5ify as ffmpegHtml5ify, getVideoTimescaleArgs, RefuseOverwriteError, logStdoutStderr } from '../ffmpeg';
+import { isCuttingStart, isCuttingEnd, runFfmpegWithProgress, getFfCommandLine, getDuration, createChaptersFromSegments, readFileMeta, cutEncodeSmartPart, getExperimentalArgs, html5ify as ffmpegHtml5ify, getVideoTimescaleArgs, RefuseOverwriteError, logStdoutStderr, runFfmpegConcat } from '../ffmpeg';
import { getMapStreamsArgs, getStreamIdsToCopy } from '../util/streams';
import { getSmartCutParams } from '../smartcut';
const { join, resolve, dirname } = window.require('path');
const { pathExists } = window.require('fs-extra');
const { writeFile, unlink, mkdir } = window.require('fs/promises');
-const stringToStream = window.require('string-to-stream');
async function writeChaptersFfmetadata(outDir, chapters) {
if (!chapters || chapters.length === 0) return undefined;
@@ -155,13 +154,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps, needSmartCut
console.log(fullCommandLine);
appendFfmpegCommandLog(fullCommandLine);
- const process = runFfmpeg(ffmpegArgs);
-
- handleProgress(process, totalDuration, onProgress);
-
- stringToStream(concatTxt).pipe(process.stdin);
-
- const result = await process;
+ const result = await runFfmpegConcat({ ffmpegArgs, concatTxt, totalDuration, onProgress });
logStdoutStderr(result);
await optionalTransferTimestamps(metadataFromPath, outPath);
@@ -313,9 +306,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps, needSmartCut
// console.log(ffmpegCommandLine);
appendFfmpegCommandLog(ffmpegCommandLine);
- const process = runFfmpeg(ffmpegArgs);
- handleProgress(process, cutDuration, onProgress);
- const result = await process;
+ const result = await runFfmpegWithProgress({ ffmpegArgs, duration: cutDuration, onProgress });
logStdoutStderr(result);
await optionalTransferTimestamps(filePath, outPath, cutFrom);
@@ -487,10 +478,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps, needSmartCut
'-y', outPath,
];
- const process = runFfmpeg(ffmpegArgs);
- handleProgress(process, duration, onProgress);
-
- const result = await process;
+ const result = await runFfmpegWithProgress({ ffmpegArgs, duration, onProgress });
logStdoutStderr(result);
await optionalTransferTimestamps(filePathArg, outPath);
@@ -515,10 +503,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps, needSmartCut
'-y', outPath,
];
- const process = runFfmpeg(ffmpegArgs);
- handleProgress(process, duration, onProgress);
-
- const result = await process;
+ const result = await runFfmpegWithProgress({ ffmpegArgs, duration, onProgress });
logStdoutStderr(result);
await optionalTransferTimestamps(filePath, outPath);
diff --git a/src/hooks/useWaveform.js b/src/hooks/useWaveform.js
index 6cb4018b..a446c81f 100644
--- a/src/hooks/useWaveform.js
+++ b/src/hooks/useWaveform.js
@@ -35,14 +35,18 @@ export default ({ darkMode, filePath, relevantTime, durationSafe, waveformEnable
const safeExtractDuration = Math.min(waveformStartTime + ffmpegExtractWindow, durationSafe) - waveformStartTime;
const promise = renderWaveformPng({ filePath, start: waveformStartTime, duration: safeExtractDuration, color: waveformColor });
creatingWaveformPromise.current = promise;
- const newWaveform = await promise;
+ const { buffer, ...newWaveform } = await promise;
if (aborted) return;
+
setWaveforms((currentWaveforms) => {
const waveformsByCreatedAt = sortBy(currentWaveforms, 'createdAt');
return [
// cleanup old
...(currentWaveforms.length >= maxWaveforms ? waveformsByCreatedAt.slice(1) : waveformsByCreatedAt),
- newWaveform,
+ {
+ ...newWaveform,
+ url: URL.createObjectURL(new Blob([buffer], { type: 'image/png' })),
+ },
];
});
} catch (err) {
diff --git a/yarn.lock b/yarn.lock
index 6d5e2288..c376ace5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4096,20 +4096,20 @@ __metadata:
languageName: node
linkType: hard
-"execa@npm:^5.0.0":
- version: 5.1.1
- resolution: "execa@npm:5.1.1"
+"execa@npm:^7.1.1":
+ version: 7.1.1
+ resolution: "execa@npm:7.1.1"
dependencies:
cross-spawn: ^7.0.3
- get-stream: ^6.0.0
- human-signals: ^2.1.0
- is-stream: ^2.0.0
+ get-stream: ^6.0.1
+ human-signals: ^4.3.0
+ is-stream: ^3.0.0
merge-stream: ^2.0.0
- npm-run-path: ^4.0.1
- onetime: ^5.1.2
- signal-exit: ^3.0.3
- strip-final-newline: ^2.0.0
- checksum: fba9022c8c8c15ed862847e94c252b3d946036d7547af310e344a527e59021fd8b6bb0723883ea87044dc4f0201f949046993124a42ccb0855cae5bf8c786343
+ npm-run-path: ^5.1.0
+ onetime: ^6.0.0
+ signal-exit: ^3.0.7
+ strip-final-newline: ^3.0.0
+ checksum: 21fa46fc69314ace4068cf820142bdde5b643a5d89831c2c9349479c1555bff137a291b8e749e7efca36535e4e0a8c772c11008ca2e84d2cbd6ca141a3c8f937
languageName: node
linkType: hard
@@ -4677,7 +4677,7 @@ __metadata:
languageName: node
linkType: hard
-"get-stream@npm:^6.0.0":
+"get-stream@npm:^6.0.1":
version: 6.0.1
resolution: "get-stream@npm:6.0.1"
checksum: e04ecece32c92eebf5b8c940f51468cd53554dcbb0ea725b2748be583c9523d00128137966afce410b9b051eb2ef16d657cd2b120ca8edafcf5a65e81af63cad
@@ -5084,10 +5084,10 @@ __metadata:
languageName: node
linkType: hard
-"human-signals@npm:^2.1.0":
- version: 2.1.0
- resolution: "human-signals@npm:2.1.0"
- checksum: b87fd89fce72391625271454e70f67fe405277415b48bcc0117ca73d31fa23a4241787afdc8d67f5a116cf37258c052f59ea82daffa72364d61351423848e3b8
+"human-signals@npm:^4.3.0":
+ version: 4.3.1
+ resolution: "human-signals@npm:4.3.1"
+ checksum: 6f12958df3f21b6fdaf02d90896c271df00636a31e2bbea05bddf817a35c66b38a6fdac5863e2df85bd52f34958997f1f50350ff97249e1dff8452865d5235d1
languageName: node
linkType: hard
@@ -5630,6 +5630,13 @@ __metadata:
languageName: node
linkType: hard
+"is-stream@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "is-stream@npm:3.0.0"
+ checksum: 172093fe99119ffd07611ab6d1bcccfe8bc4aa80d864b15f43e63e54b7abc71e779acd69afdb854c4e2a67fdc16ae710e370eda40088d1cfc956a50ed82d8f16
+ languageName: node
+ linkType: hard
+
"is-string@npm:^1.0.5, is-string@npm:^1.0.7":
version: 1.0.7
resolution: "is-string@npm:1.0.7"
@@ -6203,7 +6210,7 @@ __metadata:
eslint-plugin-react: ^7.28.0
eslint-plugin-react-hooks: ^4.3.0
evergreen-ui: ^6.13.1
- execa: ^5.0.0
+ execa: ^7.1.1
fast-xml-parser: ^4.0.3
file-type: 16
file-url: ^3.0.0
@@ -6217,9 +6224,9 @@ __metadata:
json5: ^2.2.2
ky: ^0.33.1
lodash: ^4.17.19
+ luxon: ^3.3.0
mime-types: ^2.1.14
mkdirp: ^1.0.3
- moment: ^2.29.4
mousetrap: ^1.6.5
p-map: ^5.5.0
patch-package: ^6.2.1
@@ -6311,6 +6318,13 @@ __metadata:
languageName: node
linkType: hard
+"luxon@npm:^3.3.0":
+ version: 3.3.0
+ resolution: "luxon@npm:3.3.0"
+ checksum: 50cf17a0dc155c3dcacbeae8c0b7e80db425e0ba97b9cbdf12a7fc142d841ff1ab1560919f033af46240ed44e2f70c49f76e3422524c7fc8bb8d81ca47c66187
+ languageName: node
+ linkType: hard
+
"magic-string@npm:^0.27.0":
version: 0.27.0
resolution: "magic-string@npm:0.27.0"
@@ -6435,6 +6449,13 @@ __metadata:
languageName: node
linkType: hard
+"mimic-fn@npm:^4.0.0":
+ version: 4.0.0
+ resolution: "mimic-fn@npm:4.0.0"
+ checksum: 995dcece15ee29aa16e188de6633d43a3db4611bcf93620e7e62109ec41c79c0f34277165b8ce5e361205049766e371851264c21ac64ca35499acb5421c2ba56
+ languageName: node
+ linkType: hard
+
"mimic-response@npm:^1.0.0":
version: 1.0.1
resolution: "mimic-response@npm:1.0.1"
@@ -6622,13 +6643,6 @@ __metadata:
languageName: node
linkType: hard
-"moment@npm:^2.29.4":
- version: 2.29.4
- resolution: "moment@npm:2.29.4"
- checksum: 0ec3f9c2bcba38dc2451b1daed5daded747f17610b92427bebe1d08d48d8b7bdd8d9197500b072d14e326dd0ccf3e326b9e3d07c5895d3d49e39b6803b76e80e
- languageName: node
- linkType: hard
-
"mousetrap@npm:^1.6.5":
version: 1.6.5
resolution: "mousetrap@npm:1.6.5"
@@ -6834,12 +6848,12 @@ __metadata:
languageName: node
linkType: hard
-"npm-run-path@npm:^4.0.1":
- version: 4.0.1
- resolution: "npm-run-path@npm:4.0.1"
+"npm-run-path@npm:^5.1.0":
+ version: 5.1.0
+ resolution: "npm-run-path@npm:5.1.0"
dependencies:
- path-key: ^3.0.0
- checksum: 5374c0cea4b0bbfdfae62da7bbdf1e1558d338335f4cacf2515c282ff358ff27b2ecb91ffa5330a8b14390ac66a1e146e10700440c1ab868208430f56b5f4d23
+ path-key: ^4.0.0
+ checksum: dc184eb5ec239d6a2b990b43236845332ef12f4e0beaa9701de724aa797fe40b6bbd0157fb7639d24d3ab13f5d5cf22d223a19c6300846b8126f335f788bee66
languageName: node
linkType: hard
@@ -6977,7 +6991,7 @@ __metadata:
languageName: node
linkType: hard
-"onetime@npm:^5.1.0, onetime@npm:^5.1.2":
+"onetime@npm:^5.1.0":
version: 5.1.2
resolution: "onetime@npm:5.1.2"
dependencies:
@@ -6986,6 +7000,15 @@ __metadata:
languageName: node
linkType: hard
+"onetime@npm:^6.0.0":
+ version: 6.0.0
+ resolution: "onetime@npm:6.0.0"
+ dependencies:
+ mimic-fn: ^4.0.0
+ checksum: 0846ce78e440841335d4e9182ef69d5762e9f38aa7499b19f42ea1c4cd40f0b4446094c455c713f9adac3f4ae86f613bb5e30c99e52652764d06a89f709b3788
+ languageName: node
+ linkType: hard
+
"open@npm:^7.4.2":
version: 7.4.2
resolution: "open@npm:7.4.2"
@@ -7238,13 +7261,20 @@ __metadata:
languageName: node
linkType: hard
-"path-key@npm:^3.0.0, path-key@npm:^3.1.0":
+"path-key@npm:^3.1.0":
version: 3.1.1
resolution: "path-key@npm:3.1.1"
checksum: 55cd7a9dd4b343412a8386a743f9c746ef196e57c823d90ca3ab917f90ab9f13dd0ded27252ba49dbdfcab2b091d998bc446f6220cd3cea65db407502a740020
languageName: node
linkType: hard
+"path-key@npm:^4.0.0":
+ version: 4.0.0
+ resolution: "path-key@npm:4.0.0"
+ checksum: 8e6c314ae6d16b83e93032c61020129f6f4484590a777eed709c4a01b50e498822b00f76ceaf94bc64dbd90b327df56ceadce27da3d83393790f1219e07721d7
+ languageName: node
+ linkType: hard
+
"path-parse@npm:^1.0.6, path-parse@npm:^1.0.7":
version: 1.0.7
resolution: "path-parse@npm:1.0.7"
@@ -8383,7 +8413,7 @@ __metadata:
languageName: node
linkType: hard
-"signal-exit@npm:^3.0.0, signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3":
+"signal-exit@npm:^3.0.0, signal-exit@npm:^3.0.2":
version: 3.0.6
resolution: "signal-exit@npm:3.0.6"
checksum: b819ac81ba757af559dad0804233ae31bf6f054591cd8a671e9cbcf09f21c72ec3076fe87d1e04861f5b33b47d63f0694b568de99c99cd733ee2060515beb6d5
@@ -8841,10 +8871,10 @@ __metadata:
languageName: node
linkType: hard
-"strip-final-newline@npm:^2.0.0":
- version: 2.0.0
- resolution: "strip-final-newline@npm:2.0.0"
- checksum: 69412b5e25731e1938184b5d489c32e340605bb611d6140344abc3421b7f3c6f9984b21dff296dfcf056681b82caa3bb4cc996a965ce37bcfad663e92eae9c64
+"strip-final-newline@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "strip-final-newline@npm:3.0.0"
+ checksum: 23ee263adfa2070cd0f23d1ac14e2ed2f000c9b44229aec9c799f1367ec001478469560abefd00c5c99ee6f0b31c137d53ec6029c53e9f32a93804e18c201050
languageName: node
linkType: hard