mirror of
https://github.com/mifi/lossless-cut.git
synced 2024-11-22 02:12:30 +01:00
Build with create-react-app #108
This commit is contained in:
parent
173e1bab94
commit
676f92c803
@ -1,4 +1,2 @@
|
||||
dist
|
||||
package
|
||||
build
|
||||
watch-build
|
||||
/dist
|
||||
/build
|
||||
|
38
package.json
38
package.json
@ -1,26 +1,25 @@
|
||||
{
|
||||
"name": "lossless-cut",
|
||||
"productName": "LosslessCut",
|
||||
"description": "Lossless video editor",
|
||||
"copyright": "Copyright © 2019 ${author}",
|
||||
"description": "The swiss army knife of lossless video/audio editing",
|
||||
"copyright": "Copyright © 2020 ${author}",
|
||||
"version": "3.13.0",
|
||||
"main": "build/index.js",
|
||||
"main": "public/electron.js",
|
||||
"homepage": "./",
|
||||
"scripts": {
|
||||
"start": "electron watch-build",
|
||||
"watch": "babel src -d watch-build --copy-files -w",
|
||||
"start": "concurrently -k \"BROWSER=none PORT=3001 react-scripts start\" \"wait-on http://localhost:3001 && electron .\"",
|
||||
"icon-gen": "mkdir -p icon-build && svg2png src/icon.svg -o ./icon-build/app-512.png -w 512 -h 512",
|
||||
"build": "yarn icon-gen && rm -rf build && babel src -d build --copy-files",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject",
|
||||
"lint": "eslint --ext .jsx --ext .js .",
|
||||
"pack-mac": "electron-builder --mac",
|
||||
"prepack-mac": "yarn build",
|
||||
"pack-win": "electron-builder --win",
|
||||
"prepack-win": "yarn build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"pack-linux": "electron-builder --linux",
|
||||
"prepack-linux": "yarn build",
|
||||
"release": "electron-builder",
|
||||
"prerelease": "yarn build",
|
||||
"gifify": "gifify -p 405:299 -r 5@3 Untitled.mov-00.00.00.971-00.00.19.780.mp4",
|
||||
"lint": "eslint --ext .jsx --ext .js ."
|
||||
"prepack-linux": "yarn build"
|
||||
},
|
||||
"author": {
|
||||
"name": "Mikael Finstad",
|
||||
@ -33,12 +32,7 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"babel-cli": "^6.26.0",
|
||||
"babel-eslint": "^10.0.3",
|
||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
||||
"babel-plugin-transform-object-rest-spread": "^6.26.0",
|
||||
"babel-preset-env": "^1.7.0",
|
||||
"babel-preset-react": "^6.24.1",
|
||||
"concurrently": "^5.1.0",
|
||||
"electron": "^8.0.0",
|
||||
"electron-builder": "^22.3.2",
|
||||
"electron-builder-notarize": "^1.1.2",
|
||||
@ -49,8 +43,10 @@
|
||||
"eslint-plugin-jsx-a11y": "^6.2.3",
|
||||
"eslint-plugin-react": "^7.14.3",
|
||||
"eslint-plugin-react-hooks": "^1.7.0",
|
||||
"react-scripts": "^3.4.0",
|
||||
"svg2png": "^4.1.1",
|
||||
"use-trace-update": "^1.3.0"
|
||||
"use-trace-update": "^1.3.0",
|
||||
"wait-on": "^4.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.19.2",
|
||||
@ -65,6 +61,7 @@
|
||||
"ffmpeg-static": "^4.0.1",
|
||||
"ffprobe-static": "^3.0.0",
|
||||
"file-type": "^12.4.0",
|
||||
"file-url": "^3.0.0",
|
||||
"framer-motion": "^1.8.4",
|
||||
"fs-extra": "^8.1.0",
|
||||
"github-api": "^3.2.2",
|
||||
@ -93,6 +90,9 @@
|
||||
"uuid": "^3.3.2",
|
||||
"which": "^1.2.11"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
"electron 8.0"
|
||||
|
@ -1,5 +1,6 @@
|
||||
const electron = require('electron'); // eslint-disable-line
|
||||
const isDev = require('electron-is-dev');
|
||||
const path = require('path');
|
||||
|
||||
const menu = require('./menu');
|
||||
|
||||
@ -10,8 +11,6 @@ const { BrowserWindow } = electron;
|
||||
|
||||
app.name = 'LosslessCut';
|
||||
|
||||
if (!isDev) process.env.NODE_ENV = 'production';
|
||||
|
||||
// Keep a global reference of the window object, if you don't, the window will
|
||||
// be closed automatically when the JavaScript object is garbage collected.
|
||||
let mainWindow;
|
||||
@ -23,9 +22,12 @@ function createWindow() {
|
||||
darkTheme: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
// https://github.com/electron/electron/issues/5107
|
||||
webSecurity: !isDev,
|
||||
},
|
||||
});
|
||||
mainWindow.loadFile(isDev ? 'index.html' : 'build/index.html');
|
||||
|
||||
mainWindow.loadURL(isDev ? 'http://localhost:3001' : `file://${path.join(__dirname, '../build/index.html')}`);
|
||||
|
||||
if (isDev) {
|
||||
const { default: installExtension, REACT_DEVELOPER_TOOLS } = require('electron-devtools-installer'); // eslint-disable-line global-require,import/no-extraneous-dependencies
|
||||
@ -38,7 +40,7 @@ function createWindow() {
|
||||
// Open the DevTools.
|
||||
// mainWindow.webContents.openDevTools()
|
||||
|
||||
|
||||
// Emitted when the window is closed.
|
||||
mainWindow.on('closed', () => {
|
||||
// Dereference the window object, usually you would store windows
|
||||
// in an array if your app supports multi windows, this is the time
|
10
public/index.html
Normal file
10
public/index.html
Normal file
@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>LosslessCut</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
@ -1,4 +1,5 @@
|
||||
const GitHub = require('github-api');
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
const electron = require('electron');
|
||||
|
||||
const { app } = electron;
|
@ -6,6 +6,10 @@ 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';
|
||||
|
||||
import fromPairs from 'lodash/fromPairs';
|
||||
import clamp from 'lodash/clamp';
|
||||
@ -26,37 +30,30 @@ import RightMenu from './RightMenu';
|
||||
import TimelineControls from './TimelineControls';
|
||||
import { loadMifiLink } from './mifi';
|
||||
import { primaryColor, controlsBackground, waveformColor } from './colors';
|
||||
import { showMergeDialog, showOpenAndMergeDialog } from './merge/merge';
|
||||
import allOutFormats from './outFormats';
|
||||
import captureFrame from './capture-frame';
|
||||
import {
|
||||
defaultProcessedCodecTypes, getStreamFps, isCuttingStart, isCuttingEnd,
|
||||
getDefaultOutFormat, getFormatData, renderFrame, mergeAnyFiles, renderThumbnails as ffmpegRenderThumbnails,
|
||||
readFrames, renderWaveformPng, html5ifyDummy, cutMultiple, extractStreams, autoMergeSegments, getAllStreams,
|
||||
findNearestKeyFrameTime, html5ify as ffmpegHtml5ify,
|
||||
} from './ffmpeg';
|
||||
import configStore from './store';
|
||||
import { save as edlStoreSave, load as edlStoreLoad } from './edlStore';
|
||||
import {
|
||||
getOutPath, formatDuration, toast, errorToast, showFfmpegFail, setFileNameTitle,
|
||||
promptTimeOffset, generateColor, getOutDir, withBlur,
|
||||
} from './util';
|
||||
|
||||
|
||||
import loadingLottie from './7077-magic-flow.json';
|
||||
|
||||
|
||||
const isDev = require('electron-is-dev');
|
||||
const electron = require('electron'); // eslint-disable-line
|
||||
const Mousetrap = require('mousetrap');
|
||||
const trash = require('trash');
|
||||
const uuid = require('uuid');
|
||||
|
||||
const ReactDOM = require('react-dom');
|
||||
const { default: PQueue } = require('p-queue');
|
||||
const { unlink, exists } = require('fs-extra');
|
||||
|
||||
|
||||
const { showMergeDialog, showOpenAndMergeDialog } = require('./merge/merge');
|
||||
const allOutFormats = require('./outFormats');
|
||||
const captureFrame = require('./capture-frame');
|
||||
const ffmpeg = require('./ffmpeg');
|
||||
const configStore = require('./store');
|
||||
const edlStore = require('./edlStore');
|
||||
|
||||
const {
|
||||
defaultProcessedCodecTypes, getStreamFps, isCuttingStart, isCuttingEnd,
|
||||
getDefaultOutFormat, getFormatData,
|
||||
} = ffmpeg;
|
||||
|
||||
const {
|
||||
getOutPath, formatDuration, toast, errorToast, showFfmpegFail, setFileNameTitle,
|
||||
promptTimeOffset, generateColor, getOutDir, withBlur,
|
||||
} = require('./util');
|
||||
// const isDev = window.require('electron-is-dev');
|
||||
const electron = window.require('electron'); // eslint-disable-line
|
||||
const trash = window.require('trash');
|
||||
const { unlink, exists } = window.require('fs-extra');
|
||||
|
||||
|
||||
const { dialog } = electron.remote;
|
||||
@ -528,7 +525,9 @@ const App = memo(() => {
|
||||
setCustomOutDir((filePaths && filePaths.length === 1) ? filePaths[0] : undefined);
|
||||
}, []);
|
||||
|
||||
const fileUri = (dummyVideoPath || html5FriendlyPath || filePath || '').replace(/#/g, '%23');
|
||||
const effectiveFilePath = dummyVideoPath || html5FriendlyPath || filePath;
|
||||
const fileUri = effectiveFilePath ? filePathToUrl(effectiveFilePath) : '';
|
||||
|
||||
|
||||
const outputDir = getOutDir(customOutDir, filePath);
|
||||
|
||||
@ -553,7 +552,7 @@ const App = memo(() => {
|
||||
return;
|
||||
} */
|
||||
|
||||
await edlStore.save(edlFilePath, debouncedCutSegments);
|
||||
await edlStoreSave(edlFilePath, debouncedCutSegments);
|
||||
lastSavedCutSegmentsRef.current = debouncedCutSegments;
|
||||
} catch (err) {
|
||||
errorToast('Failed to save CSV');
|
||||
@ -576,7 +575,7 @@ const App = memo(() => {
|
||||
if (playerTime == null || !filePath) return;
|
||||
|
||||
try {
|
||||
const framePathNew = await ffmpeg.renderFrame(playerTime, filePath, effectiveRotation);
|
||||
const framePathNew = await renderFrame(playerTime, filePath, effectiveRotation);
|
||||
setFramePath(framePathNew);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@ -624,7 +623,7 @@ const App = memo(() => {
|
||||
setWorking(true);
|
||||
|
||||
// console.log('merge', paths);
|
||||
await ffmpeg.mergeAnyFiles({
|
||||
await mergeAnyFiles({
|
||||
customOutDir, paths, allStreams,
|
||||
});
|
||||
} catch (err) {
|
||||
@ -701,7 +700,7 @@ const App = memo(() => {
|
||||
|
||||
try {
|
||||
setThumbnails([]);
|
||||
const promise = ffmpeg.renderThumbnails({ filePath, from: zoomWindowStartTime, duration: zoomedDuration, onThumbnail: addThumbnail });
|
||||
const promise = ffmpegRenderThumbnails({ filePath, from: zoomWindowStartTime, duration: zoomedDuration, onThumbnail: addThumbnail });
|
||||
thumnailsRenderingPromiseRef.current = promise;
|
||||
await promise;
|
||||
} catch (err) {
|
||||
@ -729,7 +728,7 @@ const App = memo(() => {
|
||||
if (!d || !d.keyframesEnabled || !d.filePath || !d.mainVideoStream || d.commandedTime == null || readingKeyframesPromise.current) return;
|
||||
|
||||
try {
|
||||
const promise = ffmpeg.readFrames({ filePath: d.filePath, aroundTime: d.commandedTime, stream: d.mainVideoStream.index, window: ffmpegExtractWindow });
|
||||
const promise = readFrames({ filePath: d.filePath, aroundTime: d.commandedTime, stream: d.mainVideoStream.index, window: ffmpegExtractWindow });
|
||||
readingKeyframesPromise.current = promise;
|
||||
const newFrames = await promise;
|
||||
// console.log(newFrames);
|
||||
@ -748,7 +747,7 @@ const App = memo(() => {
|
||||
const d = debouncedWaveformData;
|
||||
if (!d || !d.filePath || !d.mainAudioStream || d.commandedTime == null || !calcShouldShowWaveform(d.zoomedDuration) || !d.waveformEnabled || creatingWaveformPromise.current) return;
|
||||
try {
|
||||
const promise = ffmpeg.renderWaveformPng({ filePath: d.filePath, aroundTime: d.commandedTime, window: ffmpegExtractWindow, color: waveformColor });
|
||||
const promise = renderWaveformPng({ filePath: d.filePath, aroundTime: d.commandedTime, window: ffmpegExtractWindow, color: waveformColor });
|
||||
creatingWaveformPromise.current = promise;
|
||||
const wf = await promise;
|
||||
setWaveform(wf);
|
||||
@ -771,7 +770,7 @@ const App = memo(() => {
|
||||
|
||||
const createDummyVideo = useCallback(async (fp) => {
|
||||
const html5ifiedDummyPathDummy = getOutPath(customOutDir, fp, 'html5ified-dummy.mkv');
|
||||
await ffmpeg.html5ifyDummy(fp, html5ifiedDummyPathDummy);
|
||||
await html5ifyDummy(fp, html5ifiedDummyPathDummy);
|
||||
setDummyVideoPath(html5ifiedDummyPathDummy);
|
||||
setHtml5FriendlyPath();
|
||||
showUnsupportedFileMessage();
|
||||
@ -858,7 +857,7 @@ const App = memo(() => {
|
||||
try {
|
||||
setWorking(true);
|
||||
|
||||
const outFiles = await ffmpeg.cutMultiple({
|
||||
const outFiles = await cutMultiple({
|
||||
customOutDir,
|
||||
filePath,
|
||||
outFormat: fileFormat,
|
||||
@ -876,7 +875,7 @@ const App = memo(() => {
|
||||
if (outFiles.length > 1 && autoMerge) {
|
||||
setCutProgress(0); // TODO implement progress
|
||||
|
||||
await ffmpeg.autoMergeSegments({
|
||||
await autoMergeSegments({
|
||||
customOutDir,
|
||||
sourceFile: filePath,
|
||||
segmentPaths: outFiles,
|
||||
@ -885,7 +884,7 @@ const App = memo(() => {
|
||||
|
||||
if (exportExtraStreams) {
|
||||
try {
|
||||
await ffmpeg.extractStreams({
|
||||
await extractStreams({
|
||||
filePath, customOutDir, streams: nonCopiedExtraStreams,
|
||||
});
|
||||
} catch (err) {
|
||||
@ -954,7 +953,7 @@ const App = memo(() => {
|
||||
|
||||
const loadEdlFile = useCallback(async (edlPath) => {
|
||||
try {
|
||||
const storedEdl = await edlStore.load(edlPath);
|
||||
const storedEdl = await edlStoreLoad(edlPath);
|
||||
const allRowsValid = storedEdl
|
||||
.every(row => row.start === undefined || row.end === undefined || row.start < row.end);
|
||||
|
||||
@ -992,7 +991,7 @@ const App = memo(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { streams } = await ffmpeg.getAllStreams(fp);
|
||||
const { streams } = await getAllStreams(fp);
|
||||
// console.log('streams', streamsNew);
|
||||
setMainStreams(streams);
|
||||
setCopyStreamIdsForPath(fp, () => fromPairs(streams.map((stream) => [
|
||||
@ -1046,7 +1045,7 @@ const App = memo(() => {
|
||||
const jumpSeg = useCallback((val) => setCurrentSegIndex((old) => Math.max(Math.min(old + val, cutSegments.length - 1), 0)), [cutSegments.length]);
|
||||
|
||||
const seekClosestKeyframe = useCallback((direction) => {
|
||||
const time = ffmpeg.findNearestKeyFrameTime({ frames: neighbouringFrames, time: commandedTime, direction, fps: detectedFps });
|
||||
const time = findNearestKeyFrameTime({ frames: neighbouringFrames, time: commandedTime, direction, fps: detectedFps });
|
||||
if (time == null) return;
|
||||
seekAbs(time);
|
||||
}, [commandedTime, neighbouringFrames, seekAbs, detectedFps]);
|
||||
@ -1121,7 +1120,7 @@ const App = memo(() => {
|
||||
|
||||
try {
|
||||
setWorking(true);
|
||||
await ffmpeg.extractStreams({ customOutDir, filePath, streams: mainStreams });
|
||||
await extractStreams({ customOutDir, filePath, streams: mainStreams });
|
||||
toast.fire({ icon: 'success', title: `All streams can be found as separate files at: ${outputDir}` });
|
||||
} catch (err) {
|
||||
errorToast('Failed to extract all streams');
|
||||
@ -1137,7 +1136,7 @@ const App = memo(() => {
|
||||
|
||||
const addStreamSourceFile = useCallback(async (path) => {
|
||||
if (externalStreamFiles[path]) return;
|
||||
const { streams } = await ffmpeg.getAllStreams(path);
|
||||
const { streams } = await getAllStreams(path);
|
||||
const formatData = await getFormatData(path);
|
||||
// console.log('streams', streams);
|
||||
setExternalStreamFiles(old => ({ ...old, [path]: { streams, formatData } }));
|
||||
@ -1212,7 +1211,7 @@ const App = memo(() => {
|
||||
const html5FriendlyPathNew = getHtml5ifiedPath(filePath, speed);
|
||||
const encodeVideo = ['slow', 'slow-audio'].includes(speed);
|
||||
const encodeAudio = speed === 'slow-audio';
|
||||
await ffmpeg.html5ify(filePath, html5FriendlyPathNew, encodeVideo, encodeAudio);
|
||||
await ffmpegHtml5ify(filePath, html5FriendlyPathNew, encodeVideo, encodeAudio);
|
||||
load(filePath, html5FriendlyPathNew);
|
||||
} else {
|
||||
await createDummyVideo(filePath);
|
||||
@ -1259,7 +1258,7 @@ const App = memo(() => {
|
||||
errorToast('File exists, bailing');
|
||||
return;
|
||||
}
|
||||
await edlStore.save(fp, cutSegments);
|
||||
await edlStoreSave(fp, cutSegments);
|
||||
} catch (err) {
|
||||
errorToast('Failed to export CSV');
|
||||
console.error('Failed to export CSV', err);
|
||||
@ -1723,6 +1722,4 @@ const App = memo(() => {
|
||||
);
|
||||
});
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('app'));
|
||||
|
||||
console.log('Version', electron.remote.app.getVersion());
|
||||
export default App;
|
@ -3,10 +3,10 @@ import { IoIosCloseCircleOutline } from 'react-icons/io';
|
||||
import { FaClipboard } from 'react-icons/fa';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
const { clipboard } = require('electron');
|
||||
import { toast } from './util';
|
||||
|
||||
const { clipboard } = window.require('electron');
|
||||
|
||||
const { toast } = require('./util');
|
||||
|
||||
const HelpSheet = memo(({
|
||||
visible, onTogglePress, ffmpegCommandLog,
|
||||
|
@ -3,7 +3,7 @@ import { Select } from 'evergreen-ui';
|
||||
import { motion } from 'framer-motion';
|
||||
import { FaYinYang } from 'react-icons/fa';
|
||||
|
||||
const { withBlur, toast } = require('./util');
|
||||
import { withBlur, toast } from './util';
|
||||
|
||||
|
||||
const LeftMenu = memo(({ zoom, setZoom, invertCutSegments, setInvertCutSegments }) => {
|
||||
|
@ -5,13 +5,13 @@ import { GoFileBinary } from 'react-icons/go';
|
||||
import { MdSubtitles } from 'react-icons/md';
|
||||
import Swal from 'sweetalert2';
|
||||
import { SegmentedControl } from 'evergreen-ui';
|
||||
|
||||
import withReactContent from 'sweetalert2-react-content';
|
||||
|
||||
import { formatDuration } from './util';
|
||||
import { getStreamFps } from './ffmpeg';
|
||||
|
||||
const ReactSwal = withReactContent(Swal);
|
||||
|
||||
const { formatDuration } = require('./util');
|
||||
const { getStreamFps } = require('./ffmpeg');
|
||||
|
||||
function onInfoClick(s, title) {
|
||||
ReactSwal.fire({
|
||||
|
@ -140,7 +140,7 @@ const Timeline = memo(({
|
||||
const nextThumbTime = nextThumbnail ? nextThumbnail.time : durationSafe;
|
||||
const maxWidthPercent = ((nextThumbTime - thumbnail.time) / durationSafe) * 100 * 0.9;
|
||||
return (
|
||||
<img key={thumbnail.url} src={thumbnail.url} alt="" style={{ position: 'absolute', left: `${leftPercent}%`, height: timelineHeight * 1.5, zIndex: 1, maxWidth: `${maxWidthPercent}%`, objectFit: 'cover', border: '1px solid rgba(255, 255, 255, 0.5)', borderBottomRightRadius: 15, borderTopLeftRadius: 15, borderTopRightRadius: 15 }} />
|
||||
<img key={thumbnail.url} src={thumbnail.url} alt="" style={{ position: 'absolute', left: `${leftPercent}%`, height: timelineHeight * 1.5, zIndex: 1, maxWidth: `${maxWidthPercent}%`, objectFit: 'cover', border: '1px solid rgba(255, 255, 255, 0.5)', borderBottomRightRadius: 15, borderTopLeftRadius: 15, borderTopRightRadius: 15, pointerEvents: 'none' }} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
@ -4,7 +4,7 @@ import { FaTrashAlt } from 'react-icons/fa';
|
||||
|
||||
import { mySpring } from './animations';
|
||||
|
||||
const { formatDuration } = require('./util');
|
||||
import { formatDuration } from './util';
|
||||
|
||||
|
||||
const TimelineSeg = memo(({
|
||||
|
@ -1,8 +1,9 @@
|
||||
const fs = require('fs-extra');
|
||||
const mime = require('mime-types');
|
||||
const strongDataUri = require('strong-data-uri');
|
||||
import strongDataUri from 'strong-data-uri';
|
||||
|
||||
const { formatDuration, getOutPath, transferTimestampsWithOffset } = require('./util');
|
||||
import { formatDuration, getOutPath, transferTimestampsWithOffset } from './util';
|
||||
|
||||
const fs = window.require('fs-extra');
|
||||
const mime = window.require('mime-types');
|
||||
|
||||
function getFrameFromVideo(video, format) {
|
||||
const canvas = document.createElement('canvas');
|
||||
@ -16,7 +17,7 @@ function getFrameFromVideo(video, format) {
|
||||
return strongDataUri.decode(dataUri);
|
||||
}
|
||||
|
||||
async function captureFrame(customOutDir, filePath, video, currentTime, captureFormat) {
|
||||
export default async function captureFrame(customOutDir, filePath, video, currentTime, captureFormat) {
|
||||
const buf = getFrameFromVideo(video, captureFormat);
|
||||
|
||||
const ext = mime.extension(buf.mimetype);
|
||||
@ -27,5 +28,3 @@ async function captureFrame(customOutDir, filePath, video, currentTime, captureF
|
||||
const offset = -video.duration + currentTime;
|
||||
return transferTimestampsWithOffset(filePath, outPath, offset);
|
||||
}
|
||||
|
||||
module.exports = captureFrame;
|
||||
|
@ -1,12 +1,13 @@
|
||||
const fs = require('fs-extra');
|
||||
const parse = require('csv-parse');
|
||||
const stringify = require('csv-stringify');
|
||||
const { promisify } = require('util');
|
||||
import parse from 'csv-parse';
|
||||
import stringify from 'csv-stringify';
|
||||
|
||||
const fs = window.require('fs-extra');
|
||||
const { promisify } = window.require('util');
|
||||
|
||||
const stringifyAsync = promisify(stringify);
|
||||
const parseAsync = promisify(parse);
|
||||
|
||||
async function load(path) {
|
||||
export async function load(path) {
|
||||
const str = await fs.readFile(path, 'utf-8');
|
||||
const rows = await parseAsync(str, {});
|
||||
if (rows.length === 0) throw new Error('No rows found');
|
||||
@ -30,14 +31,9 @@ async function load(path) {
|
||||
return mapped;
|
||||
}
|
||||
|
||||
async function save(path, cutSegments) {
|
||||
export async function save(path, cutSegments) {
|
||||
console.log('Saving', path);
|
||||
const rows = cutSegments.map(({ start, end, name }) => [start, end, name]);
|
||||
const str = await stringifyAsync(rows);
|
||||
await fs.writeFile(path, str);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
load,
|
||||
save,
|
||||
};
|
||||
|
@ -1,20 +1,22 @@
|
||||
const execa = require('execa');
|
||||
const pMap = require('p-map');
|
||||
const { join, extname } = require('path');
|
||||
const fileType = require('file-type');
|
||||
const readChunk = require('read-chunk');
|
||||
const flatMap = require('lodash/flatMap');
|
||||
const flatMapDeep = require('lodash/flatMapDeep');
|
||||
const sum = require('lodash/sum');
|
||||
const sortBy = require('lodash/sortBy');
|
||||
const readline = require('readline');
|
||||
const moment = require('moment');
|
||||
const stringToStream = require('string-to-stream');
|
||||
const trash = require('trash');
|
||||
const isDev = require('electron-is-dev');
|
||||
const os = require('os');
|
||||
import pMap from 'p-map';
|
||||
import flatMap from 'lodash/flatMap';
|
||||
import flatMapDeep from 'lodash/flatMapDeep';
|
||||
import sum from 'lodash/sum';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import moment from 'moment';
|
||||
|
||||
import { formatDuration, getOutPath, transferTimestamps, filenamify } from './util';
|
||||
|
||||
const execa = window.require('execa');
|
||||
const { join, extname } = window.require('path');
|
||||
const fileType = window.require('file-type');
|
||||
const readChunk = window.require('read-chunk');
|
||||
const readline = window.require('readline');
|
||||
const stringToStream = window.require('string-to-stream');
|
||||
const trash = window.require('trash');
|
||||
const isDev = window.require('electron-is-dev');
|
||||
const os = window.require('os');
|
||||
|
||||
const { formatDuration, getOutPath, transferTimestamps, filenamify } = require('./util');
|
||||
|
||||
|
||||
function getFfCommandLine(cmd, args) {
|
||||
@ -80,11 +82,11 @@ function handleProgress(process, cutDuration, onProgress) {
|
||||
});
|
||||
}
|
||||
|
||||
function isCuttingStart(cutFrom) {
|
||||
export function isCuttingStart(cutFrom) {
|
||||
return cutFrom > 0;
|
||||
}
|
||||
|
||||
function isCuttingEnd(cutTo, duration) {
|
||||
export function isCuttingEnd(cutTo, duration) {
|
||||
return cutTo < duration;
|
||||
}
|
||||
|
||||
@ -104,7 +106,7 @@ function getIntervalAroundTime(time, window) {
|
||||
};
|
||||
}
|
||||
|
||||
async function readFrames({ filePath, aroundTime, window, stream }) {
|
||||
export async function readFrames({ filePath, aroundTime, window, stream }) {
|
||||
let intervalsArgs = [];
|
||||
if (aroundTime != null) {
|
||||
const { from, to } = getIntervalAroundTime(aroundTime, window);
|
||||
@ -120,7 +122,7 @@ async function readFrames({ filePath, aroundTime, window, stream }) {
|
||||
|
||||
// https://stackoverflow.com/questions/14005110/how-to-split-a-video-using-ffmpeg-so-that-each-chunk-starts-with-a-key-frame
|
||||
// http://kicherer.org/joomla/index.php/de/blog/42-avcut-frame-accurate-video-cutting-with-only-small-quality-loss
|
||||
function getSafeCutTime(frames, cutTime, nextMode) {
|
||||
export function getSafeCutTime(frames, cutTime, nextMode) {
|
||||
const sigma = 0.01;
|
||||
const isCloseTo = (time1, time2) => Math.abs(time1 - time2) < sigma;
|
||||
|
||||
@ -167,7 +169,7 @@ function getSafeCutTime(frames, cutTime, nextMode) {
|
||||
return frames[index - 1].time;
|
||||
}
|
||||
|
||||
function findNearestKeyFrameTime({ frames, time, direction, fps }) {
|
||||
export function findNearestKeyFrameTime({ frames, time, direction, fps }) {
|
||||
const sigma = fps ? (1 / fps) : 0.1;
|
||||
const keyframes = frames.filter(f => f.keyframe && (direction > 0 ? f.time > time + sigma : f.time < time - sigma));
|
||||
if (keyframes.length === 0) return undefined;
|
||||
@ -244,7 +246,7 @@ async function cut({
|
||||
await transferTimestamps(filePath, outPath);
|
||||
}
|
||||
|
||||
async function cutMultiple({
|
||||
export async function cutMultiple({
|
||||
customOutDir, filePath, segments: segmentsUnsorted, videoDuration, rotation,
|
||||
onProgress, keyframeCut, copyStreamIds, outFormat, isOutFormatUserSelected,
|
||||
appendFfmpegCommandLog, shortestFlag,
|
||||
@ -295,7 +297,7 @@ async function cutMultiple({
|
||||
return outFiles;
|
||||
}
|
||||
|
||||
async function html5ify(filePath, outPath, encodeVideo, encodeAudio) {
|
||||
export async function html5ify(filePath, outPath, encodeVideo, encodeAudio) {
|
||||
console.log('Making HTML5 friendly version', { filePath, outPath, encodeVideo });
|
||||
|
||||
const videoArgs = encodeVideo
|
||||
@ -315,7 +317,7 @@ async function html5ify(filePath, outPath, encodeVideo, encodeAudio) {
|
||||
await transferTimestamps(filePath, outPath);
|
||||
}
|
||||
|
||||
async function getDuration(filePath) {
|
||||
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']);
|
||||
return parseFloat(JSON.parse(stdout).format.duration);
|
||||
@ -323,7 +325,7 @@ async function getDuration(filePath) {
|
||||
|
||||
// This is just used to load something into the player with correct length,
|
||||
// so user can seek and then we render frames using ffmpeg
|
||||
async function html5ifyDummy(filePath, outPath) {
|
||||
export async function html5ifyDummy(filePath, outPath) {
|
||||
console.log('Making HTML5 friendly dummy', { filePath, outPath });
|
||||
|
||||
const duration = await getDuration(filePath);
|
||||
@ -376,14 +378,14 @@ async function mergeFiles({ paths, outPath, allStreams }) {
|
||||
console.log(result.stdout);
|
||||
}
|
||||
|
||||
async function mergeAnyFiles({ customOutDir, paths, allStreams }) {
|
||||
export async function mergeAnyFiles({ customOutDir, paths, allStreams }) {
|
||||
const firstPath = paths[0];
|
||||
const ext = extname(firstPath);
|
||||
const outPath = getOutPath(customOutDir, firstPath, `merged${ext}`);
|
||||
return mergeFiles({ paths, outPath, allStreams });
|
||||
}
|
||||
|
||||
async function autoMergeSegments({ customOutDir, sourceFile, segmentPaths }) {
|
||||
export async function autoMergeSegments({ customOutDir, sourceFile, segmentPaths }) {
|
||||
const ext = extname(sourceFile);
|
||||
const outPath = getOutPath(customOutDir, sourceFile, `cut-merged-${new Date().getTime()}${ext}`);
|
||||
await mergeFiles({ paths: segmentPaths, outPath });
|
||||
@ -413,7 +415,7 @@ function determineOutputFormat(ffprobeFormats, ft) {
|
||||
return ffprobeFormats[0] || undefined;
|
||||
}
|
||||
|
||||
async function getFormatData(filePath) {
|
||||
export async function getFormatData(filePath) {
|
||||
console.log('getFormatData', filePath);
|
||||
|
||||
const { stdout } = await runFfprobe([
|
||||
@ -422,7 +424,7 @@ async function getFormatData(filePath) {
|
||||
return JSON.parse(stdout).format;
|
||||
}
|
||||
|
||||
async function getDefaultOutFormat(filePath, formatData) {
|
||||
export async function getDefaultOutFormat(filePath, formatData) {
|
||||
const formatsStr = formatData.format_name;
|
||||
console.log('formats', formatsStr);
|
||||
const formats = (formatsStr || '').split(',');
|
||||
@ -435,7 +437,7 @@ async function getDefaultOutFormat(filePath, formatData) {
|
||||
return mapFormat(assumedFormat);
|
||||
}
|
||||
|
||||
async function getAllStreams(filePath) {
|
||||
export async function getAllStreams(filePath) {
|
||||
const { stdout } = await runFfprobe([
|
||||
'-of', 'json', '-show_entries', 'stream', '-i', filePath,
|
||||
]);
|
||||
@ -472,7 +474,7 @@ function getPreferredCodecFormat(codec, type) {
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/questions/32922226/extract-every-audio-and-subtitles-from-a-video-with-ffmpeg
|
||||
async function extractStreams({ filePath, customOutDir, streams }) {
|
||||
export async function extractStreams({ filePath, customOutDir, streams }) {
|
||||
const outStreams = streams.map((s) => ({
|
||||
index: s.index,
|
||||
codec: s.codec_name || s.codec_tag_string || s.codec_type,
|
||||
@ -517,7 +519,7 @@ async function renderThumbnail(filePath, timestamp) {
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
async function renderThumbnails({ filePath, from, duration, onThumbnail }) {
|
||||
export async function renderThumbnails({ filePath, from, duration, onThumbnail }) {
|
||||
// Time first render to determine how many to render
|
||||
const startTime = new Date().getTime() / 1000;
|
||||
let url = await renderThumbnail(filePath, from);
|
||||
@ -538,7 +540,7 @@ async function renderThumbnails({ filePath, from, duration, onThumbnail }) {
|
||||
}
|
||||
|
||||
|
||||
async function renderWaveformPng({ filePath, aroundTime, window, color }) {
|
||||
export async function renderWaveformPng({ filePath, aroundTime, window, color }) {
|
||||
const { from, to } = getIntervalAroundTime(aroundTime, window);
|
||||
|
||||
const args1 = [
|
||||
@ -589,7 +591,7 @@ async function renderWaveformPng({ filePath, aroundTime, window, color }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function renderFrame(timestamp, filePath, rotation) {
|
||||
export async function renderFrame(timestamp, filePath, rotation) {
|
||||
const transpose = {
|
||||
90: 'transpose=2',
|
||||
180: 'transpose=1,transpose=1',
|
||||
@ -619,13 +621,13 @@ async function renderFrame(timestamp, filePath, rotation) {
|
||||
}
|
||||
|
||||
// https://www.ffmpeg.org/doxygen/3.2/libavutil_2utils_8c_source.html#l00079
|
||||
const defaultProcessedCodecTypes = [
|
||||
export const defaultProcessedCodecTypes = [
|
||||
'video',
|
||||
'audio',
|
||||
'subtitle',
|
||||
];
|
||||
|
||||
function getStreamFps(stream) {
|
||||
export function getStreamFps(stream) {
|
||||
const match = typeof stream.avg_frame_rate === 'string' && stream.avg_frame_rate.match(/^([0-9]+)\/([0-9]+)$/);
|
||||
if (stream.codec_type === 'video' && match) {
|
||||
const num = parseInt(match[1], 10);
|
||||
@ -634,26 +636,3 @@ function getStreamFps(stream) {
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
cutMultiple,
|
||||
getFormatData,
|
||||
getDefaultOutFormat,
|
||||
html5ify,
|
||||
html5ifyDummy,
|
||||
mergeAnyFiles,
|
||||
autoMergeSegments,
|
||||
extractStreams,
|
||||
renderFrame,
|
||||
getAllStreams,
|
||||
defaultProcessedCodecTypes,
|
||||
getStreamFps,
|
||||
isCuttingStart,
|
||||
isCuttingEnd,
|
||||
readFrames,
|
||||
getSafeCutTime,
|
||||
findNearestKeyFrameTime,
|
||||
renderWaveformPng,
|
||||
renderThumbnails,
|
||||
};
|
||||
|
@ -1,14 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title></title>
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="main.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<script src="./renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
11
src/index.jsx
Normal file
11
src/index.jsx
Normal file
@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import './main.css';
|
||||
import App from './App';
|
||||
|
||||
const electron = window.require('electron');
|
||||
|
||||
|
||||
console.log('Version', electron.remote.app.getVersion());
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('root'));
|
@ -1,14 +1,16 @@
|
||||
const React = require('react');
|
||||
const swal = require('sweetalert2');
|
||||
const withReactContent = require('sweetalert2-react-content');
|
||||
import React from 'react';
|
||||
import swal from 'sweetalert2';
|
||||
|
||||
const SortableFiles = require('./SortableFiles').default;
|
||||
import withReactContent from 'sweetalert2-react-content';
|
||||
|
||||
const { errorToast } = require('../util');
|
||||
import SortableFiles from './SortableFiles';
|
||||
|
||||
|
||||
import { errorToast } from '../util';
|
||||
|
||||
const MySwal = withReactContent(swal);
|
||||
|
||||
async function showMergeDialog(paths, onMergeClick) {
|
||||
export async function showMergeDialog(paths, onMergeClick) {
|
||||
if (!paths) return;
|
||||
if (paths.length < 2) {
|
||||
errorToast('More than one file must be selected');
|
||||
@ -36,7 +38,7 @@ async function showMergeDialog(paths, onMergeClick) {
|
||||
}
|
||||
}
|
||||
|
||||
async function showOpenAndMergeDialog({ dialog, defaultPath, onMergeClick }) {
|
||||
export async function showOpenAndMergeDialog({ dialog, defaultPath, onMergeClick }) {
|
||||
const title = 'Please select files to be merged';
|
||||
const message = 'Please select files to be merged. The files need to be of the exact same format and codecs';
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
@ -48,8 +50,3 @@ async function showOpenAndMergeDialog({ dialog, defaultPath, onMergeClick }) {
|
||||
if (canceled) return;
|
||||
showMergeDialog(filePaths, onMergeClick);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
showMergeDialog,
|
||||
showOpenAndMergeDialog,
|
||||
};
|
||||
|
@ -1,5 +1,5 @@
|
||||
// Extracted from "ffmpeg -formats"
|
||||
module.exports = {
|
||||
export default {
|
||||
'3g2': '3GP2 (3GPP2 file format)',
|
||||
'3gp': '3GP (3GPP file format)',
|
||||
a64: 'a64 - video for Commodore 64',
|
||||
|
@ -1,12 +1,12 @@
|
||||
// https://github.com/mock-end/random-color/blob/master/index.js
|
||||
/* eslint-disable */
|
||||
|
||||
const color = require('color');
|
||||
import color from 'color';
|
||||
|
||||
var ratio = 0.618033988749895;
|
||||
var hue = 0.65;
|
||||
|
||||
module.exports = function (saturation, value) {
|
||||
export default (saturation, value) => {
|
||||
hue += ratio;
|
||||
hue %= 1;
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
const Store = require('electron-store');
|
||||
const Store = window.require('electron-store');
|
||||
|
||||
const store = new Store({
|
||||
export default new Store({
|
||||
defaults: {
|
||||
captureFormat: 'jpeg',
|
||||
customOutDir: undefined,
|
||||
@ -14,5 +14,3 @@ const store = new Store({
|
||||
autoSaveProjectFile: true,
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = store;
|
||||
|
69
src/util.js
69
src/util.js
@ -1,30 +1,31 @@
|
||||
const _ = require('lodash');
|
||||
const path = require('path');
|
||||
const fs = require('fs-extra');
|
||||
const Swal = require('sweetalert2');
|
||||
import padStart from 'lodash/padStart';
|
||||
import Swal from 'sweetalert2';
|
||||
|
||||
const randomColor = require('./random-color');
|
||||
import randomColor from './random-color';
|
||||
|
||||
const path = window.require('path');
|
||||
const fs = window.require('fs-extra');
|
||||
|
||||
|
||||
function formatDuration({ seconds: _seconds, fileNameFriendly, fps }) {
|
||||
export function formatDuration({ seconds: _seconds, fileNameFriendly, fps }) {
|
||||
const seconds = _seconds || 0;
|
||||
const minutes = seconds / 60;
|
||||
const hours = minutes / 60;
|
||||
|
||||
const hoursPadded = _.padStart(Math.floor(hours), 2, '0');
|
||||
const minutesPadded = _.padStart(Math.floor(minutes % 60), 2, '0');
|
||||
const secondsPadded = _.padStart(Math.floor(seconds) % 60, 2, '0');
|
||||
const hoursPadded = padStart(Math.floor(hours), 2, '0');
|
||||
const minutesPadded = padStart(Math.floor(minutes % 60), 2, '0');
|
||||
const secondsPadded = padStart(Math.floor(seconds) % 60, 2, '0');
|
||||
const ms = seconds - Math.floor(seconds);
|
||||
const msPadded = fps != null
|
||||
? _.padStart(Math.floor(ms * fps), 2, '0')
|
||||
: _.padStart(Math.floor(ms * 1000), 3, '0');
|
||||
? padStart(Math.floor(ms * fps), 2, '0')
|
||||
: padStart(Math.floor(ms * 1000), 3, '0');
|
||||
|
||||
// Be nice to filenames and use .
|
||||
const delim = fileNameFriendly ? '.' : ':';
|
||||
return `${hoursPadded}${delim}${minutesPadded}${delim}${secondsPadded}.${msPadded}`;
|
||||
}
|
||||
|
||||
function parseDuration(str) {
|
||||
export function parseDuration(str) {
|
||||
if (!str) return undefined;
|
||||
const match = str.trim().match(/^(\d{2}):(\d{2}):(\d{2})\.(\d{3})$/);
|
||||
if (!match) return undefined;
|
||||
@ -37,20 +38,20 @@ function parseDuration(str) {
|
||||
return ((((hours * 60) + minutes) * 60) + seconds) + (ms / 1000);
|
||||
}
|
||||
|
||||
function getOutDir(customOutDir, filePath) {
|
||||
export function getOutDir(customOutDir, filePath) {
|
||||
if (customOutDir) return customOutDir;
|
||||
if (filePath) return path.dirname(filePath);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getOutPath(customOutDir, filePath, nameSuffix) {
|
||||
export function getOutPath(customOutDir, filePath, nameSuffix) {
|
||||
if (!filePath) return undefined;
|
||||
const parsed = path.parse(filePath);
|
||||
|
||||
return path.join(getOutDir(customOutDir, filePath), `${parsed.name}-${nameSuffix}`);
|
||||
}
|
||||
|
||||
async function transferTimestamps(inPath, outPath) {
|
||||
export async function transferTimestamps(inPath, outPath) {
|
||||
try {
|
||||
const stat = await fs.stat(inPath);
|
||||
await fs.utimes(outPath, stat.atime.getTime() / 1000, stat.mtime.getTime() / 1000);
|
||||
@ -59,7 +60,7 @@ async function transferTimestamps(inPath, outPath) {
|
||||
}
|
||||
}
|
||||
|
||||
async function transferTimestampsWithOffset(inPath, outPath, offset) {
|
||||
export async function transferTimestampsWithOffset(inPath, outPath, offset) {
|
||||
try {
|
||||
const stat = await fs.stat(inPath);
|
||||
const time = (stat.mtime.getTime() / 1000) + offset;
|
||||
@ -69,33 +70,33 @@ async function transferTimestampsWithOffset(inPath, outPath, offset) {
|
||||
}
|
||||
}
|
||||
|
||||
const toast = Swal.mixin({
|
||||
export const toast = Swal.mixin({
|
||||
toast: true,
|
||||
position: 'top',
|
||||
showConfirmButton: false,
|
||||
timer: 5000,
|
||||
});
|
||||
|
||||
const errorToast = (title) => toast.fire({
|
||||
export const errorToast = (title) => toast.fire({
|
||||
icon: 'error',
|
||||
title,
|
||||
});
|
||||
|
||||
async function showFfmpegFail(err) {
|
||||
export async function showFfmpegFail(err) {
|
||||
console.error(err);
|
||||
return errorToast(`Failed to run ffmpeg: ${err.stack}`);
|
||||
}
|
||||
|
||||
function setFileNameTitle(filePath) {
|
||||
export function setFileNameTitle(filePath) {
|
||||
const appName = 'LosslessCut';
|
||||
document.title = filePath ? `${appName} - ${path.basename(filePath)}` : appName;
|
||||
}
|
||||
|
||||
function filenamify(name) {
|
||||
export function filenamify(name) {
|
||||
return name.replace(/[^0-9a-zA-Z_.]/g, '_');
|
||||
}
|
||||
|
||||
async function promptTimeOffset(inputValue) {
|
||||
export async function promptTimeOffset(inputValue) {
|
||||
const { value } = await Swal.fire({
|
||||
title: 'Set custom start time offset',
|
||||
text: 'Instead of video apparently starting at 0, you can offset by a specified value (useful for viewing/cutting videos according to timecodes)',
|
||||
@ -116,18 +117,18 @@ async function promptTimeOffset(inputValue) {
|
||||
return duration;
|
||||
}
|
||||
|
||||
function generateColor() {
|
||||
export function generateColor() {
|
||||
return randomColor(1, 0.95);
|
||||
}
|
||||
|
||||
function withBlur(cb) {
|
||||
export function withBlur(cb) {
|
||||
return (e) => {
|
||||
cb(e);
|
||||
e.target.blur();
|
||||
};
|
||||
}
|
||||
|
||||
function getSegColors(seg) {
|
||||
export function getSegColors(seg) {
|
||||
if (!seg) return {};
|
||||
const { color } = seg;
|
||||
return {
|
||||
@ -136,21 +137,3 @@ function getSegColors(seg) {
|
||||
segBorderColor: color.lighten(0.5).string(),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
formatDuration,
|
||||
parseDuration,
|
||||
getOutPath,
|
||||
getOutDir,
|
||||
transferTimestamps,
|
||||
transferTimestampsWithOffset,
|
||||
toast,
|
||||
errorToast,
|
||||
showFfmpegFail,
|
||||
setFileNameTitle,
|
||||
promptTimeOffset,
|
||||
generateColor,
|
||||
filenamify,
|
||||
withBlur,
|
||||
getSegColors,
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user