1
0
mirror of https://github.com/mifi/lossless-cut.git synced 2024-11-22 02:12:30 +01:00

implement URL download

closes #1486
This commit is contained in:
Mikael Finstad 2024-08-08 01:07:28 +02:00
parent c495012db2
commit 818a323e7e
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26
8 changed files with 69 additions and 6 deletions

View File

@ -59,6 +59,7 @@ The main feature is lossless trimming and cutting of video and audio files, whic
- Speed up / slow down video or audio file ([changing FPS](https://github.com/mifi/lossless-cut/issues/1712)) - Speed up / slow down video or audio file ([changing FPS](https://github.com/mifi/lossless-cut/issues/1712))
- Basic [CLI](cli.md) and [HTTP API](api.md) - Basic [CLI](cli.md) and [HTTP API](api.md)
- Show (DJI) embedded GPS track on a map - Show (DJI) embedded GPS track on a map
- Losslessly Download videos over HTTP (e.g. HLS `.m3u8`)
## Example lossless use cases ## Example lossless use cases

View File

@ -11,8 +11,8 @@
"start": "electron-vite preview", "start": "electron-vite preview",
"dev": "electron-vite dev -w", "dev": "electron-vite dev -w",
"icon-gen": "mkdirp icon-build build-resources/appx && tsx script/icon-gen.mts", "icon-gen": "mkdirp icon-build build-resources/appx && tsx script/icon-gen.mts",
"download-ffmpeg-darwin-x64": "mkdirp ffmpeg/darwin-x64 && cd ffmpeg/darwin-x64 && wget https://github.com/mifi/ffmpeg-build-script/releases/download/6.0-1/ffmpeg-macos-X64 -O ffmpeg && wget https://github.com/mifi/ffmpeg-build-script/releases/download/6.0-1/ffprobe-macos-X64 -O ffprobe && chmod +x ffmpeg && chmod +x ffprobe", "download-ffmpeg-darwin-x64": "mkdirp ffmpeg/darwin-x64 && cd ffmpeg/darwin-x64 && wget https://github.com/mifi/ffmpeg-build-script/releases/download/6.0-5/ffmpeg-macos-X64 -O ffmpeg && wget https://github.com/mifi/ffmpeg-build-script/releases/download/6.0-5/ffprobe-macos-X64 -O ffprobe && chmod +x ffmpeg && chmod +x ffprobe",
"download-ffmpeg-darwin-arm64": "mkdirp ffmpeg/darwin-arm64 && cd ffmpeg/darwin-arm64 && wget https://github.com/mifi/ffmpeg-build-script/releases/download/6.0-1/ffmpeg-macos-ARM64 -O ffmpeg && wget https://github.com/mifi/ffmpeg-build-script/releases/download/6.0-1/ffprobe-macos-ARM64 -O ffprobe && chmod +x ffmpeg && chmod +x ffprobe", "download-ffmpeg-darwin-arm64": "mkdirp ffmpeg/darwin-arm64 && cd ffmpeg/darwin-arm64 && wget https://github.com/mifi/ffmpeg-build-script/releases/download/6.0-5/ffmpeg-macos-ARM64 -O ffmpeg && wget https://github.com/mifi/ffmpeg-build-script/releases/download/6.0-5/ffprobe-macos-ARM64 -O ffprobe && chmod +x ffmpeg && chmod +x ffprobe",
"download-ffmpeg-linux-x64": "mkdirp ffmpeg/linux-x64 && cd ffmpeg/linux-x64 && wget https://github.com/mifi/ffmpeg-builds/releases/download/6.0/ffmpeg-n6.0-12-ga6dc92968a-linux64-gpl-shared-6.0.tar.xz -O ffmpeg-ffprobe.xz && tar -xv -f ffmpeg-ffprobe.xz && mv ffmpeg-n6.0-12-ga6dc92968a-linux64-gpl-shared-6.0 extracted && mkdirp lib && mv extracted/bin/ffmpeg extracted/bin/ffprobe extracted/lib/lib*.so* lib", "download-ffmpeg-linux-x64": "mkdirp ffmpeg/linux-x64 && cd ffmpeg/linux-x64 && wget https://github.com/mifi/ffmpeg-builds/releases/download/6.0/ffmpeg-n6.0-12-ga6dc92968a-linux64-gpl-shared-6.0.tar.xz -O ffmpeg-ffprobe.xz && tar -xv -f ffmpeg-ffprobe.xz && mv ffmpeg-n6.0-12-ga6dc92968a-linux64-gpl-shared-6.0 extracted && mkdirp lib && mv extracted/bin/ffmpeg extracted/bin/ffprobe extracted/lib/lib*.so* lib",
"download-ffmpeg-win32-x64": "mkdirp ffmpeg/win32-x64 && cd ffmpeg/win32-x64 && npx download-cli https://github.com/mifi/ffmpeg-builds/releases/download/6.0/ffmpeg-n6.0-12-ga6dc92968a-win64-gpl-shared-6.0.zip --out . --filename ffmpeg-ffprobe.zip && 7z x ffmpeg-ffprobe.zip && mkdirp lib && cd ffmpeg-n6.0-12-ga6dc92968a-win64-gpl-shared-6.0/bin && npx shx mv ffmpeg.exe ffprobe.exe *.dll ../../lib", "download-ffmpeg-win32-x64": "mkdirp ffmpeg/win32-x64 && cd ffmpeg/win32-x64 && npx download-cli https://github.com/mifi/ffmpeg-builds/releases/download/6.0/ffmpeg-n6.0-12-ga6dc92968a-win64-gpl-shared-6.0.zip --out . --filename ffmpeg-ffprobe.zip && 7z x ffmpeg-ffprobe.zip && mkdirp lib && cd ffmpeg-n6.0-12-ga6dc92968a-win64-gpl-shared-6.0/bin && npx shx mv ffmpeg.exe ffprobe.exe *.dll ../../lib",
"build": "yarn icon-gen && electron-vite build", "build": "yarn icon-gen && electron-vite build",

View File

@ -664,5 +664,19 @@ export function createMediaSourceProcess({ path, videoStreamIndex, audioStreamIn
return execa(getFfmpegPath(), args, { encoding: null, buffer: false, stderr: enableLog ? 'inherit' : 'pipe' }); return execa(getFfmpegPath(), args, { encoding: null, buffer: false, stderr: enableLog ? 'inherit' : 'pipe' });
} }
export async function downloadMediaUrl(url: string, outPath: string) {
// User agent taken from https://techblog.willshouse.com/2012/01/03/most-common-user-agents/
const userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
const args = [
'-hide_banner', '-loglevel', 'error',
'-user_agent', userAgent,
'-i', url,
'-c', 'copy',
outPath,
];
await runFfmpegProcess(args);
}
// Don't pass complex objects over the bridge (process) // Don't pass complex objects over the bridge (process)
export const runFfmpeg = async (...args: Parameters<typeof runFfmpegProcess>) => runFfmpegProcess(...args); export const runFfmpeg = async (...args: Parameters<typeof runFfmpegProcess>) => runFfmpegProcess(...args);

View File

@ -40,6 +40,8 @@ export { isLinux, isWindows, isMac, platform } from './util.js';
export { pathToFileURL } from 'node:url'; export { pathToFileURL } from 'node:url';
export { downloadMediaUrl } from './ffmpeg.js';
const electronUnhandled = import('electron-unhandled'); const electronUnhandled = import('electron-unhandled');

View File

@ -35,6 +35,13 @@ export default ({ app, mainWindow, newVersion, isStoreBuild }: {
mainWindow.webContents.send('openDirDialog'); mainWindow.webContents.send('openDirDialog');
}, },
}, },
{
label: esc(t('Open URL')),
async click() {
mainWindow.webContents.send('promptDownloadMediaUrl');
},
},
{ type: 'separator' },
{ {
label: esc(t('Close')), label: esc(t('Close')),
accelerator: 'CmdOrCtrl+W', accelerator: 'CmdOrCtrl+W',

View File

@ -74,13 +74,14 @@ import {
deleteFiles, isOutOfSpaceError, readFileSize, readFileSizes, checkFileSizes, setDocumentTitle, getOutFileExtension, getSuffixedFileName, mustDisallowVob, readVideoTs, readDirRecursively, getImportProjectType, deleteFiles, isOutOfSpaceError, readFileSize, readFileSizes, checkFileSizes, setDocumentTitle, getOutFileExtension, getSuffixedFileName, mustDisallowVob, readVideoTs, readDirRecursively, getImportProjectType,
calcShouldShowWaveform, calcShouldShowKeyframes, mediaSourceQualities, getFrameDuration, isExecaError, getStdioString, calcShouldShowWaveform, calcShouldShowKeyframes, mediaSourceQualities, getFrameDuration, isExecaError, getStdioString,
isMuxNotSupported, isMuxNotSupported,
getDownloadMediaOutPath,
} from './util'; } from './util';
import { toast, errorToast } from './swal'; import { toast, errorToast } from './swal';
import { formatDuration, parseDuration } from './util/duration'; import { formatDuration, parseDuration } from './util/duration';
import { adjustRate } from './util/rate-calculator'; import { adjustRate } from './util/rate-calculator';
import { askExtractFramesAsImages } from './dialogs/extractFrames'; import { askExtractFramesAsImages } from './dialogs/extractFrames';
import { askForHtml5ifySpeed } from './dialogs/html5ify'; import { askForHtml5ifySpeed } from './dialogs/html5ify';
import { askForOutDir, askForImportChapters, promptTimecode, askForFileOpenAction, confirmExtractAllStreamsDialog, showCleanupFilesDialog, showDiskFull, showExportFailedDialog, showConcatFailedDialog, openYouTubeChaptersDialog, showRefuseToOverwrite, openDirToast, openExportFinishedToast, openConcatFinishedToast, showOpenDialog, showMuxNotSupported } from './dialogs'; import { askForOutDir, askForImportChapters, promptTimecode, askForFileOpenAction, confirmExtractAllStreamsDialog, showCleanupFilesDialog, showDiskFull, showExportFailedDialog, showConcatFailedDialog, openYouTubeChaptersDialog, showRefuseToOverwrite, openDirToast, openExportFinishedToast, openConcatFinishedToast, showOpenDialog, showMuxNotSupported, promptDownloadMediaUrl } from './dialogs';
import { openSendReportDialog } from './reporting'; import { openSendReportDialog } from './reporting';
import { fallbackLng } from './i18n'; import { fallbackLng } from './i18n';
import { createSegment, getCleanCutSegments, findSegmentsAtCursor, sortSegments, convertSegmentsToChapters, hasAnySegmentOverlap, isDurationValid, playOnlyCurrentSegment, getSegmentTags } from './segments'; import { createSegment, getCleanCutSegments, findSegmentsAtCursor, sortSegments, convertSegmentsToChapters, hasAnySegmentOverlap, isDurationValid, playOnlyCurrentSegment, getSegmentTags } from './segments';
@ -178,7 +179,7 @@ function App() {
// Store "working" in a ref so we can avoid race conditions // Store "working" in a ref so we can avoid race conditions
const workingRef = useRef(!!working); const workingRef = useRef(!!working);
const setWorking = useCallback((valOrBool: { text: string, abortController?: AbortController } | true | undefined) => { const setWorking = useCallback((valOrBool?: { text: string, abortController?: AbortController } | true | undefined) => {
workingRef.current = !!valOrBool; workingRef.current = !!valOrBool;
const val = valOrBool === true ? { text: t('Loading') } : valOrBool; const val = valOrBool === true ? { text: t('Loading') } : valOrBool;
setWorkingState(val ? { text: val.text, abortController: val.abortController } : undefined); setWorkingState(val ? { text: val.text, abortController: val.abortController } : undefined);
@ -2149,6 +2150,23 @@ function App() {
onEditSegmentTags(currentSegIndexSafe); onEditSegmentTags(currentSegIndexSafe);
}, [currentSegIndexSafe, onEditSegmentTags]); }, [currentSegIndexSafe, onEditSegmentTags]);
const promptDownloadMediaUrlWrapper = useCallback(async () => {
if (customOutDir == null) {
errorToast(i18n.t('Please select a working directory first'));
return;
}
const outPath = getDownloadMediaOutPath(customOutDir, `downloaded-media-${Date.now()}.mkv`);
try {
setWorking(true);
const downloaded = await promptDownloadMediaUrl(outPath);
if (downloaded) await loadMedia({ filePath: outPath });
} catch (err) {
handleError(err);
} finally {
setWorking();
}
}, [customOutDir, loadMedia, setWorking]);
type MainKeyboardAction = Exclude<KeyboardAction, 'closeActiveScreen' | 'toggleKeyboardShortcuts' | 'goToTimecodeDirect'>; type MainKeyboardAction = Exclude<KeyboardAction, 'closeActiveScreen' | 'toggleKeyboardShortcuts' | 'goToTimecodeDirect'>;
const mainActions = useMemo(() => { const mainActions = useMemo(() => {
@ -2486,6 +2504,7 @@ function App() {
// todo separate actions per type and move them into mainActions? https://github.com/mifi/lossless-cut/issues/254#issuecomment-932649424 // todo separate actions per type and move them into mainActions? https://github.com/mifi/lossless-cut/issues/254#issuecomment-932649424
importEdlFile, importEdlFile,
exportEdlFile: tryExportEdlFile, exportEdlFile: tryExportEdlFile,
promptDownloadMediaUrl: promptDownloadMediaUrlWrapper,
}).map(([key, fn]) => [ }).map(([key, fn]) => [
key, key,
async (...args: unknown[]) => { async (...args: unknown[]) => {
@ -2544,7 +2563,7 @@ function App() {
ipcActions.forEach(([key, action]) => electron.ipcRenderer.off(key, action)); ipcActions.forEach(([key, action]) => electron.ipcRenderer.off(key, action));
electron.ipcRenderer.off('apiAction', tryApiAction); electron.ipcRenderer.off('apiAction', tryApiAction);
}; };
}, [checkFileOpened, customOutDir, detectedFps, filePath, getFrameCount, getKeyboardAction, goToTimecodeDirect, loadCutSegments, mainActions, selectedSegments, toggleKeyboardShortcuts, userOpenFiles]); }, [checkFileOpened, customOutDir, detectedFps, filePath, getFrameCount, getKeyboardAction, goToTimecodeDirect, loadCutSegments, mainActions, promptDownloadMediaUrlWrapper, selectedSegments, toggleKeyboardShortcuts, userOpenFiles]);
useEffect(() => { useEffect(() => {
async function onDrop(ev: DragEvent) { async function onDrop(ev: DragEvent) {

View File

@ -15,7 +15,10 @@ import Checkbox from '../components/Checkbox';
import { isWindows, showItemInFolder } from '../util'; import { isWindows, showItemInFolder } from '../util';
import { ParseTimecode, SegmentBase } from '../types'; import { ParseTimecode, SegmentBase } from '../types';
const { dialog, shell } = window.require('@electron/remote'); const remote = window.require('@electron/remote');
const { dialog, shell } = remote;
const { downloadMediaUrl } = remote.require('./index.js');
export async function promptTimecode({ initialValue, title, text, inputPlaceholder, parseTimecode, allowRelative = false }: { export async function promptTimecode({ initialValue, title, text, inputPlaceholder, parseTimecode, allowRelative = false }: {
@ -710,3 +713,18 @@ export async function askForPlaybackRate({ detectedFps, outputPlaybackRate }) {
return parseValue(value); return parseValue(value);
} }
export async function promptDownloadMediaUrl(outPath: string) {
const { value } = await Swal.fire<string>({
title: i18n.t('Open media from URL'),
input: 'text',
inputPlaceholder: 'https://example.com/video.m3u8',
text: i18n.t('Losslessly download a whole media file from the specified URL, mux it into an mkv file and open it in LosslessCut. This can be useful if you need to download a video from a website, e.g. a HLS streaming video. For example in Chrome you can open Developer Tools and view the network traffic, find the playlist (e.g. m3u8) and copy paste its URL here.'),
showCancelButton: true,
});
if (!value) return false;
await downloadMediaUrl(value, outPath);
return true;
}

View File

@ -52,6 +52,8 @@ export function getOutPath({ customOutDir, filePath, fileName }: { customOutDir?
return join(getOutDir(customOutDir, filePath), fileName); return join(getOutDir(customOutDir, filePath), fileName);
} }
export const getDownloadMediaOutPath = (customOutDir: string, fileName: string) => join(customOutDir, fileName);
export const getSuffixedFileName = (filePath: string | undefined, nameSuffix: string) => `${getFileBaseName(filePath)}-${nameSuffix}`; export const getSuffixedFileName = (filePath: string | undefined, nameSuffix: string) => `${getFileBaseName(filePath)}-${nameSuffix}`;
export function getSuffixedOutPath<T extends string | undefined>(a: { customOutDir?: string | undefined, filePath?: T | undefined, nameSuffix: string }): T extends string ? string : undefined; export function getSuffixedOutPath<T extends string | undefined>(a: { customOutDir?: string | undefined, filePath?: T | undefined, nameSuffix: string }): T extends string ? string : undefined;