kopia lustrzana https://github.com/mifi/lossless-cut
test contextIsolation
UNFINISHED! todo: move from devdeps to deps import pMap from 'p-map'; import flatMap from 'lodash/flatMap'; import sortBy from 'lodash/sortBy'; import moment from 'moment'; import i18n from 'i18next'; import Timecode from 'smpte-timecode'; test this!!!! const isMasBuild = process.mas; const isWindowsStoreBuild = process.windowsStore; const isStoreBuild = isMasBuild || isWindowsStoreBuild; test file open from command linecontext-isolation-test
rodzic
5e1700ef04
commit
c9066c3743
|
@ -92,9 +92,9 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"cue-parser": "^0.3.0",
|
||||
"electron-is-dev": "^0.1.2",
|
||||
"electron-store": "^5.1.1",
|
||||
"electron-unhandled": "^3.0.2",
|
||||
"eventemitter3": "^4.0.7",
|
||||
"execa": "^4.0.0",
|
||||
"ffmpeg-ffprobe-static": "^4.3.1-rc.2",
|
||||
"file-type": "^12.4.0",
|
||||
|
|
|
@ -1,18 +1,17 @@
|
|||
const electron = require('electron'); // eslint-disable-line
|
||||
const isDev = require('electron-is-dev');
|
||||
const electron = require('electron');
|
||||
const unhandled = require('electron-unhandled');
|
||||
const i18n = require('i18next');
|
||||
const { join } = require('path');
|
||||
|
||||
const menu = require('./menu');
|
||||
const configStore = require('./configStore');
|
||||
|
||||
const { checkNewVersion } = require('./update-checker');
|
||||
|
||||
const { isDev } = require('./util');
|
||||
|
||||
require('./i18n');
|
||||
|
||||
const { app } = electron;
|
||||
const { BrowserWindow } = electron;
|
||||
|
||||
const { app, BrowserWindow } = electron;
|
||||
|
||||
unhandled({
|
||||
showDialog: true,
|
||||
|
@ -38,6 +37,8 @@ function createWindow() {
|
|||
nodeIntegration: true,
|
||||
// https://github.com/electron/electron/issues/5107
|
||||
webSecurity: !isDev,
|
||||
contextIsolation: true,
|
||||
preload: isDev ? join(__dirname, 'preload.js') : join(app.getAppPath(), 'preload.js'), // todo test production
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -88,8 +89,6 @@ function updateMenu() {
|
|||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.on('ready', async () => {
|
||||
await configStore.init();
|
||||
|
||||
createWindow();
|
||||
updateMenu();
|
||||
|
||||
|
@ -146,13 +145,3 @@ electron.ipcMain.on('setAskBeforeClose', (e, val) => {
|
|||
electron.ipcMain.on('setLanguage', (e, language) => {
|
||||
i18n.changeLanguage(language).then(() => updateMenu()).catch(console.error);
|
||||
});
|
||||
|
||||
function focusWindow() {
|
||||
try {
|
||||
app.focus({ steal: true });
|
||||
} catch (err) {
|
||||
console.error('Failed to focus window', err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { focusWindow };
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
// const LanguageDetector = window.require('i18next-electron-language-detector');
|
||||
const isDev = require('electron-is-dev');
|
||||
|
||||
// const LanguageDetector = require('i18next-electron-language-detector');
|
||||
const { app } = require('electron');
|
||||
|
||||
const { join } = require('path');
|
||||
|
||||
const { isDev } = require('./util');
|
||||
|
||||
const getLangPath = (subPath) => (isDev ? join('public', subPath) : join(app.getAppPath(), 'build', subPath));
|
||||
|
||||
// Weblate hardcodes different lang codes than electron
|
||||
|
|
|
@ -0,0 +1,80 @@
|
|||
const { contextBridge, remote, ipcRenderer } = require('electron');
|
||||
const { exists, unlink, writeFile, readFile } = require('fs-extra');
|
||||
const { extname, parse, sep, join, normalize, resolve, isAbsolute } = require('path');
|
||||
const strtok3 = require('strtok3');
|
||||
const I18nBackend = require('i18next-fs-backend');
|
||||
const execa = require('execa');
|
||||
|
||||
const { githubLink } = require('./constants');
|
||||
const { commonI18nOptions, fallbackLng, loadPath, addPath } = require('./i18n-common');
|
||||
const configStore = require('./configStore');
|
||||
|
||||
const preloadUtil = require('./preload/util');
|
||||
const ffmpeg = require('./preload/ffmpeg');
|
||||
|
||||
async function initPreload() {
|
||||
await configStore.init();
|
||||
}
|
||||
contextBridge.exposeInMainWorld('init', { preload: initPreload });
|
||||
|
||||
contextBridge.exposeInMainWorld('constants', {
|
||||
isDev: !remote.app.isPackaged,
|
||||
githubLink,
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('util', preloadUtil);
|
||||
contextBridge.exposeInMainWorld('ffmpeg', ffmpeg);
|
||||
|
||||
|
||||
contextBridge.exposeInMainWorld('ipc', {
|
||||
onMessage: (event, fn) => ipcRenderer.on(event, (e, ...args) => fn(...args)),
|
||||
send: ipcRenderer.send,
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('fs', {
|
||||
exists,
|
||||
unlink,
|
||||
writeFile,
|
||||
readFile,
|
||||
}); // TODO improve?
|
||||
|
||||
contextBridge.exposeInMainWorld('path', {
|
||||
extname, parse, sep, join, normalize, resolve, isAbsolute,
|
||||
}); // TODO improve?
|
||||
|
||||
contextBridge.exposeInMainWorld('strtok3', strtok3); // TODO improve
|
||||
|
||||
// TODO improve
|
||||
let backend;
|
||||
// Because contextBridge doesn't handle classes
|
||||
const BackendProxy = {
|
||||
type: 'backend',
|
||||
init: (services, backendOptions, i18nextOptions) => {
|
||||
if (!backend) backend = new I18nBackend(services, backendOptions, i18nextOptions);
|
||||
},
|
||||
read: (language, namespace, callback) => {
|
||||
backend.read(language, namespace, callback);
|
||||
},
|
||||
// only used in backends acting as cache layer
|
||||
save: (language, namespace, data, callback) => {
|
||||
backend.save(language, namespace, data, callback);
|
||||
},
|
||||
create: (languages, namespace, key, fallbackValue, callback) => {
|
||||
backend.create(languages, namespace, key, fallbackValue, callback);
|
||||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('i18n', {
|
||||
commonI18nOptions, fallbackLng, loadPath, addPath, Backend: BackendProxy,
|
||||
}); // TODO improve
|
||||
|
||||
contextBridge.exposeInMainWorld('clipboard', {
|
||||
writeText: remote.clipboard.writeText,
|
||||
}); // TODO improve
|
||||
|
||||
contextBridge.exposeInMainWorld('configStore', {
|
||||
get: (key) => configStore.get(key),
|
||||
set: (key, val) => configStore.set(key, val),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('execa', { execa });
|
|
@ -0,0 +1,603 @@
|
|||
const pMap = require('p-map');
|
||||
const flatMap = require('lodash/flatMap');
|
||||
const sortBy = require('lodash/sortBy');
|
||||
const moment = require('moment');
|
||||
const i18n = require('i18next');
|
||||
const Timecode = require('smpte-timecode');
|
||||
|
||||
const execa = require('execa');
|
||||
const { join } = require('path');
|
||||
const fileType = require('file-type');
|
||||
const readChunk = require('read-chunk');
|
||||
const readline = require('readline');
|
||||
const os = require('os');
|
||||
|
||||
const { getOutPath, getExtensionForFormat } = require('./util');
|
||||
const { isDev } = require('../util');
|
||||
|
||||
|
||||
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 platform = os.platform();
|
||||
|
||||
if (platform === 'darwin') {
|
||||
return isDev ? `ffmpeg-mac/${cmd}` : join(process.resourcesPath, cmd);
|
||||
}
|
||||
|
||||
const exeName = platform === 'win32' ? `${cmd}.exe` : cmd;
|
||||
return isDev
|
||||
? `node_modules/ffmpeg-ffprobe-static/${exeName}`
|
||||
: join(process.resourcesPath, `node_modules/ffmpeg-ffprobe-static/${exeName}`);
|
||||
}
|
||||
|
||||
const getFfmpegPath = () => getFfPath('ffmpeg');
|
||||
const getFfprobePath = () => getFfPath('ffprobe');
|
||||
|
||||
async function runFfprobe(args) {
|
||||
const ffprobePath = getFfprobePath();
|
||||
console.log(getFfCommandLine('ffprobe', args));
|
||||
return execa(ffprobePath, args);
|
||||
}
|
||||
|
||||
function runFfmpeg(args) {
|
||||
const ffmpegPath = getFfmpegPath();
|
||||
console.log(getFfCommandLine('ffmpeg', args));
|
||||
return execa(ffmpegPath, args);
|
||||
}
|
||||
|
||||
|
||||
function handleProgress(process, cutDuration, onProgress) {
|
||||
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) return;
|
||||
|
||||
const str = match[1];
|
||||
// console.log(str);
|
||||
const progressTime = Math.max(0, moment.duration(str).asSeconds());
|
||||
// console.log(progressTime);
|
||||
const progress = cutDuration ? progressTime / cutDuration : 0;
|
||||
onProgress(progress);
|
||||
} catch (err) {
|
||||
console.log('Failed to parse ffmpeg progress line', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getIntervalAroundTime(time, window) {
|
||||
return {
|
||||
from: Math.max(time - window / 2, 0),
|
||||
to: time + window / 2,
|
||||
};
|
||||
}
|
||||
|
||||
async function readFrames({ filePath, aroundTime, window, stream }) {
|
||||
let intervalsArgs = [];
|
||||
if (aroundTime != null) {
|
||||
const { from, to } = getIntervalAroundTime(aroundTime, window);
|
||||
intervalsArgs = ['-read_intervals', `${from}%${to}`];
|
||||
}
|
||||
const { stdout } = await runFfprobe(['-v', 'error', ...intervalsArgs, '-show_packets', '-select_streams', stream, '-show_entries', 'packet=pts_time,flags', '-of', 'json', filePath]);
|
||||
const packetsFiltered = JSON.parse(stdout).packets
|
||||
.map(p => ({ keyframe: p.flags[0] === 'K', time: parseFloat(p.pts_time, 10) }))
|
||||
.filter(p => !Number.isNaN(p.time));
|
||||
|
||||
return sortBy(packetsFiltered, 'time');
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const sigma = 0.01;
|
||||
const isCloseTo = (time1, time2) => Math.abs(time1 - time2) < sigma;
|
||||
|
||||
let index;
|
||||
|
||||
if (frames.length < 2) throw new Error(i18n.t('Less than 2 frames found'));
|
||||
|
||||
if (nextMode) {
|
||||
index = frames.findIndex(f => f.keyframe && f.time >= cutTime - sigma);
|
||||
if (index === -1) throw new Error(i18n.t('Failed to find next keyframe'));
|
||||
if (index >= frames.length - 1) throw new Error(i18n.t('We are on the last frame'));
|
||||
const { time } = frames[index];
|
||||
if (isCloseTo(time, cutTime)) {
|
||||
return undefined; // Already on keyframe, no need to modify cut time
|
||||
}
|
||||
return time;
|
||||
}
|
||||
|
||||
const findReverseIndex = (arr, cb) => {
|
||||
const ret = [...arr].reverse().findIndex(cb);
|
||||
if (ret === -1) return -1;
|
||||
return arr.length - 1 - ret;
|
||||
};
|
||||
|
||||
index = findReverseIndex(frames, f => f.time <= cutTime + sigma);
|
||||
if (index === -1) throw new Error(i18n.t('Failed to find any prev frame'));
|
||||
if (index === 0) throw new Error(i18n.t('We are on the first frame'));
|
||||
|
||||
if (index === frames.length - 1) {
|
||||
// Last frame of video, no need to modify cut time
|
||||
return undefined;
|
||||
}
|
||||
if (frames[index + 1].keyframe) {
|
||||
// Already on frame before keyframe, no need to modify cut time
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// We are not on a frame before keyframe, look for preceding keyframe instead
|
||||
index = findReverseIndex(frames, f => f.keyframe && f.time <= cutTime + sigma);
|
||||
if (index === -1) throw new Error(i18n.t('Failed to find any prev keyframe'));
|
||||
if (index === 0) throw new Error(i18n.t('We are on the first keyframe'));
|
||||
|
||||
// Use frame before the found keyframe
|
||||
return frames[index - 1].time;
|
||||
}
|
||||
|
||||
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;
|
||||
const nearestFrame = sortBy(keyframes, keyframe => (direction > 0 ? keyframe.time - time : time - keyframe.time))[0];
|
||||
if (!nearestFrame) return undefined;
|
||||
return nearestFrame.time;
|
||||
}
|
||||
|
||||
async function tryReadChaptersToEdl(filePath) {
|
||||
try {
|
||||
const { stdout } = await runFfprobe(['-i', filePath, '-show_chapters', '-print_format', 'json']);
|
||||
return JSON.parse(stdout).chapters.map((chapter) => {
|
||||
const start = parseFloat(chapter.start_time);
|
||||
const end = parseFloat(chapter.end_time);
|
||||
if (Number.isNaN(start) || Number.isNaN(end)) return undefined;
|
||||
|
||||
const name = chapter.tags && typeof chapter.tags.title === 'string' ? chapter.tags.title : undefined;
|
||||
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
name,
|
||||
};
|
||||
}).filter((it) => it);
|
||||
} catch (err) {
|
||||
console.error('Failed to read chapters from file', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getFormatData(filePath) {
|
||||
console.log('getFormatData', filePath);
|
||||
|
||||
const { stdout } = await runFfprobe([
|
||||
'-of', 'json', '-show_format', '-i', filePath,
|
||||
]);
|
||||
return JSON.parse(stdout).format;
|
||||
}
|
||||
|
||||
|
||||
async function getDuration(filePath) {
|
||||
return parseFloat((await getFormatData(filePath)).duration);
|
||||
}
|
||||
|
||||
async function createChaptersFromSegments({ segmentPaths, chapterNames }) {
|
||||
if (chapterNames) {
|
||||
try {
|
||||
const durations = await pMap(segmentPaths, (segmentPath) => getDuration(segmentPath), { concurrency: 3 });
|
||||
let timeAt = 0;
|
||||
return durations.map((duration, i) => {
|
||||
const ret = { start: timeAt, end: timeAt + duration, name: chapterNames[i] };
|
||||
timeAt += duration;
|
||||
return ret;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to create chapters from segments', err);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* ffmpeg only supports encoding certain formats, and some of the detected input
|
||||
* formats are not the same as the names used for encoding.
|
||||
* Therefore we have to map between detected format and encode format
|
||||
* See also ffmpeg -formats
|
||||
*/
|
||||
function mapFormat(requestedFormat) {
|
||||
switch (requestedFormat) {
|
||||
// These two cmds produce identical output, so we assume that encoding "ipod" means encoding m4a
|
||||
// ffmpeg -i example.aac -c copy OutputFile2.m4a
|
||||
// ffmpeg -i example.aac -c copy -f ipod OutputFile.m4a
|
||||
// See also https://github.com/mifi/lossless-cut/issues/28
|
||||
case 'm4a': return 'ipod';
|
||||
case 'aac': return 'ipod';
|
||||
default: return requestedFormat;
|
||||
}
|
||||
}
|
||||
|
||||
function determineOutputFormat(ffprobeFormats, ft) {
|
||||
if (ffprobeFormats.includes(ft.ext)) return ft.ext;
|
||||
return ffprobeFormats[0] || undefined;
|
||||
}
|
||||
|
||||
async function getDefaultOutFormat(filePath, formatData) {
|
||||
const formatsStr = formatData.format_name;
|
||||
console.log('formats', formatsStr);
|
||||
const formats = (formatsStr || '').split(',');
|
||||
|
||||
// ffprobe sometimes returns a list of formats, try to be a bit smarter about it.
|
||||
const bytes = await readChunk(filePath, 0, 4100);
|
||||
const ft = fileType(bytes) || {};
|
||||
console.log(`fileType detected format ${JSON.stringify(ft)}`);
|
||||
const assumedFormat = determineOutputFormat(formats, ft);
|
||||
return mapFormat(assumedFormat);
|
||||
}
|
||||
|
||||
async function getAllStreams(filePath) {
|
||||
const { stdout } = await runFfprobe([
|
||||
'-of', 'json', '-show_entries', 'stream', '-i', filePath,
|
||||
]);
|
||||
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
function getPreferredCodecFormat(codec, type) {
|
||||
const map = {
|
||||
mp3: 'mp3',
|
||||
opus: 'opus',
|
||||
vorbis: 'ogg',
|
||||
h264: 'mp4',
|
||||
hevc: 'mp4',
|
||||
eac3: 'eac3',
|
||||
|
||||
subrip: 'srt',
|
||||
|
||||
// See mapFormat
|
||||
m4a: 'ipod',
|
||||
aac: 'ipod',
|
||||
|
||||
// TODO add more
|
||||
// TODO allow user to change?
|
||||
};
|
||||
|
||||
const format = map[codec];
|
||||
if (format) return { ext: getExtensionForFormat(format), format };
|
||||
if (type === 'video') return { ext: 'mkv', format: 'matroska' };
|
||||
if (type === 'audio') return { ext: 'mka', format: 'matroska' };
|
||||
if (type === 'subtitle') return { ext: 'mks', format: 'matroska' };
|
||||
if (type === 'data') return { ext: 'bin', format: 'data' }; // https://superuser.com/questions/1243257/save-data-stream
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/questions/32922226/extract-every-audio-and-subtitles-from-a-video-with-ffmpeg
|
||||
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,
|
||||
type: s.codec_type,
|
||||
format: getPreferredCodecFormat(s.codec_name, s.codec_type),
|
||||
}))
|
||||
.filter(it => it && it.format && it.index != null);
|
||||
|
||||
// console.log(outStreams);
|
||||
|
||||
const streamArgs = flatMap(outStreams, ({
|
||||
index, codec, type, format: { format, ext },
|
||||
}) => [
|
||||
'-map', `0:${index}`, '-c', 'copy', '-f', format, '-y', getOutPath(customOutDir, filePath, `stream-${index}-${type}-${codec}.${ext}`),
|
||||
]);
|
||||
|
||||
const ffmpegArgs = [
|
||||
'-hide_banner',
|
||||
|
||||
'-i', filePath,
|
||||
...streamArgs,
|
||||
];
|
||||
|
||||
// TODO progress
|
||||
const { stdout } = await runFfmpeg(ffmpegArgs);
|
||||
console.log(stdout);
|
||||
}
|
||||
|
||||
async function renderThumbnail(filePath, timestamp) {
|
||||
const args = [
|
||||
'-ss', timestamp,
|
||||
'-i', filePath,
|
||||
'-vf', 'scale=-2:200',
|
||||
'-f', 'image2',
|
||||
'-vframes', '1',
|
||||
'-q:v', '10',
|
||||
'-',
|
||||
];
|
||||
|
||||
const ffmpegPath = await getFfmpegPath();
|
||||
const { stdout } = await execa(ffmpegPath, args, { encoding: null });
|
||||
|
||||
const blob = new Blob([stdout], { type: 'image/jpeg' });
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
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);
|
||||
const endTime = new Date().getTime() / 1000;
|
||||
onThumbnail({ time: from, url });
|
||||
|
||||
// Aim for max 3 sec to render all
|
||||
const numThumbs = Math.floor(Math.min(Math.max(3 / (endTime - startTime), 3), 10));
|
||||
// console.log(numThumbs);
|
||||
|
||||
const thumbTimes = Array(numThumbs - 1).fill().map((unused, i) => (from + ((duration * (i + 1)) / (numThumbs))));
|
||||
// console.log(thumbTimes);
|
||||
|
||||
await pMap(thumbTimes, async (time) => {
|
||||
url = await renderThumbnail(filePath, time);
|
||||
onThumbnail({ time, url });
|
||||
}, { concurrency: 2 });
|
||||
}
|
||||
|
||||
|
||||
async function renderWaveformPng({ filePath, aroundTime, window, color }) {
|
||||
const { from, to } = getIntervalAroundTime(aroundTime, window);
|
||||
|
||||
const args1 = [
|
||||
'-i', filePath,
|
||||
'-ss', from,
|
||||
'-t', to - from,
|
||||
'-c', 'copy',
|
||||
'-vn',
|
||||
'-map', 'a:0',
|
||||
'-f', 'matroska', // mpegts doesn't support vorbis etc
|
||||
'-',
|
||||
];
|
||||
|
||||
const args2 = [
|
||||
'-i', '-',
|
||||
'-filter_complex', `aformat=channel_layouts=mono,showwavespic=s=640x120:scale=sqrt:colors=${color}`,
|
||||
'-frames:v', '1',
|
||||
'-vcodec', 'png',
|
||||
'-f', 'image2',
|
||||
'-',
|
||||
];
|
||||
|
||||
console.log(getFfCommandLine('ffmpeg1', args1));
|
||||
console.log(getFfCommandLine('ffmpeg2', args2));
|
||||
|
||||
let ps1;
|
||||
let ps2;
|
||||
try {
|
||||
const ffmpegPath = getFfmpegPath();
|
||||
ps1 = execa(ffmpegPath, args1, { encoding: null, buffer: false });
|
||||
ps2 = execa(ffmpegPath, args2, { encoding: null });
|
||||
ps1.stdout.pipe(ps2.stdin);
|
||||
|
||||
const { stdout } = await ps2;
|
||||
|
||||
const blob = new Blob([stdout], { type: 'image/png' });
|
||||
|
||||
return {
|
||||
url: URL.createObjectURL(blob),
|
||||
from,
|
||||
aroundTime,
|
||||
to,
|
||||
};
|
||||
} catch (err) {
|
||||
if (ps1) ps1.kill();
|
||||
if (ps2) ps2.kill();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function extractWaveform({ filePath, outPath }) {
|
||||
const numSegs = 10;
|
||||
const duration = 60 * 60;
|
||||
const maxLen = 0.1;
|
||||
const segments = Array(numSegs).fill().map((unused, i) => [i * (duration / numSegs), Math.min(duration / numSegs, maxLen)]);
|
||||
|
||||
// https://superuser.com/questions/681885/how-can-i-remove-multiple-segments-from-a-video-using-ffmpeg
|
||||
let filter = segments.map(([from, len], i) => `[0:a]atrim=start=${from}:end=${from + len},asetpts=PTS-STARTPTS[a${i}]`).join(';');
|
||||
filter += ';';
|
||||
filter += segments.map((arr, i) => `[a${i}]`).join('');
|
||||
filter += `concat=n=${segments.length}:v=0:a=1[out]`;
|
||||
|
||||
console.time('ffmpeg');
|
||||
await runFfmpeg([
|
||||
'-i',
|
||||
filePath,
|
||||
'-filter_complex',
|
||||
filter,
|
||||
'-map',
|
||||
'[out]',
|
||||
'-f', 'wav',
|
||||
'-y',
|
||||
outPath,
|
||||
]);
|
||||
console.timeEnd('ffmpeg');
|
||||
}
|
||||
|
||||
// See also capture-frame.js
|
||||
async function captureFrame({ timestamp, videoPath, outPath }) {
|
||||
const args = [
|
||||
'-ss', timestamp,
|
||||
'-i', videoPath,
|
||||
'-vframes', '1',
|
||||
'-q:v', '3',
|
||||
'-y', outPath,
|
||||
];
|
||||
|
||||
const ffmpegPath = getFfmpegPath();
|
||||
await execa(ffmpegPath, args, { encoding: null });
|
||||
}
|
||||
|
||||
// https://www.ffmpeg.org/doxygen/3.2/libavutil_2utils_8c_source.html#l00079
|
||||
const defaultProcessedCodecTypes = [
|
||||
'video',
|
||||
'audio',
|
||||
'subtitle',
|
||||
'attachment',
|
||||
];
|
||||
|
||||
const isMov = (format) => ['ismv', 'ipod', 'mp4', 'mov'].includes(format);
|
||||
|
||||
function isStreamThumbnail(stream) {
|
||||
return stream && stream.disposition && stream.disposition.attached_pic === 1;
|
||||
}
|
||||
|
||||
function isAudioSupported(streams) {
|
||||
const audioStreams = streams.filter(stream => stream.codec_type === 'audio');
|
||||
if (audioStreams.length === 0) return true;
|
||||
// TODO this could be improved
|
||||
return audioStreams.some(stream => !['ac3'].includes(stream.codec_name));
|
||||
}
|
||||
|
||||
function isIphoneHevc(format, streams) {
|
||||
if (!streams.some((s) => s.codec_name === 'hevc')) return false;
|
||||
const makeTag = format.tags && format.tags['com.apple.quicktime.make'];
|
||||
const modelTag = format.tags && format.tags['com.apple.quicktime.model'];
|
||||
return (makeTag === 'Apple' && modelTag.startsWith('iPhone'));
|
||||
}
|
||||
|
||||
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);
|
||||
const den = parseInt(match[2], 10);
|
||||
if (den > 0) return num / den;
|
||||
}
|
||||
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: execa(getFfmpegPath(), args, execaOpts),
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTimecode(str, frameRate) {
|
||||
// console.log(str, frameRate);
|
||||
const t = Timecode(str, frameRate ? parseFloat(frameRate.toFixed(3)) : undefined);
|
||||
if (!t) return undefined;
|
||||
const seconds = ((t.hours * 60) + t.minutes) * 60 + t.seconds + (t.frames / t.frameRate);
|
||||
return Number.isFinite(seconds) ? seconds : undefined;
|
||||
}
|
||||
|
||||
function getTimecodeFromStreams(streams) {
|
||||
console.log('Trying to load timecode');
|
||||
let foundTimecode;
|
||||
streams.find((stream) => {
|
||||
try {
|
||||
if (stream.tags && stream.tags.timecode) {
|
||||
const fps = getStreamFps(stream);
|
||||
foundTimecode = parseTimecode(stream.tags.timecode, fps);
|
||||
console.log('Loaded timecode', stream.tags.timecode, 'from stream', stream.index);
|
||||
return true;
|
||||
}
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
// console.warn('Failed to parse timecode from file streams', err);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
return foundTimecode;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getFfCommandLine,
|
||||
getFfmpegPath,
|
||||
getFfprobePath,
|
||||
runFfprobe,
|
||||
runFfmpeg,
|
||||
handleProgress,
|
||||
readFrames,
|
||||
getSafeCutTime,
|
||||
findNearestKeyFrameTime,
|
||||
tryReadChaptersToEdl,
|
||||
getFormatData,
|
||||
getDuration,
|
||||
createChaptersFromSegments,
|
||||
getDefaultOutFormat,
|
||||
getAllStreams,
|
||||
extractStreams,
|
||||
renderThumbnails,
|
||||
renderWaveformPng,
|
||||
extractWaveform,
|
||||
captureFrame,
|
||||
defaultProcessedCodecTypes,
|
||||
isMov,
|
||||
isStreamThumbnail,
|
||||
isAudioSupported,
|
||||
isIphoneHevc,
|
||||
getStreamFps,
|
||||
getOneRawFrame,
|
||||
encodeLiveRawStream,
|
||||
getTimecodeFromStreams,
|
||||
};
|
|
@ -0,0 +1,128 @@
|
|||
const path = require('path');
|
||||
const fs = require('fs-extra');
|
||||
const open = require('open');
|
||||
const os = require('os');
|
||||
const trash = require('trash');
|
||||
const mime = require('mime-types');
|
||||
const cueParser = require('cue-parser');
|
||||
const stringToStream = require('string-to-stream');
|
||||
|
||||
|
||||
const { remote } = require('electron');
|
||||
|
||||
function focusWindow() {
|
||||
try {
|
||||
remote.app.focus({ steal: true });
|
||||
} catch (err) {
|
||||
console.error('Failed to focus window', err);
|
||||
}
|
||||
}
|
||||
|
||||
function getOutDir(customOutDir, filePath) {
|
||||
if (customOutDir) return customOutDir;
|
||||
if (filePath) return path.dirname(filePath);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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 havePermissionToReadFile(filePath) {
|
||||
try {
|
||||
const fd = await fs.open(filePath, 'r');
|
||||
try {
|
||||
await fs.close(fd);
|
||||
} catch (err) {
|
||||
console.error('Failed to close fd', err);
|
||||
}
|
||||
} catch (err) {
|
||||
if (['EPERM', 'EACCES'].includes(err.code)) return false;
|
||||
console.error(err);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function checkDirWriteAccess(dirPath) {
|
||||
try {
|
||||
await fs.access(dirPath, fs.constants.W_OK);
|
||||
} catch (err) {
|
||||
if (err.code === 'EPERM') return false; // Thrown on Mac (MAS build) when user has not yet allowed access
|
||||
if (err.code === 'EACCES') return false; // Thrown on Linux when user doesn't have access to output dir
|
||||
console.error(err);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function dirExists(dirPath) {
|
||||
return (await fs.exists(dirPath)) && (await fs.lstat(dirPath)).isDirectory();
|
||||
}
|
||||
|
||||
async function transferTimestamps(inPath, outPath, offset = 0) {
|
||||
try {
|
||||
const { atime, mtime } = await fs.stat(inPath);
|
||||
await fs.utimes(outPath, (atime.getTime() / 1000) + offset, (mtime.getTime() / 1000) + offset);
|
||||
} catch (err) {
|
||||
console.error('Failed to set output file modified time', err);
|
||||
}
|
||||
}
|
||||
|
||||
const getBaseName = (p) => path.basename(p);
|
||||
|
||||
function getExtensionForFormat(format) {
|
||||
const ext = {
|
||||
matroska: 'mkv',
|
||||
ipod: 'm4a',
|
||||
}[format];
|
||||
|
||||
return ext || format;
|
||||
}
|
||||
|
||||
function getOutFileExtension({ isCustomFormatSelected, outFormat, filePath }) {
|
||||
return isCustomFormatSelected ? `.${getExtensionForFormat(outFormat)}` : path.extname(filePath);
|
||||
}
|
||||
|
||||
const platform = os.platform();
|
||||
const isWindows = platform === 'win32';
|
||||
|
||||
// TODO test!!!!
|
||||
const isMasBuild = process.mas;
|
||||
const isWindowsStoreBuild = process.windowsStore;
|
||||
const isStoreBuild = isMasBuild || isWindowsStoreBuild;
|
||||
|
||||
|
||||
const getAppVersion = () => remote.app.getVersion();
|
||||
|
||||
const openExternal = (url) => remote.shell.openExternal(url);
|
||||
|
||||
const buildMenuFromTemplate = (template) => remote.Menu.buildFromTemplate(template);
|
||||
|
||||
module.exports = {
|
||||
open,
|
||||
isWindows,
|
||||
getBaseName,
|
||||
getOutFileExtension,
|
||||
getExtensionForFormat,
|
||||
transferTimestamps,
|
||||
dirExists,
|
||||
checkDirWriteAccess,
|
||||
havePermissionToReadFile,
|
||||
getOutPath,
|
||||
getOutDir,
|
||||
focusWindow,
|
||||
getAppVersion,
|
||||
dialog: remote.dialog,
|
||||
trash,
|
||||
getExtensionFromMime: mime.extension,
|
||||
parseCueFile: (p) => cueParser.parse(p),
|
||||
openExternal,
|
||||
platform,
|
||||
buildMenuFromTemplate,
|
||||
isMasBuild,
|
||||
isWindowsStoreBuild,
|
||||
isStoreBuild,
|
||||
stringToStream,
|
||||
};
|
|
@ -0,0 +1,8 @@
|
|||
const electron = require('electron');
|
||||
|
||||
// TODO?
|
||||
const isDev = electron.remote ? !electron.remote.app.isPackaged : !electron.app.isPackaged;
|
||||
|
||||
module.exports = {
|
||||
isDev,
|
||||
};
|
158
src/App.jsx
158
src/App.jsx
|
@ -17,6 +17,7 @@ import clamp from 'lodash/clamp';
|
|||
import sortBy from 'lodash/sortBy';
|
||||
import flatMap from 'lodash/flatMap';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import EventEmitter from 'eventemitter3';
|
||||
|
||||
import useTimelineScroll from './hooks/useTimelineScroll';
|
||||
import useUserPreferences from './hooks/useUserPreferences';
|
||||
|
@ -41,7 +42,7 @@ import { showMergeDialog, showOpenAndMergeDialog } from './merge/merge';
|
|||
import allOutFormats from './outFormats';
|
||||
import { captureFrameFromTag, captureFrameFfmpeg } from './capture-frame';
|
||||
import {
|
||||
defaultProcessedCodecTypes, getStreamFps, isCuttingStart, isCuttingEnd,
|
||||
defaultProcessedCodecTypes, getStreamFps,
|
||||
getDefaultOutFormat, getFormatData, renderThumbnails as ffmpegRenderThumbnails,
|
||||
readFrames, renderWaveformPng, extractStreams, getAllStreams,
|
||||
findNearestKeyFrameTime as ffmpegFindNearestKeyFrameTime, isStreamThumbnail, isAudioSupported, isIphoneHevc, tryReadChaptersToEdl,
|
||||
|
@ -53,7 +54,7 @@ import {
|
|||
getOutPath, toast, errorToast, showFfmpegFail, setFileNameTitle, getOutDir, withBlur,
|
||||
checkDirWriteAccess, dirExists, openDirToast, isMasBuild, isStoreBuild, dragPreventer, doesPlayerSupportFile,
|
||||
isDurationValid, isWindows, filenamify, getOutFileExtension, generateSegFileName, defaultOutSegTemplate,
|
||||
hasDuplicates, havePermissionToReadFile,
|
||||
hasDuplicates, havePermissionToReadFile, openAbout, isCuttingStart, isCuttingEnd,
|
||||
} from './util';
|
||||
import { formatDuration } from './util/duration';
|
||||
import { askForOutDir, askForImportChapters, createNumSegments, createFixedDurationSegments, promptTimeOffset, askForHtml5ifySpeed, askForYouTubeInput, askForFileOpenAction, confirmExtractAllStreamsDialog, cleanupFilesDialog, showDiskFull, showCutFailedDialog, labelSegmentDialog, openYouTubeChaptersDialog } from './dialogs';
|
||||
|
@ -61,19 +62,37 @@ import { openSendReportDialog } from './reporting';
|
|||
import { fallbackLng } from './i18n';
|
||||
import { createSegment, createInitialCutSegments, getCleanCutSegments, getSegApparentStart, findSegmentsAtCursor, sortSegments, invertSegments } from './segments';
|
||||
|
||||
|
||||
import loadingLottie from './7077-magic-flow.json';
|
||||
|
||||
const { extname, parse: parsePath, sep: pathSep, join: pathJoin, normalize: pathNormalize, resolve: pathResolve, isAbsolute: pathIsAbsolute } = window.path;
|
||||
|
||||
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 { extname, parse: parsePath, sep: pathSep, join: pathJoin, normalize: pathNormalize, resolve: pathResolve, isAbsolute: pathIsAbsolute } = window.require('path');
|
||||
const { isDev } = window.constants;
|
||||
|
||||
const { dialog, app } = electron.remote;
|
||||
const ipcEmitter = new EventEmitter();
|
||||
const reEmitEvent = (event, opts) => ipcEmitter.emit('from-main', { event, opts });
|
||||
|
||||
const { focusWindow } = electron.remote.require('./electron');
|
||||
[
|
||||
'file-opened',
|
||||
'close-file',
|
||||
'html5ify',
|
||||
'show-merge-dialog',
|
||||
'set-start-offset',
|
||||
'extract-all-streams',
|
||||
'showStreamsSelector',
|
||||
'importEdlFile',
|
||||
'exportEdlFile',
|
||||
'exportEdlYouTube',
|
||||
'openHelp',
|
||||
'openSettings',
|
||||
'openAbout',
|
||||
'batchConvertFriendlyFormat',
|
||||
'openSendReportDialog',
|
||||
'clearSegments',
|
||||
'createNumSegments',
|
||||
'createFixedDurationSegments',
|
||||
'fixInvalidDuration',
|
||||
'reorderSegsByStartTime',
|
||||
].map((ev) => window.ipc.onMessage(ev, (opts) => reEmitEvent(ev, opts)));
|
||||
|
||||
|
||||
const ffmpegExtractWindow = 60;
|
||||
|
@ -159,7 +178,7 @@ const App = memo(() => {
|
|||
useEffect(() => {
|
||||
const l = language || fallbackLng;
|
||||
i18n.changeLanguage(l).catch(console.error);
|
||||
electron.ipcRenderer.send('setLanguage', l);
|
||||
window.ipc.send('setLanguage', l);
|
||||
}, [language]);
|
||||
|
||||
// Global state
|
||||
|
@ -263,7 +282,7 @@ const App = memo(() => {
|
|||
}, [seekRel, detectedFps]);
|
||||
|
||||
/* useEffect(() => () => {
|
||||
if (dummyVideoPath) unlink(dummyVideoPath).catch(console.error);
|
||||
if (dummyVideoPath) window.fs.unlink(dummyVideoPath).catch(console.error);
|
||||
}, [dummyVideoPath]); */
|
||||
|
||||
// 360 means we don't modify rotation
|
||||
|
@ -874,12 +893,12 @@ const App = memo(() => {
|
|||
try {
|
||||
setWorking(i18n.t('Cleaning up'));
|
||||
|
||||
if (deleteTmpFiles && saved.html5FriendlyPath) await trash(saved.html5FriendlyPath).catch(console.error);
|
||||
if (deleteTmpFiles && saved.dummyVideoPath) await trash(saved.dummyVideoPath).catch(console.error);
|
||||
if (deleteProjectFile && saved.edlFilePath) await trash(saved.edlFilePath).catch(console.error);
|
||||
if (deleteTmpFiles && saved.html5FriendlyPath) await window.util.trash(saved.html5FriendlyPath).catch(console.error);
|
||||
if (deleteTmpFiles && saved.dummyVideoPath) await window.util.trash(saved.dummyVideoPath).catch(console.error);
|
||||
if (deleteProjectFile && saved.edlFilePath) await window.util.trash(saved.edlFilePath).catch(console.error);
|
||||
|
||||
// throw new Error('test');
|
||||
if (deleteOriginal) await trash(saved.filePath);
|
||||
if (deleteOriginal) await window.util.trash(saved.filePath);
|
||||
toast.fire({ icon: 'info', title: i18n.t('Cleanup successful') });
|
||||
} catch (err) {
|
||||
try {
|
||||
|
@ -893,10 +912,10 @@ const App = memo(() => {
|
|||
});
|
||||
|
||||
if (value) {
|
||||
if (deleteTmpFiles && saved.html5FriendlyPath) await unlink(saved.html5FriendlyPath).catch(console.error);
|
||||
if (deleteTmpFiles && saved.dummyVideoPath) await unlink(saved.dummyVideoPath).catch(console.error);
|
||||
if (deleteProjectFile && saved.edlFilePath) await unlink(saved.edlFilePath).catch(console.error);
|
||||
if (deleteOriginal) await unlink(saved.filePath);
|
||||
if (deleteTmpFiles && saved.html5FriendlyPath) await window.fs.unlink(saved.html5FriendlyPath).catch(console.error);
|
||||
if (deleteTmpFiles && saved.dummyVideoPath) await window.fs.unlink(saved.dummyVideoPath).catch(console.error);
|
||||
if (deleteProjectFile && saved.edlFilePath) await window.fs.unlink(saved.edlFilePath).catch(console.error);
|
||||
if (deleteOriginal) await window.fs.unlink(saved.filePath);
|
||||
toast.fire({ icon: 'info', title: i18n.t('Cleanup successful') });
|
||||
}
|
||||
} catch (err2) {
|
||||
|
@ -1202,7 +1221,7 @@ const App = memo(() => {
|
|||
|
||||
async function checkAndSetExistingHtml5FriendlyFile(speed) {
|
||||
const existing = getHtml5ifiedPath(cod, fp, speed);
|
||||
const ret = existing && await exists(existing);
|
||||
const ret = existing && await window.fs.exists(existing);
|
||||
if (ret) {
|
||||
console.log('Found existing supported file', existing);
|
||||
if (speed === 'fastest-audio') {
|
||||
|
@ -1284,7 +1303,7 @@ const App = memo(() => {
|
|||
|
||||
const openedFileEdlPath = getEdlFilePath(fp);
|
||||
|
||||
if (await exists(openedFileEdlPath)) {
|
||||
if (await window.fs.exists(openedFileEdlPath)) {
|
||||
await loadEdlFile(openedFileEdlPath);
|
||||
} else {
|
||||
const edl = await tryReadChaptersToEdl(fp);
|
||||
|
@ -1448,11 +1467,11 @@ const App = memo(() => {
|
|||
document.ondragover = dragPreventer;
|
||||
document.ondragend = dragPreventer;
|
||||
|
||||
electron.ipcRenderer.send('renderer-ready');
|
||||
window.ipc.send('renderer-ready');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
electron.ipcRenderer.send('setAskBeforeClose', askBeforeClose && isFileOpened);
|
||||
window.ipc.send('setAskBeforeClose', askBeforeClose && isFileOpened);
|
||||
}, [askBeforeClose, isFileOpened]);
|
||||
|
||||
const extractSingleStream = useCallback(async (index) => {
|
||||
|
@ -1554,7 +1573,7 @@ const App = memo(() => {
|
|||
const { files } = ev.dataTransfer;
|
||||
const filePaths = Array.from(files).map(f => f.path);
|
||||
|
||||
focusWindow();
|
||||
window.util.focusWindow();
|
||||
|
||||
if (filePaths.length === 1 && filePaths[0].toLowerCase().endsWith('.csv')) {
|
||||
if (!checkFileOpened()) return;
|
||||
|
@ -1652,7 +1671,7 @@ const App = memo(() => {
|
|||
useEffect(() => {
|
||||
function showOpenAndMergeDialog2() {
|
||||
showOpenAndMergeDialog({
|
||||
dialog,
|
||||
dialog: window.util.dialog,
|
||||
defaultPath: outputDir,
|
||||
onMergeClick: mergeFiles,
|
||||
});
|
||||
|
@ -1685,7 +1704,7 @@ const App = memo(() => {
|
|||
filters = [{ name: i18n.t('TXT files'), extensions: [ext, 'txt'] }];
|
||||
}
|
||||
|
||||
const { canceled, filePath: fp } = await dialog.showSaveDialog({ defaultPath: `${new Date().getTime()}.${ext}`, filters });
|
||||
const { canceled, filePath: fp } = await window.util.dialog.showSaveDialog({ defaultPath: `${new Date().getTime()}.${ext}`, filters });
|
||||
if (canceled || !fp) return;
|
||||
console.log('Saving', type, fp);
|
||||
if (type === 'csv') await saveCsv(fp, cutSegments);
|
||||
|
@ -1719,22 +1738,14 @@ const App = memo(() => {
|
|||
else if (type === 'pbf') filters = [{ name: i18n.t('PBF files'), extensions: ['pbf'] }];
|
||||
else if (type === 'mplayer') filters = [{ name: i18n.t('MPlayer EDL'), extensions: ['*'] }];
|
||||
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile'], filters });
|
||||
const { canceled, filePaths } = await window.util.dialog.showOpenDialog({ properties: ['openFile'], filters });
|
||||
if (canceled || filePaths.length < 1) return;
|
||||
await loadEdlFile(filePaths[0], type);
|
||||
}
|
||||
|
||||
function openAbout() {
|
||||
Swal.fire({
|
||||
icon: 'info',
|
||||
title: 'About LosslessCut',
|
||||
text: `You are running version ${app.getVersion()}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function batchConvertFriendlyFormat() {
|
||||
const title = i18n.t('Select files to batch convert to supported format');
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile', 'multiSelections'], title, message: title });
|
||||
const { canceled, filePaths } = await window.util.dialog.showOpenDialog({ properties: ['openFile', 'multiSelections'], title, message: title });
|
||||
if (canceled || filePaths.length < 1) return;
|
||||
|
||||
const failedFiles = [];
|
||||
|
@ -1809,49 +1820,34 @@ const App = memo(() => {
|
|||
const openSendReportDialog2 = () => { openSendReportDialogWithState(); };
|
||||
const closeFile2 = () => { closeFile(); };
|
||||
|
||||
electron.ipcRenderer.on('file-opened', fileOpened);
|
||||
electron.ipcRenderer.on('close-file', closeFile2);
|
||||
electron.ipcRenderer.on('html5ify', html5ifyCurrentFile);
|
||||
electron.ipcRenderer.on('show-merge-dialog', showOpenAndMergeDialog2);
|
||||
electron.ipcRenderer.on('set-start-offset', setStartOffset);
|
||||
electron.ipcRenderer.on('extract-all-streams', extractAllStreams);
|
||||
electron.ipcRenderer.on('showStreamsSelector', showStreamsSelector);
|
||||
electron.ipcRenderer.on('importEdlFile', importEdlFile);
|
||||
electron.ipcRenderer.on('exportEdlFile', exportEdlFile);
|
||||
electron.ipcRenderer.on('exportEdlYouTube', exportEdlYouTube);
|
||||
electron.ipcRenderer.on('openHelp', toggleHelp);
|
||||
electron.ipcRenderer.on('openSettings', toggleSettings);
|
||||
electron.ipcRenderer.on('openAbout', openAbout);
|
||||
electron.ipcRenderer.on('batchConvertFriendlyFormat', batchConvertFriendlyFormat);
|
||||
electron.ipcRenderer.on('openSendReportDialog', openSendReportDialog2);
|
||||
electron.ipcRenderer.on('clearSegments', clearSegments);
|
||||
electron.ipcRenderer.on('createNumSegments', createNumSegments2);
|
||||
electron.ipcRenderer.on('createFixedDurationSegments', createFixedDurationSegments2);
|
||||
electron.ipcRenderer.on('fixInvalidDuration', fixInvalidDuration2);
|
||||
electron.ipcRenderer.on('reorderSegsByStartTime', reorderSegsByStartTime);
|
||||
function fromMain({ event, opts }) {
|
||||
switch (event) {
|
||||
case 'file-opened': fileOpened(undefined, opts); break;
|
||||
case 'close-file': closeFile2(undefined, opts); break;
|
||||
case 'html5ify': html5ifyCurrentFile(undefined, opts); break;
|
||||
case 'show-merge-dialog': showOpenAndMergeDialog2(undefined, opts); break;
|
||||
case 'set-start-offset': setStartOffset(undefined, opts); break;
|
||||
case 'extract-all-streams': extractAllStreams(undefined, opts); break;
|
||||
case 'showStreamsSelector': showStreamsSelector(undefined, opts); break;
|
||||
case 'importEdlFile': importEdlFile(undefined, opts); break;
|
||||
case 'exportEdlFile': exportEdlFile(undefined, opts); break;
|
||||
case 'exportEdlYouTube': exportEdlYouTube(undefined, opts); break;
|
||||
case 'openHelp': toggleHelp(undefined, opts); break;
|
||||
case 'openSettings': toggleSettings(undefined, opts); break;
|
||||
case 'openAbout': openAbout(undefined, opts); break;
|
||||
case 'batchConvertFriendlyFormat': batchConvertFriendlyFormat(undefined, opts); break;
|
||||
case 'openSendReportDialog': openSendReportDialog2(undefined, opts); break;
|
||||
case 'clearSegments': clearSegments(undefined, opts); break;
|
||||
case 'createNumSegments': createNumSegments2(undefined, opts); break;
|
||||
case 'createFixedDurationSegments': createFixedDurationSegments2(undefined, opts); break;
|
||||
case 'fixInvalidDuration': fixInvalidDuration2(undefined, opts); break;
|
||||
case 'reorderSegsByStartTime': reorderSegsByStartTime(undefined, opts); break;
|
||||
default: console.error('Unknown event', event);
|
||||
}
|
||||
}
|
||||
ipcEmitter.on('from-main', fromMain);
|
||||
|
||||
return () => {
|
||||
electron.ipcRenderer.removeListener('file-opened', fileOpened);
|
||||
electron.ipcRenderer.removeListener('close-file', closeFile2);
|
||||
electron.ipcRenderer.removeListener('html5ify', html5ifyCurrentFile);
|
||||
electron.ipcRenderer.removeListener('show-merge-dialog', showOpenAndMergeDialog2);
|
||||
electron.ipcRenderer.removeListener('set-start-offset', setStartOffset);
|
||||
electron.ipcRenderer.removeListener('extract-all-streams', extractAllStreams);
|
||||
electron.ipcRenderer.removeListener('showStreamsSelector', showStreamsSelector);
|
||||
electron.ipcRenderer.removeListener('importEdlFile', importEdlFile);
|
||||
electron.ipcRenderer.removeListener('exportEdlFile', exportEdlFile);
|
||||
electron.ipcRenderer.removeListener('exportEdlYouTube', exportEdlYouTube);
|
||||
electron.ipcRenderer.removeListener('openHelp', toggleHelp);
|
||||
electron.ipcRenderer.removeListener('openSettings', toggleSettings);
|
||||
electron.ipcRenderer.removeListener('openAbout', openAbout);
|
||||
electron.ipcRenderer.removeListener('batchConvertFriendlyFormat', batchConvertFriendlyFormat);
|
||||
electron.ipcRenderer.removeListener('openSendReportDialog', openSendReportDialog2);
|
||||
electron.ipcRenderer.removeListener('clearSegments', clearSegments);
|
||||
electron.ipcRenderer.removeListener('createNumSegments', createNumSegments2);
|
||||
electron.ipcRenderer.removeListener('createFixedDurationSegments', createFixedDurationSegments2);
|
||||
electron.ipcRenderer.removeListener('fixInvalidDuration', fixInvalidDuration2);
|
||||
electron.ipcRenderer.removeListener('reorderSegsByStartTime', reorderSegsByStartTime);
|
||||
};
|
||||
return () => ipcEmitter.removeListener('from-main', fromMain);
|
||||
}, [
|
||||
mergeFiles, outputDir, filePath, customOutDir, startTimeOffset, html5ifyCurrentFile,
|
||||
createDummyVideo, extractAllStreams, userOpenFiles, openSendReportDialogWithState,
|
||||
|
@ -1860,7 +1856,7 @@ const App = memo(() => {
|
|||
]);
|
||||
|
||||
async function showAddStreamSourceDialog() {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile'] });
|
||||
const { canceled, filePaths } = await window.util.dialog.showOpenDialog({ properties: ['openFile'] });
|
||||
if (canceled || filePaths.length < 1) return;
|
||||
await addStreamSourceFile(filePaths[0]);
|
||||
}
|
||||
|
@ -1969,7 +1965,7 @@ const App = memo(() => {
|
|||
|
||||
useEffect(() => {
|
||||
// Testing:
|
||||
// if (isDev) load({ filePath: '/Users/mifi/Downloads/inp.MOV', customOutDir });
|
||||
if (isDev) load({ filePath: '/Users/mifi/Downloads/lofoten.mp4', customOutDir });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import { encodeLiveRawStream, getOneRawFrame } from './ffmpeg';
|
||||
|
||||
// TODO keep everything in electron land?
|
||||
const strtok3 = window.require('strtok3');
|
||||
|
||||
export default ({ path, width: inWidth, height: inHeight, streamIndex }) => {
|
||||
let canvas;
|
||||
|
@ -40,7 +38,8 @@ export default ({ path, width: inWidth, height: inHeight, streamIndex }) => {
|
|||
|
||||
// process.stderr.on('data', data => console.log(data.toString('utf-8')));
|
||||
|
||||
const tokenizer = await strtok3.fromStream(process.stdout);
|
||||
// TODO keep everything in electron land?
|
||||
const tokenizer = await window.strtok3.fromStream(process.stdout);
|
||||
|
||||
const size = width * height * channels;
|
||||
const buf = Buffer.allocUnsafe(size);
|
||||
|
|
|
@ -7,9 +7,7 @@ import CopyClipboardButton from './components/CopyClipboardButton';
|
|||
import { primaryTextColor } from './colors';
|
||||
import Sheet from './Sheet';
|
||||
|
||||
const electron = window.require('electron');
|
||||
|
||||
const { githubLink } = electron.remote.require('./constants');
|
||||
const { githubLink } = window.constants;
|
||||
|
||||
const HelpSheet = memo(({ visible, onTogglePress, ffmpegCommandLog, currentCutSeg }) => {
|
||||
const { t } = useTranslation();
|
||||
|
@ -32,7 +30,7 @@ const HelpSheet = memo(({ visible, onTogglePress, ffmpegCommandLog, currentCutSe
|
|||
|
||||
<p style={{ fontWeight: 'bold' }}>
|
||||
{t('For more help and issues, please go to:')}<br />
|
||||
<span style={{ color: primaryTextColor, cursor: 'pointer' }} role="button" onClick={() => electron.shell.openExternal(githubLink)}>{githubLink}</span>
|
||||
<span style={{ color: primaryTextColor, cursor: 'pointer' }} role="button" onClick={() => window.util.openExternal(githubLink)}>{githubLink}</span>
|
||||
</p>
|
||||
|
||||
<h1>{t('Keyboard & mouse shortcuts')}</h1>
|
||||
|
|
|
@ -7,7 +7,6 @@ import { useTranslation, Trans } from 'react-i18next';
|
|||
import SetCutpointButton from './components/SetCutpointButton';
|
||||
import SimpleModeButton from './components/SimpleModeButton';
|
||||
|
||||
const electron = window.require('electron');
|
||||
|
||||
const NoFileLoaded = memo(({ topBarHeight, bottomBarHeight, mifiLink, toggleHelp, currentCutSeg, simpleMode, toggleSimpleMode }) => {
|
||||
const { t } = useTranslation();
|
||||
|
@ -33,7 +32,7 @@ const NoFileLoaded = memo(({ topBarHeight, bottomBarHeight, mifiLink, toggleHelp
|
|||
<div style={{ position: 'relative', margin: '3vmin', width: '60vmin', height: '20vmin' }}>
|
||||
<iframe src={mifiLink.loadUrl} title="iframe" style={{ background: 'rgba(0,0,0,0)', border: 'none', pointerEvents: 'none', width: '100%', height: '100%', position: 'absolute' }} />
|
||||
{/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */}
|
||||
<div style={{ width: '100%', height: '100%', position: 'absolute', cursor: 'pointer' }} role="button" onClick={() => electron.shell.openExternal(mifiLink.targetUrl)} />
|
||||
<div style={{ width: '100%', height: '100%', position: 'absolute', cursor: 'pointer' }} role="button" onClick={() => window.util.openExternal(mifiLink.targetUrl)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -5,9 +5,6 @@ import { formatDuration } from './util/duration';
|
|||
|
||||
import { captureFrame as ffmpegCaptureFrame } from './ffmpeg';
|
||||
|
||||
const fs = window.require('fs-extra');
|
||||
const mime = window.require('mime-types');
|
||||
|
||||
function getFrameFromVideo(video, format) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
|
@ -33,11 +30,11 @@ export async function captureFrameFfmpeg({ customOutDir, filePath, currentTime,
|
|||
export async function captureFrameFromTag({ customOutDir, filePath, currentTime, captureFormat, video, enableTransferTimestamps }) {
|
||||
const buf = getFrameFromVideo(video, captureFormat);
|
||||
|
||||
const ext = mime.extension(buf.mimetype);
|
||||
const ext = window.util.getExtensionFromMime(buf.mimetype);
|
||||
const time = formatDuration({ seconds: currentTime, fileNameFriendly: true });
|
||||
|
||||
const outPath = getOutPath(customOutDir, filePath, `${time}.${ext}`);
|
||||
await fs.writeFile(outPath, buf);
|
||||
await window.fs.writeFile(outPath, buf);
|
||||
|
||||
if (enableTransferTimestamps) await transferTimestamps(filePath, outPath, currentTime);
|
||||
return outPath;
|
||||
|
|
|
@ -3,16 +3,13 @@ import { FaClipboard } from 'react-icons/fa';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { motion, useAnimation } from 'framer-motion';
|
||||
|
||||
const electron = window.require('electron');
|
||||
const { clipboard } = electron;
|
||||
|
||||
const CopyClipboardButton = memo(({ text, style }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const animation = useAnimation();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
clipboard.writeText(text);
|
||||
window.clipboard.writeText(text);
|
||||
animation.start({
|
||||
scale: [1, 2, 1],
|
||||
transition: { duration: 0.3 },
|
||||
|
|
|
@ -8,10 +8,6 @@ import { parseDuration } from './util/duration';
|
|||
import { parseYouTube } from './edlFormats';
|
||||
import CopyClipboardButton from './components/CopyClipboardButton';
|
||||
|
||||
const electron = window.require('electron'); // eslint-disable-line
|
||||
|
||||
const { dialog } = electron.remote;
|
||||
|
||||
const ReactSwal = withReactContent(Swal);
|
||||
|
||||
export async function promptTimeOffset(inputValue) {
|
||||
|
@ -86,7 +82,7 @@ export async function askForYouTubeInput() {
|
|||
}
|
||||
|
||||
export async function askForOutDir(defaultPath) {
|
||||
const { filePaths } = await dialog.showOpenDialog({
|
||||
const { filePaths } = await window.dialog.showOpenDialog({
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
defaultPath,
|
||||
title: i18n.t('Where do you want to save output files?'),
|
||||
|
|
|
@ -4,35 +4,32 @@ import pify from 'pify';
|
|||
import { parseCuesheet, parseXmeml, parseCsv, parsePbf, parseMplayerEdl } from './edlFormats';
|
||||
import { formatDuration } from './util/duration';
|
||||
|
||||
const fs = window.require('fs-extra');
|
||||
const cueParser = window.require('cue-parser');
|
||||
|
||||
const csvStringifyAsync = pify(csvStringify);
|
||||
|
||||
export async function loadCsv(path) {
|
||||
return parseCsv(await fs.readFile(path, 'utf-8'));
|
||||
return parseCsv(await window.fs.readFile(path, 'utf-8'));
|
||||
}
|
||||
|
||||
export async function loadXmeml(path) {
|
||||
return parseXmeml(await fs.readFile(path, 'utf-8'));
|
||||
return parseXmeml(await window.fs.readFile(path, 'utf-8'));
|
||||
}
|
||||
|
||||
export async function loadPbf(path) {
|
||||
return parsePbf(await fs.readFile(path, 'utf-8'));
|
||||
return parsePbf(await window.fs.readFile(path, 'utf-8'));
|
||||
}
|
||||
|
||||
export async function loadMplayerEdl(path) {
|
||||
return parseMplayerEdl(await fs.readFile(path, 'utf-8'));
|
||||
return parseMplayerEdl(await window.fs.readFile(path, 'utf-8'));
|
||||
}
|
||||
|
||||
export async function loadCue(path) {
|
||||
return parseCuesheet(cueParser.parse(path));
|
||||
return parseCuesheet(window.util.parseCueFile(path));
|
||||
}
|
||||
|
||||
export async function saveCsv(path, cutSegments) {
|
||||
const rows = cutSegments.map(({ start, end, name }) => [start, end, name]);
|
||||
const str = await csvStringifyAsync(rows);
|
||||
await fs.writeFile(path, str);
|
||||
await window.fs.writeFile(path, str);
|
||||
}
|
||||
|
||||
const formatDurationStr = (duration) => (duration != null ? formatDuration({ seconds: duration }) : '');
|
||||
|
@ -41,10 +38,10 @@ const mapSegments = (segments) => segments.map(({ start, end, name }) => [format
|
|||
|
||||
export async function saveCsvHuman(path, cutSegments) {
|
||||
const str = await csvStringifyAsync(mapSegments(cutSegments));
|
||||
await fs.writeFile(path, str);
|
||||
await window.fs.writeFile(path, str);
|
||||
}
|
||||
|
||||
export async function saveTsv(path, cutSegments) {
|
||||
const str = await csvStringifyAsync(mapSegments(cutSegments), { delimiter: '\t' });
|
||||
await fs.writeFile(path, str);
|
||||
await window.fs.writeFile(path, str);
|
||||
}
|
||||
|
|
611
src/ffmpeg.js
611
src/ffmpeg.js
|
@ -1,580 +1,33 @@
|
|||
import pMap from 'p-map';
|
||||
import flatMap from 'lodash/flatMap';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import moment from 'moment';
|
||||
import i18n from 'i18next';
|
||||
import Timecode from 'smpte-timecode';
|
||||
|
||||
import { getOutPath, isDurationValid, getExtensionForFormat } from './util';
|
||||
|
||||
const execa = window.require('execa');
|
||||
const { join } = window.require('path');
|
||||
const fileType = window.require('file-type');
|
||||
const readChunk = window.require('read-chunk');
|
||||
const readline = window.require('readline');
|
||||
const isDev = window.require('electron-is-dev');
|
||||
const os = window.require('os');
|
||||
|
||||
|
||||
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 platform = os.platform();
|
||||
|
||||
if (platform === 'darwin') {
|
||||
return isDev ? `ffmpeg-mac/${cmd}` : join(window.process.resourcesPath, cmd);
|
||||
}
|
||||
|
||||
const exeName = platform === 'win32' ? `${cmd}.exe` : cmd;
|
||||
return isDev
|
||||
? `node_modules/ffmpeg-ffprobe-static/${exeName}`
|
||||
: join(window.process.resourcesPath, `node_modules/ffmpeg-ffprobe-static/${exeName}`);
|
||||
}
|
||||
|
||||
export const getFfmpegPath = () => getFfPath('ffmpeg');
|
||||
export const getFfprobePath = () => getFfPath('ffprobe');
|
||||
|
||||
export async function runFfprobe(args) {
|
||||
const ffprobePath = getFfprobePath();
|
||||
console.log(getFfCommandLine('ffprobe', args));
|
||||
return execa(ffprobePath, args);
|
||||
}
|
||||
|
||||
export function runFfmpeg(args) {
|
||||
const ffmpegPath = getFfmpegPath();
|
||||
console.log(getFfCommandLine('ffmpeg', args));
|
||||
return execa(ffmpegPath, args);
|
||||
}
|
||||
|
||||
|
||||
export function handleProgress(process, cutDuration, onProgress) {
|
||||
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) return;
|
||||
|
||||
const str = match[1];
|
||||
// console.log(str);
|
||||
const progressTime = Math.max(0, moment.duration(str).asSeconds());
|
||||
// console.log(progressTime);
|
||||
const progress = cutDuration ? progressTime / cutDuration : 0;
|
||||
onProgress(progress);
|
||||
} catch (err) {
|
||||
console.log('Failed to parse ffmpeg progress line', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function isCuttingStart(cutFrom) {
|
||||
return cutFrom > 0;
|
||||
}
|
||||
|
||||
export function isCuttingEnd(cutTo, duration) {
|
||||
if (!isDurationValid(duration)) return true;
|
||||
return cutTo < duration;
|
||||
}
|
||||
|
||||
function getIntervalAroundTime(time, window) {
|
||||
return {
|
||||
from: Math.max(time - window / 2, 0),
|
||||
to: time + window / 2,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readFrames({ filePath, aroundTime, window, stream }) {
|
||||
let intervalsArgs = [];
|
||||
if (aroundTime != null) {
|
||||
const { from, to } = getIntervalAroundTime(aroundTime, window);
|
||||
intervalsArgs = ['-read_intervals', `${from}%${to}`];
|
||||
}
|
||||
const { stdout } = await runFfprobe(['-v', 'error', ...intervalsArgs, '-show_packets', '-select_streams', stream, '-show_entries', 'packet=pts_time,flags', '-of', 'json', filePath]);
|
||||
const packetsFiltered = JSON.parse(stdout).packets
|
||||
.map(p => ({ keyframe: p.flags[0] === 'K', time: parseFloat(p.pts_time, 10) }))
|
||||
.filter(p => !Number.isNaN(p.time));
|
||||
|
||||
return sortBy(packetsFiltered, 'time');
|
||||
}
|
||||
|
||||
// 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
|
||||
export function getSafeCutTime(frames, cutTime, nextMode) {
|
||||
const sigma = 0.01;
|
||||
const isCloseTo = (time1, time2) => Math.abs(time1 - time2) < sigma;
|
||||
|
||||
let index;
|
||||
|
||||
if (frames.length < 2) throw new Error(i18n.t('Less than 2 frames found'));
|
||||
|
||||
if (nextMode) {
|
||||
index = frames.findIndex(f => f.keyframe && f.time >= cutTime - sigma);
|
||||
if (index === -1) throw new Error(i18n.t('Failed to find next keyframe'));
|
||||
if (index >= frames.length - 1) throw new Error(i18n.t('We are on the last frame'));
|
||||
const { time } = frames[index];
|
||||
if (isCloseTo(time, cutTime)) {
|
||||
return undefined; // Already on keyframe, no need to modify cut time
|
||||
}
|
||||
return time;
|
||||
}
|
||||
|
||||
const findReverseIndex = (arr, cb) => {
|
||||
const ret = [...arr].reverse().findIndex(cb);
|
||||
if (ret === -1) return -1;
|
||||
return arr.length - 1 - ret;
|
||||
};
|
||||
|
||||
index = findReverseIndex(frames, f => f.time <= cutTime + sigma);
|
||||
if (index === -1) throw new Error(i18n.t('Failed to find any prev frame'));
|
||||
if (index === 0) throw new Error(i18n.t('We are on the first frame'));
|
||||
|
||||
if (index === frames.length - 1) {
|
||||
// Last frame of video, no need to modify cut time
|
||||
return undefined;
|
||||
}
|
||||
if (frames[index + 1].keyframe) {
|
||||
// Already on frame before keyframe, no need to modify cut time
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// We are not on a frame before keyframe, look for preceding keyframe instead
|
||||
index = findReverseIndex(frames, f => f.keyframe && f.time <= cutTime + sigma);
|
||||
if (index === -1) throw new Error(i18n.t('Failed to find any prev keyframe'));
|
||||
if (index === 0) throw new Error(i18n.t('We are on the first keyframe'));
|
||||
|
||||
// Use frame before the found keyframe
|
||||
return frames[index - 1].time;
|
||||
}
|
||||
|
||||
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;
|
||||
const nearestFrame = sortBy(keyframes, keyframe => (direction > 0 ? keyframe.time - time : time - keyframe.time))[0];
|
||||
if (!nearestFrame) return undefined;
|
||||
return nearestFrame.time;
|
||||
}
|
||||
|
||||
export async function tryReadChaptersToEdl(filePath) {
|
||||
try {
|
||||
const { stdout } = await runFfprobe(['-i', filePath, '-show_chapters', '-print_format', 'json']);
|
||||
return JSON.parse(stdout).chapters.map((chapter) => {
|
||||
const start = parseFloat(chapter.start_time);
|
||||
const end = parseFloat(chapter.end_time);
|
||||
if (Number.isNaN(start) || Number.isNaN(end)) return undefined;
|
||||
|
||||
const name = chapter.tags && typeof chapter.tags.title === 'string' ? chapter.tags.title : undefined;
|
||||
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
name,
|
||||
};
|
||||
}).filter((it) => it);
|
||||
} catch (err) {
|
||||
console.error('Failed to read chapters from file', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFormatData(filePath) {
|
||||
console.log('getFormatData', filePath);
|
||||
|
||||
const { stdout } = await runFfprobe([
|
||||
'-of', 'json', '-show_format', '-i', filePath,
|
||||
]);
|
||||
return JSON.parse(stdout).format;
|
||||
}
|
||||
|
||||
|
||||
export async function getDuration(filePath) {
|
||||
return parseFloat((await getFormatData(filePath)).duration);
|
||||
}
|
||||
|
||||
export async function createChaptersFromSegments({ segmentPaths, chapterNames }) {
|
||||
if (chapterNames) {
|
||||
try {
|
||||
const durations = await pMap(segmentPaths, (segmentPath) => getDuration(segmentPath), { concurrency: 3 });
|
||||
let timeAt = 0;
|
||||
return durations.map((duration, i) => {
|
||||
const ret = { start: timeAt, end: timeAt + duration, name: chapterNames[i] };
|
||||
timeAt += duration;
|
||||
return ret;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to create chapters from segments', err);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* ffmpeg only supports encoding certain formats, and some of the detected input
|
||||
* formats are not the same as the names used for encoding.
|
||||
* Therefore we have to map between detected format and encode format
|
||||
* See also ffmpeg -formats
|
||||
*/
|
||||
function mapFormat(requestedFormat) {
|
||||
switch (requestedFormat) {
|
||||
// These two cmds produce identical output, so we assume that encoding "ipod" means encoding m4a
|
||||
// ffmpeg -i example.aac -c copy OutputFile2.m4a
|
||||
// ffmpeg -i example.aac -c copy -f ipod OutputFile.m4a
|
||||
// See also https://github.com/mifi/lossless-cut/issues/28
|
||||
case 'm4a': return 'ipod';
|
||||
case 'aac': return 'ipod';
|
||||
default: return requestedFormat;
|
||||
}
|
||||
}
|
||||
|
||||
function determineOutputFormat(ffprobeFormats, ft) {
|
||||
if (ffprobeFormats.includes(ft.ext)) return ft.ext;
|
||||
return ffprobeFormats[0] || undefined;
|
||||
}
|
||||
|
||||
export async function getDefaultOutFormat(filePath, formatData) {
|
||||
const formatsStr = formatData.format_name;
|
||||
console.log('formats', formatsStr);
|
||||
const formats = (formatsStr || '').split(',');
|
||||
|
||||
// ffprobe sometimes returns a list of formats, try to be a bit smarter about it.
|
||||
const bytes = await readChunk(filePath, 0, 4100);
|
||||
const ft = fileType(bytes) || {};
|
||||
console.log(`fileType detected format ${JSON.stringify(ft)}`);
|
||||
const assumedFormat = determineOutputFormat(formats, ft);
|
||||
return mapFormat(assumedFormat);
|
||||
}
|
||||
|
||||
export async function getAllStreams(filePath) {
|
||||
const { stdout } = await runFfprobe([
|
||||
'-of', 'json', '-show_entries', 'stream', '-i', filePath,
|
||||
]);
|
||||
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
function getPreferredCodecFormat(codec, type) {
|
||||
const map = {
|
||||
mp3: 'mp3',
|
||||
opus: 'opus',
|
||||
vorbis: 'ogg',
|
||||
h264: 'mp4',
|
||||
hevc: 'mp4',
|
||||
eac3: 'eac3',
|
||||
|
||||
subrip: 'srt',
|
||||
|
||||
// See mapFormat
|
||||
m4a: 'ipod',
|
||||
aac: 'ipod',
|
||||
|
||||
// TODO add more
|
||||
// TODO allow user to change?
|
||||
};
|
||||
|
||||
const format = map[codec];
|
||||
if (format) return { ext: getExtensionForFormat(format), format };
|
||||
if (type === 'video') return { ext: 'mkv', format: 'matroska' };
|
||||
if (type === 'audio') return { ext: 'mka', format: 'matroska' };
|
||||
if (type === 'subtitle') return { ext: 'mks', format: 'matroska' };
|
||||
if (type === 'data') return { ext: 'bin', format: 'data' }; // https://superuser.com/questions/1243257/save-data-stream
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/questions/32922226/extract-every-audio-and-subtitles-from-a-video-with-ffmpeg
|
||||
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,
|
||||
type: s.codec_type,
|
||||
format: getPreferredCodecFormat(s.codec_name, s.codec_type),
|
||||
}))
|
||||
.filter(it => it && it.format && it.index != null);
|
||||
|
||||
// console.log(outStreams);
|
||||
|
||||
const streamArgs = flatMap(outStreams, ({
|
||||
index, codec, type, format: { format, ext },
|
||||
}) => [
|
||||
'-map', `0:${index}`, '-c', 'copy', '-f', format, '-y', getOutPath(customOutDir, filePath, `stream-${index}-${type}-${codec}.${ext}`),
|
||||
]);
|
||||
|
||||
const ffmpegArgs = [
|
||||
'-hide_banner',
|
||||
|
||||
'-i', filePath,
|
||||
...streamArgs,
|
||||
];
|
||||
|
||||
// TODO progress
|
||||
const { stdout } = await runFfmpeg(ffmpegArgs);
|
||||
console.log(stdout);
|
||||
}
|
||||
|
||||
async function renderThumbnail(filePath, timestamp) {
|
||||
const args = [
|
||||
'-ss', timestamp,
|
||||
'-i', filePath,
|
||||
'-vf', 'scale=-2:200',
|
||||
'-f', 'image2',
|
||||
'-vframes', '1',
|
||||
'-q:v', '10',
|
||||
'-',
|
||||
];
|
||||
|
||||
const ffmpegPath = await getFfmpegPath();
|
||||
const { stdout } = await execa(ffmpegPath, args, { encoding: null });
|
||||
|
||||
const blob = new Blob([stdout], { type: 'image/jpeg' });
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
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);
|
||||
const endTime = new Date().getTime() / 1000;
|
||||
onThumbnail({ time: from, url });
|
||||
|
||||
// Aim for max 3 sec to render all
|
||||
const numThumbs = Math.floor(Math.min(Math.max(3 / (endTime - startTime), 3), 10));
|
||||
// console.log(numThumbs);
|
||||
|
||||
const thumbTimes = Array(numThumbs - 1).fill().map((unused, i) => (from + ((duration * (i + 1)) / (numThumbs))));
|
||||
// console.log(thumbTimes);
|
||||
|
||||
await pMap(thumbTimes, async (time) => {
|
||||
url = await renderThumbnail(filePath, time);
|
||||
onThumbnail({ time, url });
|
||||
}, { concurrency: 2 });
|
||||
}
|
||||
|
||||
|
||||
export async function renderWaveformPng({ filePath, aroundTime, window, color }) {
|
||||
const { from, to } = getIntervalAroundTime(aroundTime, window);
|
||||
|
||||
const args1 = [
|
||||
'-i', filePath,
|
||||
'-ss', from,
|
||||
'-t', to - from,
|
||||
'-c', 'copy',
|
||||
'-vn',
|
||||
'-map', 'a:0',
|
||||
'-f', 'matroska', // mpegts doesn't support vorbis etc
|
||||
'-',
|
||||
];
|
||||
|
||||
const args2 = [
|
||||
'-i', '-',
|
||||
'-filter_complex', `aformat=channel_layouts=mono,showwavespic=s=640x120:scale=sqrt:colors=${color}`,
|
||||
'-frames:v', '1',
|
||||
'-vcodec', 'png',
|
||||
'-f', 'image2',
|
||||
'-',
|
||||
];
|
||||
|
||||
console.log(getFfCommandLine('ffmpeg1', args1));
|
||||
console.log(getFfCommandLine('ffmpeg2', args2));
|
||||
|
||||
let ps1;
|
||||
let ps2;
|
||||
try {
|
||||
const ffmpegPath = getFfmpegPath();
|
||||
ps1 = execa(ffmpegPath, args1, { encoding: null, buffer: false });
|
||||
ps2 = execa(ffmpegPath, args2, { encoding: null });
|
||||
ps1.stdout.pipe(ps2.stdin);
|
||||
|
||||
const { stdout } = await ps2;
|
||||
|
||||
const blob = new Blob([stdout], { type: 'image/png' });
|
||||
|
||||
return {
|
||||
url: URL.createObjectURL(blob),
|
||||
from,
|
||||
aroundTime,
|
||||
to,
|
||||
};
|
||||
} catch (err) {
|
||||
if (ps1) ps1.kill();
|
||||
if (ps2) ps2.kill();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractWaveform({ filePath, outPath }) {
|
||||
const numSegs = 10;
|
||||
const duration = 60 * 60;
|
||||
const maxLen = 0.1;
|
||||
const segments = Array(numSegs).fill().map((unused, i) => [i * (duration / numSegs), Math.min(duration / numSegs, maxLen)]);
|
||||
|
||||
// https://superuser.com/questions/681885/how-can-i-remove-multiple-segments-from-a-video-using-ffmpeg
|
||||
let filter = segments.map(([from, len], i) => `[0:a]atrim=start=${from}:end=${from + len},asetpts=PTS-STARTPTS[a${i}]`).join(';');
|
||||
filter += ';';
|
||||
filter += segments.map((arr, i) => `[a${i}]`).join('');
|
||||
filter += `concat=n=${segments.length}:v=0:a=1[out]`;
|
||||
|
||||
console.time('ffmpeg');
|
||||
await runFfmpeg([
|
||||
'-i',
|
||||
filePath,
|
||||
'-filter_complex',
|
||||
filter,
|
||||
'-map',
|
||||
'[out]',
|
||||
'-f', 'wav',
|
||||
'-y',
|
||||
outPath,
|
||||
]);
|
||||
console.timeEnd('ffmpeg');
|
||||
}
|
||||
|
||||
// See also capture-frame.js
|
||||
export async function captureFrame({ timestamp, videoPath, outPath }) {
|
||||
const args = [
|
||||
'-ss', timestamp,
|
||||
'-i', videoPath,
|
||||
'-vframes', '1',
|
||||
'-q:v', '3',
|
||||
'-y', outPath,
|
||||
];
|
||||
|
||||
const ffmpegPath = getFfmpegPath();
|
||||
await execa(ffmpegPath, args, { encoding: null });
|
||||
}
|
||||
|
||||
// https://www.ffmpeg.org/doxygen/3.2/libavutil_2utils_8c_source.html#l00079
|
||||
export const defaultProcessedCodecTypes = [
|
||||
'video',
|
||||
'audio',
|
||||
'subtitle',
|
||||
'attachment',
|
||||
];
|
||||
|
||||
export const isMov = (format) => ['ismv', 'ipod', 'mp4', 'mov'].includes(format);
|
||||
|
||||
export function isStreamThumbnail(stream) {
|
||||
return stream && stream.disposition && stream.disposition.attached_pic === 1;
|
||||
}
|
||||
|
||||
export function isAudioSupported(streams) {
|
||||
const audioStreams = streams.filter(stream => stream.codec_type === 'audio');
|
||||
if (audioStreams.length === 0) return true;
|
||||
// TODO this could be improved
|
||||
return audioStreams.some(stream => !['ac3'].includes(stream.codec_name));
|
||||
}
|
||||
|
||||
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'];
|
||||
const modelTag = format.tags && format.tags['com.apple.quicktime.model'];
|
||||
return (makeTag === 'Apple' && modelTag.startsWith('iPhone'));
|
||||
}
|
||||
|
||||
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);
|
||||
const den = parseInt(match[2], 10);
|
||||
if (den > 0) return num / den;
|
||||
}
|
||||
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: execa(getFfmpegPath(), args, execaOpts),
|
||||
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);
|
||||
const t = Timecode(str, frameRate ? parseFloat(frameRate.toFixed(3)) : undefined);
|
||||
if (!t) return undefined;
|
||||
const seconds = ((t.hours * 60) + t.minutes) * 60 + t.seconds + (t.frames / t.frameRate);
|
||||
return Number.isFinite(seconds) ? seconds : undefined;
|
||||
}
|
||||
|
||||
export function getTimecodeFromStreams(streams) {
|
||||
console.log('Trying to load timecode');
|
||||
let foundTimecode;
|
||||
streams.find((stream) => {
|
||||
try {
|
||||
if (stream.tags && stream.tags.timecode) {
|
||||
const fps = getStreamFps(stream);
|
||||
foundTimecode = parseTimecode(stream.tags.timecode, fps);
|
||||
console.log('Loaded timecode', stream.tags.timecode, 'from stream', stream.index);
|
||||
return true;
|
||||
}
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
// console.warn('Failed to parse timecode from file streams', err);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
return foundTimecode;
|
||||
}
|
||||
// TODO
|
||||
export const {
|
||||
getFfCommandLine,
|
||||
getFfmpegPath,
|
||||
getFfprobePath,
|
||||
runFfprobe,
|
||||
runFfmpeg,
|
||||
handleProgress,
|
||||
readFrames,
|
||||
getSafeCutTime,
|
||||
findNearestKeyFrameTime,
|
||||
tryReadChaptersToEdl,
|
||||
getFormatData,
|
||||
getDuration,
|
||||
createChaptersFromSegments,
|
||||
getDefaultOutFormat,
|
||||
getAllStreams,
|
||||
extractStreams,
|
||||
renderThumbnails,
|
||||
renderWaveformPng,
|
||||
extractWaveform,
|
||||
captureFrame,
|
||||
defaultProcessedCodecTypes,
|
||||
isMov,
|
||||
isStreamThumbnail,
|
||||
isAudioSupported,
|
||||
isIphoneHevc,
|
||||
getStreamFps,
|
||||
getOneRawFrame,
|
||||
encodeLiveRawStream,
|
||||
getTimecodeFromStreams,
|
||||
} = window.ffmpeg;
|
||||
|
|
|
@ -1,24 +1,22 @@
|
|||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
// TODO pull out?
|
||||
const { remote } = window.require('electron');
|
||||
const { Menu } = remote;
|
||||
|
||||
// https://github.com/transflow/use-electron-context-menu
|
||||
|
||||
// TODO pull out?
|
||||
const { buildMenuFromTemplate } = window.util;
|
||||
|
||||
export default function useContextMenu(
|
||||
ref,
|
||||
template,
|
||||
options = {},
|
||||
) {
|
||||
const menu = useMemo(() => Menu.buildFromTemplate(template), [template]);
|
||||
const menu = useMemo(() => buildMenuFromTemplate(template), [template]);
|
||||
|
||||
const { x, y, onContext, onClose } = options;
|
||||
|
||||
useEffect(() => {
|
||||
function handleContext(e) {
|
||||
menu.popup({
|
||||
window: remote.getCurrentWindow(),
|
||||
x,
|
||||
y,
|
||||
callback: onClose,
|
||||
|
|
|
@ -4,14 +4,15 @@ import flatMapDeep from 'lodash/flatMapDeep';
|
|||
import sum from 'lodash/sum';
|
||||
import pMap from 'p-map';
|
||||
|
||||
import { getOutPath, transferTimestamps, getOutFileExtension, getOutDir } from '../util';
|
||||
import { isCuttingStart, isCuttingEnd, handleProgress, getFfCommandLine, getFfmpegPath, getDuration, runFfmpeg, createChaptersFromSegments } from '../ffmpeg';
|
||||
import { getOutPath, transferTimestamps, getOutFileExtension, getOutDir, isCuttingStart, isCuttingEnd } from '../util';
|
||||
import { handleProgress, getFfCommandLine, getFfmpegPath, getDuration, runFfmpeg, createChaptersFromSegments } from '../ffmpeg';
|
||||
|
||||
const execa = window.require('execa');
|
||||
const os = window.require('os');
|
||||
const { join } = window.require('path');
|
||||
const fs = window.require('fs-extra');
|
||||
const stringToStream = window.require('string-to-stream');
|
||||
const { join } = window.path;
|
||||
const { writeFile, unlink } = window.path;
|
||||
|
||||
// TODO improve
|
||||
const { execa } = window.execa;
|
||||
const { stringToStream } = window.util;
|
||||
|
||||
async function writeChaptersFfmetadata(outDir, chapters) {
|
||||
if (!chapters) return undefined;
|
||||
|
@ -23,7 +24,7 @@ async function writeChaptersFfmetadata(outDir, chapters) {
|
|||
return `[CHAPTER]\nTIMEBASE=1/1000\nSTART=${Math.floor(start * 1000)}\nEND=${Math.floor(end * 1000)}\ntitle=${nameOut}`;
|
||||
}).join('\n\n');
|
||||
// console.log(ffmetadata);
|
||||
await fs.writeFile(path, ffmetadata);
|
||||
await writeFile(path, ffmetadata);
|
||||
return path;
|
||||
}
|
||||
|
||||
|
@ -244,7 +245,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
|||
const { stdout } = await process;
|
||||
console.log(stdout);
|
||||
} finally {
|
||||
if (ffmetadataPath) await fs.unlink(ffmetadataPath).catch((err) => console.error('Failed to delete', ffmetadataPath, err));
|
||||
if (ffmetadataPath) await unlink(ffmetadataPath).catch((err) => console.error('Failed to delete', ffmetadataPath, err));
|
||||
}
|
||||
|
||||
await optionalTransferTimestamps(paths[0], outPath);
|
||||
|
@ -259,7 +260,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
|||
const chapters = await createChaptersFromSegments({ segmentPaths, chapterNames });
|
||||
|
||||
await mergeFiles({ paths: segmentPaths, outDir, outPath, outFormat, allStreams: true, ffmpegExperimental, onProgress, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge });
|
||||
if (autoDeleteMergedSegments) await pMap(segmentPaths, path => fs.unlink(path), { concurrency: 5 });
|
||||
if (autoDeleteMergedSegments) await pMap(segmentPaths, path => unlink(path), { concurrency: 5 });
|
||||
}, [filePath, mergeFiles]);
|
||||
|
||||
const html5ify = useCallback(async ({ filePath: specificFilePath, outPath, video, audio, onProgress }) => {
|
||||
|
@ -268,7 +269,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
|||
let videoArgs;
|
||||
let audioArgs;
|
||||
|
||||
const isMac = os.platform() === 'darwin';
|
||||
const isMac = window.util.platform === 'darwin';
|
||||
|
||||
switch (video) {
|
||||
case 'hq': {
|
||||
|
|
|
@ -3,10 +3,8 @@ import i18n from 'i18next';
|
|||
|
||||
import { errorToast } from '../util';
|
||||
|
||||
const electron = window.require('electron'); // eslint-disable-line
|
||||
const isDev = window.require('electron-is-dev');
|
||||
|
||||
const configStore = electron.remote.require('./configStore');
|
||||
const { isDev } = window.constants;
|
||||
const { configStore } = window;
|
||||
|
||||
export default () => {
|
||||
const firstUpdateRef = useRef(true);
|
||||
|
|
|
@ -1,17 +1,14 @@
|
|||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
const Backend = window.require('i18next-fs-backend');
|
||||
|
||||
const electron = window.require('electron'); // eslint-disable-line
|
||||
|
||||
const { commonI18nOptions, fallbackLng, loadPath, addPath } = electron.remote.require('./i18n-common');
|
||||
const { commonI18nOptions, fallbackLng, loadPath, addPath, Backend } = window.i18n;
|
||||
|
||||
export { fallbackLng };
|
||||
|
||||
// https://github.com/i18next/i18next/issues/869
|
||||
i18n
|
||||
.use(Backend)
|
||||
// TODO
|
||||
// .use(Backend)
|
||||
// detect user language
|
||||
// learn more: https://github.com/i18next/i18next-browser-languageDetector
|
||||
// TODO disabled for now because translations need more reviewing https://github.com/mifi/lossless-cut/issues/346
|
||||
|
|
|
@ -8,8 +8,8 @@ import './fonts.css';
|
|||
import './main.css';
|
||||
|
||||
|
||||
const electron = window.require('electron');
|
||||
console.log('Version', window.util.getAppVersion());
|
||||
|
||||
console.log('Version', electron.remote.app.getVersion());
|
||||
|
||||
ReactDOM.render(<ErrorBoundary><Suspense fallback={<div />}><App /></Suspense></ErrorBoundary>, document.getElementById('root'));
|
||||
window.init.preload().then(() => {
|
||||
ReactDOM.render(<ErrorBoundary><Suspense fallback={<div />}><App /></Suspense></ErrorBoundary>, document.getElementById('root'));
|
||||
}).catch(console.error);
|
||||
|
|
|
@ -7,20 +7,17 @@ import { Trans } from 'react-i18next';
|
|||
import CopyClipboardButton from './components/CopyClipboardButton';
|
||||
import { isStoreBuild, isMasBuild, isWindowsStoreBuild } from './util';
|
||||
|
||||
const electron = window.require('electron'); // eslint-disable-line
|
||||
const os = window.require('os');
|
||||
|
||||
|
||||
const ReactSwal = withReactContent(Swal);
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export function openSendReportDialog(err, state) {
|
||||
const reportInstructions = isStoreBuild
|
||||
? <p><Trans>Please send an email to <span style={{ fontWeight: 'bold' }} role="button" onClick={() => electron.shell.openExternal('mailto:losslesscut@yankee.no')}>losslesscut@yankee.no</span> where you describe what you were doing.</Trans></p>
|
||||
: <p><Trans>Please create an issue at <span style={{ fontWeight: 'bold' }} role="button" onClick={() => electron.shell.openExternal('https://github.com/mifi/lossless-cut/issues')}>https://github.com/mifi/lossless-cut/issues</span> where you describe what you were doing.</Trans></p>;
|
||||
? <p><Trans>Please send an email to <span style={{ fontWeight: 'bold' }} role="button" onClick={() => window.util.openExternal('mailto:losslesscut@yankee.no')}>losslesscut@yankee.no</span> where you describe what you were doing.</Trans></p>
|
||||
: <p><Trans>Please create an issue at <span style={{ fontWeight: 'bold' }} role="button" onClick={() => window.util.openExternal('https://github.com/mifi/lossless-cut/issues')}>https://github.com/mifi/lossless-cut/issues</span> where you describe what you were doing.</Trans></p>;
|
||||
|
||||
const platform = os.platform();
|
||||
const version = electron.remote.app.getVersion();
|
||||
const { platform } = window.util;
|
||||
const version = window.util.getAppVersion();
|
||||
|
||||
const text = `${err ? err.stack : 'No error'}\n\n${JSON.stringify({
|
||||
err: err && {
|
||||
|
|
111
src/util.js
111
src/util.js
|
@ -2,62 +2,21 @@ import Swal from 'sweetalert2';
|
|||
import i18n from 'i18next';
|
||||
import lodashTemplate from 'lodash/template';
|
||||
|
||||
const path = window.require('path');
|
||||
const fs = window.require('fs-extra');
|
||||
const open = window.require('open');
|
||||
const os = window.require('os');
|
||||
export const {
|
||||
isWindows,
|
||||
getOutDir,
|
||||
getOutFileExtension,
|
||||
transferTimestamps,
|
||||
dirExists,
|
||||
checkDirWriteAccess,
|
||||
havePermissionToReadFile,
|
||||
getOutPath,
|
||||
getExtensionForFormat,
|
||||
isMasBuild,
|
||||
isWindowsStoreBuild,
|
||||
isStoreBuild,
|
||||
} = window.util;
|
||||
|
||||
export function getOutDir(customOutDir, filePath) {
|
||||
if (customOutDir) return customOutDir;
|
||||
if (filePath) return path.dirname(filePath);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getOutPath(customOutDir, filePath, nameSuffix) {
|
||||
if (!filePath) return undefined;
|
||||
const parsed = path.parse(filePath);
|
||||
|
||||
return path.join(getOutDir(customOutDir, filePath), `${parsed.name}-${nameSuffix}`);
|
||||
}
|
||||
|
||||
export async function havePermissionToReadFile(filePath) {
|
||||
try {
|
||||
const fd = await fs.open(filePath, 'r');
|
||||
try {
|
||||
await fs.close(fd);
|
||||
} catch (err) {
|
||||
console.error('Failed to close fd', err);
|
||||
}
|
||||
} catch (err) {
|
||||
if (['EPERM', 'EACCES'].includes(err.code)) return false;
|
||||
console.error(err);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function checkDirWriteAccess(dirPath) {
|
||||
try {
|
||||
await fs.access(dirPath, fs.constants.W_OK);
|
||||
} catch (err) {
|
||||
if (err.code === 'EPERM') return false; // Thrown on Mac (MAS build) when user has not yet allowed access
|
||||
if (err.code === 'EACCES') return false; // Thrown on Linux when user doesn't have access to output dir
|
||||
console.error(err);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function dirExists(dirPath) {
|
||||
return (await fs.exists(dirPath)) && (await fs.lstat(dirPath)).isDirectory();
|
||||
}
|
||||
|
||||
export async function transferTimestamps(inPath, outPath, offset = 0) {
|
||||
try {
|
||||
const { atime, mtime } = await fs.stat(inPath);
|
||||
await fs.utimes(outPath, (atime.getTime() / 1000) + offset, (mtime.getTime() / 1000) + offset);
|
||||
} catch (err) {
|
||||
console.error('Failed to set output file modified time', err);
|
||||
}
|
||||
}
|
||||
|
||||
export const toast = Swal.mixin({
|
||||
toast: true,
|
||||
|
@ -73,7 +32,7 @@ export const errorToast = (title) => toast.fire({
|
|||
|
||||
export const openDirToast = async ({ dirPath, ...props }) => {
|
||||
const { value } = await toast.fire({ icon: 'success', ...props, timer: 13000, showConfirmButton: true, confirmButtonText: i18n.t('Show'), showCancelButton: true, cancelButtonText: i18n.t('Close') });
|
||||
if (value) open(dirPath);
|
||||
if (value) window.util.open(dirPath);
|
||||
};
|
||||
|
||||
export async function showFfmpegFail(err) {
|
||||
|
@ -83,7 +42,7 @@ export async function showFfmpegFail(err) {
|
|||
|
||||
export function setFileNameTitle(filePath) {
|
||||
const appName = 'LosslessCut';
|
||||
document.title = filePath ? `${appName} - ${path.basename(filePath)}` : appName;
|
||||
document.title = filePath ? `${appName} - ${window.util.getBaseName(filePath)}` : appName;
|
||||
}
|
||||
|
||||
export function filenamify(name) {
|
||||
|
@ -111,29 +70,8 @@ export function doesPlayerSupportFile(streams) {
|
|||
return videoStreams.some(s => !['hevc', 'prores', 'mpeg4'].includes(s.codec_name));
|
||||
}
|
||||
|
||||
export const isMasBuild = window.process.mas;
|
||||
export const isWindowsStoreBuild = window.process.windowsStore;
|
||||
export const isStoreBuild = isMasBuild || isWindowsStoreBuild;
|
||||
|
||||
export const isDurationValid = (duration) => Number.isFinite(duration) && duration > 0;
|
||||
|
||||
const platform = os.platform();
|
||||
|
||||
export const isWindows = platform === 'win32';
|
||||
|
||||
export function getExtensionForFormat(format) {
|
||||
const ext = {
|
||||
matroska: 'mkv',
|
||||
ipod: 'm4a',
|
||||
}[format];
|
||||
|
||||
return ext || format;
|
||||
}
|
||||
|
||||
export function getOutFileExtension({ isCustomFormatSelected, outFormat, filePath }) {
|
||||
return isCustomFormatSelected ? `.${getExtensionForFormat(outFormat)}` : path.extname(filePath);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-template-curly-in-string
|
||||
export const defaultOutSegTemplate = '${FILENAME}-${CUT_FROM}-${CUT_TO}${SEG_SUFFIX}${EXT}';
|
||||
|
||||
|
@ -143,3 +81,20 @@ export function generateSegFileName({ template, inputFileNameWithoutExt, segSuff
|
|||
}
|
||||
|
||||
export const hasDuplicates = (arr) => new Set(arr).size !== arr.length;
|
||||
|
||||
export function openAbout() {
|
||||
Swal.fire({
|
||||
icon: 'info',
|
||||
title: 'About LosslessCut',
|
||||
text: `You are running version ${window.util.getAppVersion()}`,
|
||||
});
|
||||
}
|
||||
|
||||
export function isCuttingStart(cutFrom) {
|
||||
return cutFrom > 0;
|
||||
}
|
||||
|
||||
export function isCuttingEnd(cutTo, duration) {
|
||||
if (!isDurationValid(duration)) return true;
|
||||
return cutTo < duration;
|
||||
}
|
||||
|
|
|
@ -4678,11 +4678,6 @@ electron-devtools-installer@^2.2.4:
|
|||
rimraf "^2.5.2"
|
||||
semver "^5.3.0"
|
||||
|
||||
electron-is-dev@^0.1.2:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.npmjs.org/electron-is-dev/-/electron-is-dev-0.1.2.tgz#8a1043e32b3a1da1c3f553dce28ce764246167e3"
|
||||
integrity sha1-ihBD4ys6HaHD9VPc4oznZCRhZ+M=
|
||||
|
||||
electron-is-dev@^1.0.1:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/electron-is-dev/-/electron-is-dev-1.2.0.tgz#2e5cea0a1b3ccf1c86f577cee77363ef55deb05e"
|
||||
|
@ -5250,7 +5245,7 @@ etag@~1.8.1:
|
|||
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
|
||||
integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
|
||||
|
||||
eventemitter3@^4.0.0:
|
||||
eventemitter3@^4.0.0, eventemitter3@^4.0.7:
|
||||
version "4.0.7"
|
||||
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
|
||||
integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
|
||||
|
|
Ładowanie…
Reference in New Issue