diff --git a/README.md b/README.md index c5729dc0..6774652a 100644 --- a/README.md +++ b/README.md @@ -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)) - Basic [CLI](cli.md) and [HTTP API](api.md) - Show (DJI) embedded GPS track on a map +- Losslessly Download videos over HTTP (e.g. HLS `.m3u8`) ## Example lossless use cases diff --git a/package.json b/package.json index 400918ff..1a4812ce 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "start": "electron-vite preview", "dev": "electron-vite dev -w", "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-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-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-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-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", diff --git a/src/main/ffmpeg.ts b/src/main/ffmpeg.ts index e234aed7..f013b65d 100644 --- a/src/main/ffmpeg.ts +++ b/src/main/ffmpeg.ts @@ -664,5 +664,19 @@ export function createMediaSourceProcess({ path, videoStreamIndex, audioStreamIn 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) export const runFfmpeg = async (...args: Parameters) => runFfmpegProcess(...args); diff --git a/src/main/index.ts b/src/main/index.ts index f39f931a..f51f0208 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -40,6 +40,8 @@ export { isLinux, isWindows, isMac, platform } from './util.js'; export { pathToFileURL } from 'node:url'; +export { downloadMediaUrl } from './ffmpeg.js'; + const electronUnhandled = import('electron-unhandled'); diff --git a/src/main/menu.ts b/src/main/menu.ts index 0b5a6815..c160c166 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -35,6 +35,13 @@ export default ({ app, mainWindow, newVersion, isStoreBuild }: { mainWindow.webContents.send('openDirDialog'); }, }, + { + label: esc(t('Open URL')), + async click() { + mainWindow.webContents.send('promptDownloadMediaUrl'); + }, + }, + { type: 'separator' }, { label: esc(t('Close')), accelerator: 'CmdOrCtrl+W', diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 02e8fd7b..955aed95 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -74,13 +74,14 @@ import { deleteFiles, isOutOfSpaceError, readFileSize, readFileSizes, checkFileSizes, setDocumentTitle, getOutFileExtension, getSuffixedFileName, mustDisallowVob, readVideoTs, readDirRecursively, getImportProjectType, calcShouldShowWaveform, calcShouldShowKeyframes, mediaSourceQualities, getFrameDuration, isExecaError, getStdioString, isMuxNotSupported, + getDownloadMediaOutPath, } from './util'; import { toast, errorToast } from './swal'; import { formatDuration, parseDuration } from './util/duration'; import { adjustRate } from './util/rate-calculator'; import { askExtractFramesAsImages } from './dialogs/extractFrames'; 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 { fallbackLng } from './i18n'; 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 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; const val = valOrBool === true ? { text: t('Loading') } : valOrBool; setWorkingState(val ? { text: val.text, abortController: val.abortController } : undefined); @@ -2149,6 +2150,23 @@ function App() { onEditSegmentTags(currentSegIndexSafe); }, [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; 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 importEdlFile, exportEdlFile: tryExportEdlFile, + promptDownloadMediaUrl: promptDownloadMediaUrlWrapper, }).map(([key, fn]) => [ key, async (...args: unknown[]) => { @@ -2544,7 +2563,7 @@ function App() { ipcActions.forEach(([key, action]) => electron.ipcRenderer.off(key, action)); 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(() => { async function onDrop(ev: DragEvent) { diff --git a/src/renderer/src/dialogs/index.tsx b/src/renderer/src/dialogs/index.tsx index 32f8d4d0..648e30c4 100644 --- a/src/renderer/src/dialogs/index.tsx +++ b/src/renderer/src/dialogs/index.tsx @@ -15,7 +15,10 @@ import Checkbox from '../components/Checkbox'; import { isWindows, showItemInFolder } from '../util'; 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 }: { @@ -710,3 +713,18 @@ export async function askForPlaybackRate({ detectedFps, outputPlaybackRate }) { return parseValue(value); } + +export async function promptDownloadMediaUrl(outPath: string) { + const { value } = await Swal.fire({ + 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; +} diff --git a/src/renderer/src/util.ts b/src/renderer/src/util.ts index 0168428a..1b48c27c 100644 --- a/src/renderer/src/util.ts +++ b/src/renderer/src/util.ts @@ -52,6 +52,8 @@ export function getOutPath({ customOutDir, filePath, fileName }: { customOutDir? 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 function getSuffixedOutPath(a: { customOutDir?: string | undefined, filePath?: T | undefined, nameSuffix: string }): T extends string ? string : undefined;