├── .eslintignore ├── .eslintrc.cjs ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── 1-file-issue.yml │ ├── 2-bug.yml │ ├── 3-feature.yml │ └── config.yml └── workflows │ ├── build.yml │ └── test.yml ├── .gitignore ├── .vscode └── settings.json ├── .yarn ├── patches │ └── file-type-npm-19.4.1-d18086444c.patch ├── plugins │ └── @yarnpkg │ │ └── plugin-licenses.cjs └── releases │ └── yarn-4.4.0.cjs ├── .yarnrc.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── _config.yml ├── api.md ├── batch.md ├── cli.md ├── docs.md ├── donate.svg ├── electron.vite.config.ts ├── entitlements.mas.inherit.plist ├── entitlements.mas.loginhelper.plist ├── entitlements.mas.plist ├── expressions.md ├── ffprobe.ts ├── flathub-badge.svg ├── i18next-parser.config.mjs ├── import-export.md ├── installation.md ├── issues.md ├── locales ├── ar │ └── translation.json ├── cs │ └── translation.json ├── de │ └── translation.json ├── en │ └── translation.json ├── es │ └── translation.json ├── et │ └── translation.json ├── fa │ └── translation.json ├── fi │ └── translation.json ├── fr │ └── translation.json ├── he │ └── translation.json ├── hu │ └── translation.json ├── id │ └── translation.json ├── it │ └── translation.json ├── ja │ └── translation.json ├── ko │ └── translation.json ├── lt │ └── translation.json ├── nb_NO │ └── translation.json ├── nl │ └── translation.json ├── nn │ └── translation.json ├── pl │ └── translation.json ├── pt │ └── translation.json ├── pt_BR │ └── translation.json ├── ro │ └── translation.json ├── ru │ └── translation.json ├── si │ └── translation.json ├── sk │ └── translation.json ├── sl │ └── translation.json ├── sr │ └── translation.json ├── sv │ └── translation.json ├── ta │ └── translation.json ├── tr │ └── translation.json ├── uk │ └── translation.json ├── vi │ └── translation.json ├── zh_Hans │ └── translation.json └── zh_Hant │ └── translation.json ├── mac-app-store-badge.svg ├── main_screenshot.jpg ├── ms-store-badge.svg ├── no.mifi.losslesscut.appdata.xml ├── no.mifi.losslesscut.desktop ├── package.json ├── requirements.md ├── script ├── e2e.mts ├── icon-gen.mts ├── postversion.mts └── xcrun-wrapper.mts ├── snap-store-black.svg ├── src ├── main │ ├── aboutPanel.ts │ ├── common.ts │ ├── compatPlayer.ts │ ├── configStore.ts │ ├── constants.ts │ ├── contextMenu.ts │ ├── ffmpeg.ts │ ├── httpServer.ts │ ├── i18n.ts │ ├── i18nCommon.ts │ ├── index.ts │ ├── isDev.ts │ ├── isStoreBuild.ts │ ├── logger.ts │ ├── menu.ts │ ├── pathToFileURL.test.ts │ ├── progress.test.ts │ ├── progress.ts │ ├── updateChecker.ts │ └── util.ts ├── preload │ └── index.ts └── renderer │ ├── errors.ts │ ├── index.html │ └── src │ ├── 7077-magic-flow.json │ ├── App.module.css │ ├── App.tsx │ ├── BetweenSegments.tsx │ ├── BottomBar.tsx │ ├── ErrorBoundary.tsx │ ├── LastCommandsSheet.tsx │ ├── MediaSourcePlayer.tsx │ ├── NoFileLoaded.tsx │ ├── SegmentList.tsx │ ├── StreamsSelector.module.css │ ├── StreamsSelector.tsx │ ├── Timeline.module.css │ ├── Timeline.tsx │ ├── TimelineSeg.tsx │ ├── TopMenu.tsx │ ├── __snapshots__ │ ├── edl.test.ts.snap │ ├── edlFormats.test.ts.snap │ └── segments.test.ts.snap │ ├── animations.ts │ ├── cmx3600.ts │ ├── colors.ts │ ├── components │ ├── Action.tsx │ ├── AutoExportToggler.tsx │ ├── BatchFile.tsx │ ├── BatchFilesList.tsx │ ├── BigWaveform.tsx │ ├── Button.module.css │ ├── Button.tsx │ ├── CaptureFormatButton.tsx │ ├── Checkbox.module.css │ ├── Checkbox.tsx │ ├── CloseButton.module.css │ ├── CloseButton.tsx │ ├── ConcatDialog.tsx │ ├── CopyClipboardButton.tsx │ ├── Dialog.module.css │ ├── Dialog.tsx │ ├── ExportButton.tsx │ ├── ExportConfirm.module.css │ ├── ExportConfirm.tsx │ ├── ExportDialog.module.css │ ├── ExportDialog.tsx │ ├── ExportModeButton.tsx │ ├── FileNameTemplateEditor.tsx │ ├── HighlightedText.tsx │ ├── KeyboardShortcuts.tsx │ ├── OutputFormatSelect.tsx │ ├── PlaybackStreamSelector.module.css │ ├── PlaybackStreamSelector.tsx │ ├── SegmentCutpointButton.tsx │ ├── Select.module.css │ ├── Select.tsx │ ├── SetCutpointButton.tsx │ ├── Settings.module.css │ ├── Settings.tsx │ ├── Sheet.module.css │ ├── Sheet.tsx │ ├── SimpleModeButton.tsx │ ├── Switch.module.css │ ├── Switch.tsx │ ├── TagEditor.tsx │ ├── TextInput.tsx │ ├── ToggleExportConfirm.tsx │ ├── Truncated.tsx │ ├── ValueTuner.module.css │ ├── ValueTuner.tsx │ ├── ValueTuners.tsx │ ├── VolumeControl.tsx │ ├── Warning.tsx │ ├── Working.module.css │ └── Working.tsx │ ├── contexts.ts │ ├── dialogs │ ├── extractFrames.tsx │ ├── html5ify.tsx │ ├── index.tsx │ └── parameters.tsx │ ├── edl.test.ts │ ├── edlFormats.test.ts │ ├── edlFormats.ts │ ├── edlStore.ts │ ├── ffmpeg.ts │ ├── ffmpegParameters.ts │ ├── ffprobe.ts │ ├── gps.tsx │ ├── hooks │ ├── normalizeWheel.ts │ ├── useContextMenu.ts │ ├── useDirectoryAccess.ts │ ├── useFfmpegOperations.ts │ ├── useFileFormatState.ts │ ├── useFrameCapture.ts │ ├── useKeyboard.ts │ ├── useKeyframes.ts │ ├── useLoading.ts │ ├── useNativeMenu.ts │ ├── useSegments.ts │ ├── useSegmentsAutoSave.ts │ ├── useStreamsMeta.ts │ ├── useSubtitles.ts │ ├── useThumbnails.ts │ ├── useTimecode.ts │ ├── useTimelineScroll.ts │ ├── useUserSettings.ts │ ├── useUserSettingsRoot.ts │ ├── useVideo.ts │ ├── useWaveform.ts │ └── useWhatChanged.ts │ ├── i18n.ts │ ├── icon-mac.svg │ ├── icon.svg │ ├── index.tsx │ ├── isDev.ts │ ├── main.css │ ├── mifi.ts │ ├── outFormats.ts │ ├── reporting.tsx │ ├── segments.test.ts │ ├── segments.ts │ ├── smartcut.ts │ ├── styles.ts │ ├── swal.ts │ ├── swal2.scss │ ├── test │ ├── fixtures │ │ ├── DV Analyzer Summary.txt │ │ ├── FCPXML_1_9.fcpxml │ │ ├── Final Cut Pro XMEML 2.xml │ │ ├── Final Cut Pro XMEML 3.xml │ │ ├── Final Cut Pro XMEML.xml │ │ ├── cmx3600.edl │ │ ├── edl │ │ │ ├── 070816_EG101_HEISTS_ROUGH_CUT_SOURCES_PART 1.edl │ │ │ ├── 12_16 TL01 MUSIC.edl │ │ │ ├── README.md │ │ │ ├── cmx3600.edl │ │ │ ├── cmx3600_5994.edl │ │ │ ├── file32.edl │ │ │ └── pull001_201109_exr.edl │ │ ├── mplayer.edl │ │ ├── otio.otio │ │ ├── otio.ts │ │ ├── potplayer bookmark format utf16le issue 867.pbf │ │ ├── sample.srt │ │ ├── sample.vtt │ │ ├── test1.csv │ │ ├── test1.pbf │ │ ├── test2.csv │ │ ├── test2.pbf │ │ ├── test3.csv │ │ └── test3.pbf │ └── util.ts │ ├── theme.ts │ ├── types.ts │ ├── util.ts │ ├── util │ ├── colors.ts │ ├── constants.ts │ ├── duration.test.ts │ ├── duration.ts │ ├── outputNameTemplate.ts │ ├── rate-calculator.test.ts │ ├── rate-calculator.ts │ ├── streams.test.ts │ └── streams.ts │ └── worker │ ├── eval.ts │ └── evalWorker.ts ├── test-manual ├── README.md └── formats.sh ├── tracks_screenshot.jpg ├── translation.md ├── tsconfig.json ├── tsconfig.main.json ├── tsconfig.node.json ├── tsconfig.web.json ├── types.ts └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /out 3 | /ts-dist -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['mifi'], 3 | rules: { 4 | 'jsx-a11y/click-events-have-key-events': 0, 5 | 'jsx-a11y/interactive-supports-focus': 0, 6 | 'jsx-a11y/control-has-associated-label': 0, 7 | }, 8 | 9 | overrides: [ 10 | { 11 | files: ['./src/renderer/**/*.{js,cjs,mjs,jsx,ts,tsx,mts}'], 12 | env: { 13 | node: false, 14 | browser: true, 15 | }, 16 | rules: { 17 | 'no-console': 0, 18 | 'import/no-extraneous-dependencies': 0, 19 | }, 20 | }, 21 | { 22 | files: ['./src/preload/**/*.{js,cjs,jsx,ts,tsx}'], 23 | env: { 24 | browser: true, 25 | }, 26 | rules: { 27 | 'no-console': 0, 28 | }, 29 | }, 30 | { 31 | files: ['./script/**/*.{js,cjs,mjs,jsx,ts,tsx,mts}', 'electron.vite.config.ts'], 32 | rules: { 33 | 'import/no-extraneous-dependencies': ['error', { 34 | devDependencies: true, 35 | optionalDependencies: false, 36 | }], 37 | }, 38 | }, 39 | ], 40 | }; 41 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: mifi 2 | custom: https://mifi.no/thanks 3 | open_collective: losslesscut 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-file-issue.yml: -------------------------------------------------------------------------------- 1 | name: 📄 Problem with a particular file/type 2 | description: Report a problem that happens only with a particular file or type (e.g. all files from a particular camera/app). 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | If you have a problem **only with a particular file or type**, use this form. If you have a problem with all files or LosslessCut in general, [use this other form instead](https://github.com/mifi/lossless-cut/issues/new?assignees=&labels=&projects=&template=2-bug.yml). 8 | - type: checkboxes 9 | id: initial-checklist 10 | attributes: 11 | label: The fewer issues I have to read, the more new features I will have time to implement, so I ask that you please try these things first 12 | options: 13 | - label: Try with a different kind of file to confirm that the problem is related to just this file. 14 | required: true 15 | - label: Try with the [newest version from GitHub](https://github.com/mifi/lossless-cut/releases/latest) 16 | required: true 17 | - label: Read the [documentation](https://github.com/mifi/lossless-cut) and [FAQ, Known issues, Troubleshooting](https://github.com/mifi/lossless-cut/blob/master/issues.md) 18 | required: true 19 | - label: Search for your problem under [Issues](https://github.com/mifi/lossless-cut/issues) or [Discussions](https://github.com/mifi/lossless-cut/discussions) 20 | required: true 21 | - type: textarea 22 | id: steps-to-reproduce 23 | attributes: 24 | label: Steps to reproduce 25 | description: How would I reproduce the problem? Please describe step-by-step what you're doing from starting up LosslessCut until the problem occurs. You can attach screenshots or screencasts if needed. 26 | validations: 27 | required: true 28 | - type: textarea 29 | id: expected-behavior 30 | attributes: 31 | label: Expected behavior 32 | description: What should happen? 33 | validations: 34 | required: true 35 | - type: textarea 36 | id: actual-behavior 37 | attributes: 38 | label: Actual behavior 39 | description: What happens instead? 40 | validations: 41 | required: true 42 | - type: textarea 43 | id: error-report 44 | attributes: 45 | label: Provide an error report 46 | description: 'Please provide the error report from LosslessCut (Menu: Help -> Report an error) and paste it here' 47 | validations: 48 | required: true 49 | - type: input 50 | id: share-file 51 | attributes: 52 | label: Share the file 53 | description: If you have an issue cutting a particular file, please share the file here, if possible. (or mail a link to finstaden@gmail.com if private). If possible, provide a file as small as possible that reproduces the problem. 54 | validations: 55 | required: false 56 | - type: textarea 57 | id: share-log 58 | attributes: 59 | label: Share log from developer tools 60 | description: 'Please open developer tools after starting LosslessCut and before performing the relevant operation. Then share any relevant output from the javascript Console. (Menu: Tools > Toggle Developer Tools)' 61 | validations: 62 | required: false 63 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-bug.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug report 2 | description: Describe a bug in LosslessCut (not related to a specific file or file type). 3 | body: 4 | - type: checkboxes 5 | id: initial-checklist 6 | attributes: 7 | label: The fewer issues I have to read, the more new features I will have time to implement, so I ask that you please try these things first 8 | options: 9 | - label: Try with the [newest version from GitHub](https://github.com/mifi/lossless-cut/releases/latest) (also it might have already been fixed in latest [nightly build](https://github.com/mifi/lossless-cut#nightly-builds-)) 10 | required: true 11 | - label: Read the [documentation](https://github.com/mifi/lossless-cut) and [FAQ, Known issues, Troubleshooting](https://github.com/mifi/lossless-cut/blob/master/issues.md) 12 | required: true 13 | - label: Search for your problem under [Issues](https://github.com/mifi/lossless-cut/issues) or [Discussions](https://github.com/mifi/lossless-cut/discussions) 14 | required: true 15 | - label: This problem happens with all kinds of files that I try. For problem only with a particular file or type, [use this other form instead](https://github.com/mifi/lossless-cut/issues/new?assignees=&labels=&projects=&template=1-file-issue.yml) 16 | required: true 17 | - type: dropdown 18 | id: operating-system 19 | attributes: 20 | label: Operating System 21 | description: Which operating system are you running? 22 | options: 23 | - MacOS 15 24 | - MacOS 14 25 | - MacOS 13 26 | - MacOS 12 27 | - MacOS 11 28 | - MacOS 10 29 | - Windows 11 30 | - Windows 10 31 | - Windows 8.1 32 | - Windows 8 33 | - Windows 7 34 | - Windows Vista 35 | - Linux 36 | - Other (please specify) 37 | validations: 38 | required: true 39 | - type: textarea 40 | id: steps-to-reproduce 41 | attributes: 42 | label: Steps to reproduce 43 | description: How would I reproduce the problem? Please describe step-by-step what you're doing from starting up LosslessCut until the problem occurs. You can drag-drop to attach screenshots or screencasts if needed. 44 | validations: 45 | required: true 46 | - type: textarea 47 | id: expected-behavior 48 | attributes: 49 | label: Expected behavior 50 | description: What should happen? 51 | validations: 52 | required: true 53 | - type: textarea 54 | id: actual-behavior 55 | attributes: 56 | label: Actual behavior 57 | description: What happens instead? 58 | validations: 59 | required: true 60 | - type: textarea 61 | id: share-log 62 | attributes: 63 | label: Share log from developer tools 64 | description: 'If you have a problem or an unexpected bug or crash, please open developer tools after starting LosslessCut and before doing the failing operation. Then share the output from the javascript Console. (Menu: Tools > Toggle Developer Tools)' 65 | validations: 66 | required: false 67 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3-feature.yml: -------------------------------------------------------------------------------- 1 | name: ✨ Feature request 2 | description: Suggest an idea for an improvement 3 | body: 4 | - type: checkboxes 5 | id: initial-checklist 6 | attributes: 7 | label: The fewer issues I have to read, the more new features I will have time to implement, so I ask that you please try these things first 8 | options: 9 | - label: Check out all the menus and keyboard shortcuts. Maybe the feature already exists? 10 | required: true 11 | - label: Try with the [newest version from GitHub](https://github.com/mifi/lossless-cut/releases/latest) 12 | required: true 13 | - label: Read the [documentation](https://github.com/mifi/lossless-cut) and [FAQ, Known issues, Troubleshooting](https://github.com/mifi/lossless-cut/blob/master/issues.md) to see if the feature already exists 14 | required: true 15 | - label: See if someone already suggested this under [Issues](https://github.com/mifi/lossless-cut/issues) or [Discussions](https://github.com/mifi/lossless-cut/discussions) 16 | required: true 17 | - type: textarea 18 | id: feature-description 19 | attributes: 20 | label: Description 21 | description: A clear and concise description of what you want you would like to be added or improved in LosslessCut. You can attach screenshots or screencasts if needed. 22 | validations: 23 | required: true 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: ❓ Discussions and questions 4 | url: https://github.com/mifi/lossless-cut/discussions 5 | about: Please ask and answer questions here. 6 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ${{ matrix.os }} 8 | timeout-minutes: 60 9 | 10 | strategy: 11 | matrix: 12 | os: [ubuntu-latest, windows-latest] 13 | 14 | steps: 15 | # Windows fix. See https://github.com/actions/checkout/issues/226 16 | - run: git config --global core.autocrlf false 17 | 18 | - uses: actions/checkout@v4 19 | 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: 22 23 | cache: 'yarn' 24 | 25 | - run: yarn install --immutable 26 | - run: yarn dedupe --check 27 | - run: yarn test 28 | - run: yarn tsc 29 | - run: yarn lint 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | *.provisionprofile 4 | 5 | .pnp.* 6 | .yarn/* 7 | !.yarn/patches 8 | !.yarn/plugins 9 | !.yarn/releases 10 | !.yarn/sdks 11 | !.yarn/versions 12 | 13 | /dist 14 | /out 15 | /icon-build 16 | /build-resources 17 | /doc 18 | /ffmpeg 19 | /app*.log 20 | /ts-dist 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | "/src/main/locales/**": true, 4 | } 5 | } -------------------------------------------------------------------------------- /.yarn/patches/file-type-npm-19.4.1-d18086444c.patch: -------------------------------------------------------------------------------- 1 | diff --git a/package.json b/package.json 2 | index 08f1ca3b598aa9f6ffdd35988744df1cd5ffcff6..d671d7ef0422c4f00f7d78fb5dd96a90c10c285a 100644 3 | --- a/package.json 4 | +++ b/package.json 5 | @@ -25,6 +25,10 @@ 6 | "./core": { 7 | "types": "./core.d.ts", 8 | "import": "./core.js" 9 | + }, 10 | + "./node": { 11 | + "types": "./index.d.ts", 12 | + "import": "./index.js" 13 | } 14 | }, 15 | "sideEffects": false, 16 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | 3 | nodeLinker: node-modules 4 | 5 | plugins: 6 | - checksum: 55d54388ad171beb0a12e808e375d3c5f8632556a2eed7d03b38b8304514b07831ef7a9f9e78410287313940d107ea4634cfa2933e08459a165c432cea55bdba 7 | path: .yarn/plugins/@yarnpkg/plugin-licenses.cjs 8 | spec: "https://raw.githubusercontent.com/mhassan1/yarn-plugin-licenses/v0.13.1/bundles/@yarnpkg/plugin-licenses.js" 9 | 10 | yarnPath: .yarn/releases/yarn-4.4.0.cjs 11 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal -------------------------------------------------------------------------------- /api.md: -------------------------------------------------------------------------------- 1 | # HTTP API 2 | 3 | LosslessCut can be controlled via a HTTP API, if it is being run with the command line option `--http-api`. See also [CLI](./cli.md). **Note that the HTTP API is experimental and may change at any time.** 4 | 5 | ## Programmatically opening a file 6 | 7 | This must be done with [the CLI](./cli.md). 8 | 9 | ## Enabling the API 10 | 11 | ```bash 12 | LosslessCut --http-api 13 | ``` 14 | 15 | ## API endpoints 16 | 17 | ### `POST /api/action/:action` 18 | 19 | Execute a keyboard shortcut `action`, similar to the `--keyboard-action` CLI option. This is different from the CLI in that most of the actions will wait for the action to finish before responding to the HTTP request (but not all). 20 | 21 | #### [Available keyboard actions](./cli.md#available-keyboard-actions) 22 | 23 | #### Example 24 | 25 | Export the currently opened file: 26 | 27 | ```bash 28 | curl -X POST http://localhost:8080/api/action/export 29 | ``` 30 | 31 | Seek to time: 32 | ```bash 33 | curl -X POST http://localhost:8080/api/action/goToTimecodeDirect --json '{"time": "09:11"}' 34 | ``` 35 | 36 | 37 | ### Batch example 38 | 39 | Start the main LosslessCut in one terminal with the HTTP API enabled: 40 | 41 | ```bash 42 | LosslessCut --http-api 43 | ``` 44 | 45 | Then run the script in a different terminal: 46 | 47 | ```bash 48 | for PROJECT in /path/to/folder/with/projects/*.llc 49 | LosslessCut $PROJECT 50 | sleep 5 # wait for the file to open 51 | curl -X POST http://localhost:8080/api/action/export 52 | curl -X POST http://localhost:8080/api/action/closeCurrentFile 53 | done 54 | ``` 55 | -------------------------------------------------------------------------------- /batch.md: -------------------------------------------------------------------------------- 1 | # Batch processing ⏩ 2 | 3 | I get a lot of questions about whether LosslessCut can help automate the same operation on X number of files. For example given a folder of 100 files, cut off 10 seconds from the beginning of every file, or split each file into 30 second files. LosslessCut was not designed to be a batch processing toolkit and generally cannot not do these things, however the good news is that often it's not very hard to automate with a simple script. 4 | 5 | See also [#868](https://github.com/mifi/lossless-cut/issues/868). 6 | 7 | ## Setup FFmpeg 📀 8 | 9 | First you need to [download and install FFmpeg](https://ffmpeg.org/) on your computer. Make sure you install it properly so that you can open a Bash terminal (Linux/macOS) or Console (Windows) and type the command `ffmpeg` (or `ffmpeg.exe` on Windows) and press Enter. It should then print out something like this: 10 | 11 | ```bash 12 | ffmpeg version 7.1 Copyright (c) 2000-2024 the FFmpeg developers 13 | ``` 14 | 15 | If you cannot get it working, there here are lots of resources online on how to do this. Or you can ask an AI (for example ChatGPT) to assist you. 16 | 17 | ## Create your script 📜 18 | 19 | Make a file `myscript.sh` (macOS/Linux) or `myscript.bat` (Windows) and edit it with a plain text editor like `nano` or Notepad. 20 | 21 | If there's a particular operation from LosslessCut you want to automate across multiple files, you can find the command from the "Last FFmpeg commands" page. Then copy paste this command into your script. Note: if you're on Windows, the command might have to be altered slightly to be compatible (you can use an AI for this). 22 | 23 | ## Using AI 🤖 24 | 25 | > AI opposers are strong in their faith. They swore they'd never [kneel before the LLM](https://github.com/mifi/lossless-cut/discussions/1490#discussioncomment-12014019) — now they're [prompting psalms](https://github.com/mifi/lossless-cut/discussions/1490#discussioncomment-12019277) about the [divine GPT.](https://github.com/mifi/lossless-cut/discussions/1490#discussioncomment-12018982) 26 | 27 | I wish more people were aware of this: large language models like ChatGPT can be incredibly useful for helping non-programmers with simple scripting tasks as well as helping you learn things and debug error messages, and it's free! Basically you just ask the AI to write a script for you to do whatever you need. If it doesn't work, you can continue the conversation with the AI and give it the error messages you received and it will try to help your get it working. 28 | 29 | Start your sentence with your operating system, e.g. "I am using Windows 10", then try to be so exact and concise as possible to describe what kind of files you have and what you want to do with them to the AI using FFmpeg. Example prompt: 30 | 31 | > I am on macOS. Please help me write a script that for each *.mp4 file in a specified folder, losslessly removes the first 10 seconds from each file? Also how do I run the script? The files are inside the folder `/Users/user/my-files`. I have FFmpeg installed and running as `ffmpeg`. 32 | 33 | ### Action from LosslessCut 34 | 35 | If there's a particular operation from LosslessCut you want to automate, you can find the command from the "Last FFmpeg commands" page. Then copy it and paste it into your AI prompt. For example: 36 | 37 | > I am on Windows 11. I have this (UNIX bash) command: `ffmpeg -hide_banner -i 'C:\path\to\input.mp4' -map '0:1' -c copy -f adts -y 'c:\path\to\lofoten-stream-1-audio-aac.aac'`, that I want to run automatically on every *.mp4 file in a specified folder. Please help me write a script that achieves this. The files are inside the folder `C:\path\to\folder`. I have FFmpeg installed and running as `ffmpeg.exe`. 38 | 39 | If you are on Windows and what you want to do is more complex, it might be a good idea to instruct the AI to use PowerShell instead of Batch. 40 | 41 | ### More examples 42 | 43 | Split files into equal length segments: 44 | 45 | > Write a script that takes a folder of *.mp4 files, then for each file, split it into an (unknown) number of files, each file of an equal length of approximately 299 seconds. 46 | 47 | Batch rotate all files to 90 degrees: 48 | 49 | > Write a script that takes a folder of *.mp4 files, then for each file, losslessly change the rotation to 90 degrees and output to the same folder. 50 | -------------------------------------------------------------------------------- /cli.md: -------------------------------------------------------------------------------- 1 | # Command line interface (CLI) 2 | 3 | LosslessCut has basic support for automation through the CLI. See also [HTTP API](./api.md). 4 | 5 | ```bash 6 | LosslessCut [options] [files] 7 | ``` 8 | 9 | Note that these examples assume that you have set up the LosslessCut executable to be available in your `PATH` (command line environment). Alternatively you can run it like this: 10 | 11 | ```bash 12 | # First navigate to the folder containing the LosslessCut app 13 | cd /path/to/directory/containing/app 14 | # Then run it 15 | # On Linux: 16 | ./LosslessCut arguments 17 | # On Windows: 18 | ./LosslessCut.exe arguments 19 | # On MacOS: 20 | ./LosslessCut.app/Contents/MacOS/LosslessCut arguments 21 | ``` 22 | 23 | Note that some users have reported that the Windows Store version of LosslessCut needs an [app execution alias](https://github.com/mifi/lossless-cut/issues/1136). 24 | 25 | ## Open one or more files: 26 | ```bash 27 | LosslessCut file1.mp4 file2.mkv 28 | ``` 29 | 30 | ## Override settings (experimental) 31 | See [available settings](https://github.com/mifi/lossless-cut/blob/master/src/main/configStore.ts). Note that this is subject to change in newer versions. ⚠️ If you specify incorrect values it could corrupt your configuration file. You may use JSON or JSON5. Example: 32 | ```bash 33 | LosslessCut --settings-json '{captureFormat:"jpeg", "keyframeCut":true}' 34 | ``` 35 | 36 | ## Other options 37 | 38 | - `--locales-path` Customise path to locales (useful for [translators](./translation.md)). 39 | - `--disable-networking` Turn off all network requests. 40 | - `--http-api` Start the [HTTP server with an API](./api.md) to control LosslessCut, optionally specifying a port (default `8080`). 41 | - `--keyboard-action` Run a keyboard action (see below.) 42 | - `--config-dir` Path to a directory where the `config.json` file will be stored and loaded from. Note: don't include `config.json` in the path (only the directory containing it). 43 | 44 | ## Controlling a running instance (experimental) 45 | 46 | If you have the "Allow multiple instances" setting enabled, you can control a running instance of LosslessCut from the outside, using for example a command line. You do this by issuing messages to it through the `LosslessCut` command. Currently only keyboard actions are supported, and you can open files. *Note that this is considered experimental and the API may change at any time.* 47 | 48 | ### Keyboard actions, `--keyboard-action` 49 | 50 | Simulate a keyboard press action in an already running instance of LosslessCut. Note that the command will return immediately, so if you want to run multiple actions in a sequence, you have to `sleep` for a few seconds between the commands. Alternatively if you want to wait until an action has finished processing, you can use the [HTTP API](./api.md) instead. Note that the HTTP API does not support opening files, and it is currently not possible to wait for a file to have finished opening. 51 | 52 | ### Available keyboard actions 53 | 54 | A list of the available action names can be found in the "Keyboard shortcuts" dialog in the app. Note that you don't have to bind them to any key before using them. 55 | 56 | Example: 57 | 58 | ```bash 59 | # Open a file in an already running instance 60 | LosslessCut file.mp4 61 | sleep 3 # hopefully the file has loaded by now 62 | # Export the currently opened file 63 | LosslessCut --keyboard-action export 64 | ``` 65 | 66 | ### Open files in running instance 67 | 68 | ```bash 69 | LosslessCut file1.mp4 file2.mkv 70 | ``` 71 | -------------------------------------------------------------------------------- /electron.vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, externalizeDepsPlugin } from 'electron-vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | 5 | export default defineConfig({ 6 | main: { 7 | // https://electron-vite.org/guide/dev#dependencies-vs-devdependencies 8 | // For the main process and preload, the best practice is to externalize dependencies and only bundle our own code. 9 | // However, until we use ESM for electron main, we need to include ESM-only deps in the bundle: (exclude from externalize) 10 | plugins: [externalizeDepsPlugin({ exclude: ['p-map', 'execa', 'nanoid', 'file-type'] })], 11 | build: { 12 | target: 'node22.14', 13 | sourcemap: true, 14 | }, 15 | }, 16 | preload: { 17 | // https://electron-vite.org/guide/dev#dependencies-vs-devdependencies 18 | plugins: [externalizeDepsPlugin({ exclude: [] })], 19 | build: { 20 | target: 'node22.14', 21 | sourcemap: true, 22 | }, 23 | }, 24 | renderer: { 25 | plugins: [react()], 26 | build: { 27 | target: 'chrome134', 28 | sourcemap: true, 29 | chunkSizeWarningLimit: 3e6, 30 | }, 31 | server: { 32 | port: 3001, 33 | host: '127.0.0.1', 34 | }, 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /entitlements.mas.inherit.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.inherit 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /entitlements.mas.loginhelper.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /entitlements.mas.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.application-groups 8 | 9 | 46F6T3M669.no.mifi.losslesscut-mac 10 | 11 | com.apple.security.network.client 12 | 13 | com.apple.security.network.server 14 | 15 | com.apple.security.files.user-selected.read-write 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /expressions.md: -------------------------------------------------------------------------------- 1 | # Expressions 2 | 3 | LosslessCut has support for normal JavaScript expressions. 4 | 5 | ## Select segments by expression 6 | 7 | You will be given a variable `segment` and can create an expression that returns `true` or `false`. For example to select all segments with a duration of less than 5 seconds use this expression: 8 | 9 | ```js 10 | segment.duration < 5 11 | ``` 12 | 13 | ## Edit segments by expression 14 | 15 | LosslessCut has support for normal JavaScript expressions. You will be given a variable `segment` for each selected segment and can return a new segment with modified properties. 16 | 17 | See more examples in-app. 18 | -------------------------------------------------------------------------------- /i18next-parser.config.mjs: -------------------------------------------------------------------------------- 1 | // eslint-disable-line unicorn/filename-case 2 | export default { 3 | input: ['src/renderer/**/*.{js,jsx,ts,tsx}', 'src/main/*.{js,ts}'], 4 | 5 | output: 'locales/$LOCALE/$NAMESPACE.json', 6 | indentation: 4, 7 | 8 | sort: true, 9 | 10 | locales: ['en'], 11 | 12 | lexers: { 13 | js: ['JavascriptLexer'], 14 | jsx: ['JsxLexer'], 15 | }, 16 | 17 | defaultValue: (lng, ns, key) => key, 18 | 19 | // Keep in sync between i18next-parser.config.js and i18nCommon.js: 20 | keySeparator: false, 21 | namespaceSeparator: false, 22 | }; 23 | -------------------------------------------------------------------------------- /import-export.md: -------------------------------------------------------------------------------- 1 | # Export 2 | 3 | This has been moved to the [documentation (Custom exported file names)](docs.md#custom-exported-file-names). 4 | -------------------------------------------------------------------------------- /installation.md: -------------------------------------------------------------------------------- 1 | # Installation and files 2 | 3 | ## There is no installer 4 | 5 | There is no installer. The app is just a compressed file that you download from [GitHub](https://github.com/mifi/lossless-cut/releases) and extract. Then you run the executable contained within. 6 | - Windows: Download the `.7z` file and extract it using [7zip](https://www.7-zip.org/download.html). 7 | - MacOS: Mount the `dmg` and drag the app into your `Applications` folder. 8 | - Linux: Y'all know what to do ;) 9 | 10 | ## Portable app? 11 | 12 | LosslessCut is **not** a portable app. If you install it from the Mac App Store or Microsoft Store, it is somewhat portable because it will be containerized by the operating system, so that when you uninstall the app there will most likely not be many traces of it left. You *can* however customise where settings are stored, see below. 13 | 14 | ## Settings and temporary files 15 | 16 | Settings, logs and temporary cache files are stored in your [`appData`](https://www.electronjs.org/docs/api/app#appgetpathname) folder. 17 | 18 | ### `appData` folder: 19 | 20 | | OS | Path | Notes | 21 | |-|-|-| 22 | | Windows | `%APPDATA%\LosslessCut` | | 23 | | Windows (MS Store Version) | `C:\Users\%USERNAME%\AppData\Local\Packages\57275mifi.no.LosslessCut_eg8x93dt4dxje\LocalCache\Roaming\LosslessCut` | [*Not sure](https://github.com/mifi/lossless-cut/discussions/2167) | 24 | | MacOS | `~/Library/Application Support/LosslessCut` | | 25 | | MacOS (App Store version) | `~/Library/Containers/no.mifi.losslesscut/Data/Library/Application Support/LosslessCut` | | 26 | | Linux | `$XDG_CONFIG_HOME/LosslessCut` or `~/.config/LosslessCut` | | 27 | 28 | [What is Windows `%APPDATA%`?](https://superuser.com/questions/632891/what-is-appdata) 29 | 30 | Settings and keyboard actions are stored inside the `config.json` file inside your `appData` folder. 31 | 32 | ### Custom `config.json` path 33 | 34 | On Windows, if you create a `config.json` file with the contents `{}` next to the `LosslessCut.exe` file, LosslessCut will read/store settings from this file instead of the one inside `appData`. Note that other temporary files will still be stored in `appData`. Alternatively you can specify a custom path to a folder containing `config.json` by using the [CLI option](./cli.md) `--config-dir`. See also [#645](https://github.com/mifi/lossless-cut/issues/645). 35 | 36 | ## How to uninstall 37 | 38 | Just delete the folder/app that you extracted when you installed it. 39 | 40 | If you want to also delete all settings, logs and caches, see [Settings and temporary files](#settings-and-temporary-files) above. See also [#2058](https://github.com/mifi/lossless-cut/issues/). 41 | 42 | ## Unofficial versions 43 | 44 | Because LosslessCut is Open Source (GPL), there are many people and organizations who publish their own variant of LosslessCut for example portableapps.com. This is fine, however **I don't provide support for those versions**. 45 | -------------------------------------------------------------------------------- /locales/ar/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "(detected)": "(مُكتَشَف)" 3 | } 4 | -------------------------------------------------------------------------------- /locales/fa/translation.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /locales/he/translation.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /locales/id/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "{{durationMsFormatted}} ms, {{frameCount}} frames": "{{durationMsFormatted}} md, {{frameCount}} bingkai", 3 | "(detected)": "(terdeteksi)", 4 | "{{selectedSegments}} of {{nonFilteredSegments}} segments selected": "{{selectedSegments}} dari {{nonFilteredSegments}} segmen terpilih", 5 | "\"ffmpeg\" experimental flag": "\"ffmpeg\" penanda eksperimental", 6 | "(data tracks such as GoPro GPS, telemetry etc. are not copied over by default because ffmpeg cannot cut them, thus they will cause the media duration to stay the same after cutting video/audio)": "(pelacakan data seperti GoPro GPS, telemetri, dll. tidak disalin secara bawaan karena ffmpeg tidak dapat memotongnya, sehingga hal itu akan menyebabkan durasi media tetap sama setelah memotong video/audio)", 7 | "+{{numFrames}} frames_one": "+{{numFrames}} bingkai", 8 | "+{{numFrames}} frames_other": "+{{numFrames}} bingkai" 9 | } 10 | -------------------------------------------------------------------------------- /locales/ro/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "Start time must be before end time": "Timpul de început trebuie să fie înainte de cel de sfârșit", 3 | "Report": "Raport", 4 | "OK": "OK", 5 | "Unable to export this file": "Nu se poate exporta acest fișier", 6 | "File has been permanently deleted": "Fișierul a fost șters definitiv", 7 | "Permanently delete": "Șterge definitiv", 8 | "Unable to move source file to trash. Do you want to permanently delete it?": "Nu se poate muta sursa fișierului în coșul de gunoi. Vrei să-l ștergi definitiv?", 9 | "File has been moved to trash": "Fișierul a fost mutat în coșul de gunoi", 10 | "Deleting source": "Se șterge sursa", 11 | "Trash it": "Șterge-l", 12 | "Are you sure you want to move the source file to trash?": "Ești sigur(ă) că vrei să muți sursa fișierului în coșul de gunoi?", 13 | "Converting to supported format": "Se convertește la formatul suportat", 14 | "Unable to playback this file. Try to convert to supported format from the menu": "Nu se poate rula acest fișier. Încercați să-l convertiți din meniu, la formatul suportat", 15 | "File not natively supported. Preview may have no audio or low quality. The final export will however be lossless with audio. You may convert it from the menu for a better preview with audio.": "Fișierul nu este suportat nativ. Previzualizarea s-ar putea sa aibă sonor de calitate joasă sau deloc. Cu toate acestea, exportul final va fi cu sonor, fără pierderi. Pentru o previzualizare mai bună, cu sonor, s-ar putea converti din meniu.", 16 | "Will now cut at the exact position, but may leave an empty portion at the beginning of the file. You may have to set the cutpoint a few frames before the next keyframe to achieve a precise cut": "Va tăia acum, exact la poziție, dar poate lăsa o porțiune goală la începutul fișierului. Pentru a realiza o tăiere precisă, va trebui selectat locul de tăiere, cu câteva cadre înainte de următorul keyframe", 17 | "Keyframe cut disabled": "Modul de tăiere Keyframe, dezactivat", 18 | "Will now cut at the nearest keyframe before the desired start cutpoint. This is recommended for most files.": "Se va tăia la cel mai apropiat cadru cu imagine completă(Keyframe), înainte de locul de tăiere de la început dorit. Această metodă e recomandată pentru majoritatea fișierelor.", 19 | "Keyframe cut enabled": "Modul de tăiere Keyframe, activat", 20 | "Failed to merge files. Make sure they are all of the exact same codecs": "Nu s-au putut uni fișierele. Asigurațivă că sunt toate cu exact aceleași codecuri", 21 | "Files merged!": "Fișierele s-au unit!", 22 | "Merging": "Unire", 23 | "You have no write access to the directory of this file, please select a custom working dir": "Nu aveți drept de scriere în directorul acestui fișier, vă rog selectați un director în care puteți crea și edita", 24 | "Unable to save project file": "Nu se poate salva fișierul proiectului", 25 | "Muted preview (exported file will not be affected)": "Previzualizare fără sonor (fișierul exportat nu va fi afectat)", 26 | "Unable to save your preferences. Try to disable any anti-virus": "Nu se pot salva setările efectuate. Încercați să dezactivați programele anti-virus" 27 | } 28 | -------------------------------------------------------------------------------- /locales/si/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "All Files": "සියළුම ගොනු", 3 | "attachment": "ඇමුණුම", 4 | "Both": "දෙකම", 5 | "Change preferences": "අභිප්‍රේත සංශෝධනය", 6 | "Auto save project file?": "ව්‍යාපෘතියේ ගොනුව ඉබේ සුරකින්න", 7 | "Bitrate": "බිටුඅනුපා.", 8 | "Change value": "අගය සංශෝධනය", 9 | "audio": "ශ්‍රව්‍ය", 10 | "Cancel": "අවලංගු", 11 | "Capture frame": "රාමුව ග්‍රහණය" 12 | } 13 | -------------------------------------------------------------------------------- /locales/sr/translation.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /main_screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mifi/lossless-cut/250523c3c3a1d37d140d19e0e0b7e3dbe00728bd/main_screenshot.jpg -------------------------------------------------------------------------------- /no.mifi.losslesscut.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=LosslessCut 3 | Comment=simple video editor to trim or cut videos 4 | Comment[fr]=Un simple éditeur vidéo pour retailler ou couper les vidéos 5 | Exec=/app/bin/run.sh 6 | MimeType=video/mpeg;video/x-mpeg;video/msvideo;video/quicktime;video/x-anim;video/x-avi;video/x-ms-asf;video/x-ms-wmv;video/x-msvideo;video/x-nsv;video/x-flc;video/x-fli;video/x-flv;video/vnd.rn-realvideo;video/mp4;video/mp4v-es;video/mp2t;application/ogg;application/x-ogg;video/x-ogm+ogg;audio/x-vorbis+ogg;application/x-matroska;audio/x-matroska;video/x-matroska;video/webm; 7 | Icon=no.mifi.losslesscut 8 | Terminal=false 9 | Type=Application 10 | Encoding=UTF-8 11 | Categories=AudioVideo;AudioVideoEditing; 12 | Keywords=trim;codec;cut;movie;mpeg;avi;h264;mkv;mp4; 13 | StartupWMClass=losslesscut 14 | -------------------------------------------------------------------------------- /requirements.md: -------------------------------------------------------------------------------- 1 | # Supported OS versions 2 | 3 | LosslessCut is based on Electron which routinely drops support for old OS versions, and therefore LosslessCut will also do so. [More info](https://github.com/mifi/lossless-cut/discussions/1476#discussioncomment-5012521). 4 | 5 | - v3.58.0 [dropped support](https://www.electronjs.org/docs/latest/breaking-changes#removed-macos-1013--1014-support) for MacOS 10.14 and older. 6 | - v3.52.0 dropped support for Windows 8.1 and older. 7 | - v3.48.2 dropped support for MacOS 10.12 and older. 8 | - v3.48.2 dropped support for 32 bit Linux. 9 | -------------------------------------------------------------------------------- /script/e2e.mts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import assert from 'node:assert'; 3 | import { execa } from 'execa'; 4 | import ky from 'ky'; 5 | import os from 'node:os'; 6 | import timers from 'node:timers/promises'; 7 | 8 | 9 | const losslessCutExePath = process.argv[2]; 10 | assert(losslessCutExePath); 11 | const screenshotDevice = process.argv[3]; 12 | const screenshotOutPath = process.argv[4]; 13 | assert(screenshotOutPath); 14 | 15 | 16 | const port = 8081; 17 | 18 | const platform = os.platform(); 19 | 20 | const losslessCutArgs = [ 21 | ...(platform === 'linux' ? ['--no-sandbox'] : []), 22 | '--http-api', String(port), 23 | ]; 24 | const ps = execa(losslessCutExePath, losslessCutArgs, { forceKillAfterDelay: 10000 }); 25 | 26 | console.log('Started', losslessCutExePath); 27 | 28 | // eslint-disable-next-line unicorn/prefer-top-level-await 29 | ps.catch((err) => console.error(err)); 30 | 31 | const client = ky.extend({ prefixUrl: `http://127.0.0.1:${port}` }); 32 | 33 | async function captureScreenshot(outPath: string) { 34 | // https://trac.ffmpeg.org/wiki/Capture/Desktop#Windows 35 | 36 | if (platform === 'darwin') { 37 | const { stderr } = await execa('ffmpeg', ['-f', 'avfoundation', '-list_devices', 'true', '-i', '', '-hide_banner'], { reject: false, timeout: 30000 }); 38 | console.log(stderr); 39 | } 40 | 41 | assert(screenshotDevice); 42 | 43 | await execa('ffmpeg', [ 44 | ...(platform === 'darwin' ? ['-r', '30', '-pix_fmt', 'uyvy422', '-f', 'avfoundation'] : []), 45 | ...(platform === 'win32' ? ['-f', 'gdigrab', '-framerate', '30'] : []), 46 | ...(platform === 'linux' ? ['-framerate', '25', '-f', 'x11grab'] : []), 47 | '-i', screenshotDevice, 48 | '-vframes', '1', outPath, 49 | ], { timeout: 30000 }); 50 | } 51 | 52 | try { 53 | const resp = await client('', { 54 | timeout: 5000, 55 | retry: { backoffLimit: 5000, limit: 10 }, 56 | hooks: { beforeRequest: [() => { console.log('attempt'); }] }, 57 | }).text(); 58 | assert(resp.length > 0); 59 | 60 | console.log('Waiting for UI to settle'); 61 | 62 | await timers.setTimeout(5000); 63 | 64 | console.log('Capturing screenshot'); 65 | 66 | await captureScreenshot(screenshotOutPath); 67 | 68 | console.log('Sending quit command'); 69 | 70 | try { 71 | await client.post('api/action/quit').text(); 72 | } catch (err) { 73 | console.warn('Quit command failed', err); 74 | ps.kill(); 75 | } 76 | } finally { 77 | // ps.cancel(); 78 | } 79 | 80 | console.log('Waiting for app to quit'); 81 | 82 | try { 83 | const { stdout, stderr } = await ps; 84 | console.log('App exited'); 85 | console.log('stdout:', stdout); 86 | console.log('stderr:', stderr); 87 | } catch (err) { 88 | console.warn(err); 89 | } 90 | -------------------------------------------------------------------------------- /script/icon-gen.mts: -------------------------------------------------------------------------------- 1 | // eslint-disable-line unicorn/filename-case 2 | import sharp from 'sharp'; 3 | import icongenRaw from 'icon-gen'; 4 | 5 | const icongen = icongenRaw as unknown as typeof icongenRaw['default']; 6 | 7 | const svg2png = (from: string, to: string, width: number, height: number) => sharp(from) 8 | .png() 9 | .resize(width, height, { 10 | fit: sharp.fit.contain, 11 | background: { r: 0, g: 0, b: 0, alpha: 0 }, 12 | }) 13 | .toFile(to); 14 | 15 | const srcIcon = 'src/renderer/src/icon.svg'; 16 | // Linux: 17 | await svg2png(srcIcon, './icon-build/app-512.png', 512, 512); 18 | 19 | // Windows Store 20 | await svg2png(srcIcon, './build-resources/appx/StoreLogo.png', 50, 50); 21 | await svg2png(srcIcon, './build-resources/appx/Square150x150Logo.png', 300, 300); 22 | await svg2png(srcIcon, './build-resources/appx/Square44x44Logo.png', 44, 44); 23 | await svg2png(srcIcon, './build-resources/appx/Wide310x150Logo.png', 620, 300); 24 | 25 | // MacOS: 26 | // https://github.com/mifi/lossless-cut/issues/1820 27 | await icongen('./src/renderer/src/icon-mac.svg', './icon-build', { icns: { sizes: [512, 1024] }, report: false }); 28 | 29 | // Windows ICO: 30 | // https://github.com/mifi/lossless-cut/issues/778 31 | // https://stackoverflow.com/questions/3236115/which-icon-sizes-should-my-windows-applications-icon-include 32 | await icongen(srcIcon, './icon-build', { ico: { sizes: [16, 24, 32, 40, 48, 64, 96, 128, 256, 512] }, report: false }); 33 | -------------------------------------------------------------------------------- /script/postversion.mts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from 'node:fs/promises'; 2 | import { XMLParser, XMLBuilder } from 'fast-xml-parser'; 3 | import { DateTime } from 'luxon'; 4 | 5 | const xmlUrl = new URL('../no.mifi.losslesscut.appdata.xml', import.meta.url); 6 | const xmlData = await readFile(xmlUrl); 7 | 8 | const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); 9 | 10 | const parser = new XMLParser({ alwaysCreateTextNode: true, ignoreAttributes: false, ignoreDeclaration: false }); 11 | const xml = parser.parse(xmlData); 12 | // console.log(xml); 13 | 14 | const { version } = packageJson; 15 | 16 | xml.component.releases.release = [{ '@_version': version, '@_date': DateTime.now().toISODate() }, ...xml.component.releases.release]; 17 | 18 | const builder = new XMLBuilder({ format: true, ignoreAttributes: false, suppressEmptyNode: true }); 19 | await writeFile(xmlUrl, builder.build(xml)); 20 | -------------------------------------------------------------------------------- /src/main/aboutPanel.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import { AboutPanelOptionsOptions, app } from 'electron'; 3 | 4 | import { appName, copyrightYear } from './common.js'; 5 | import { isLinux } from './util.js'; 6 | import isStoreBuild from './isStoreBuild.js'; 7 | import { githubLink, homepage } from './constants.js'; 8 | 9 | 10 | // eslint-disable-next-line import/prefer-default-export 11 | export function getAboutPanelOptions() { 12 | const showVersion = !isStoreBuild; 13 | 14 | const appVersion = app.getVersion(); 15 | 16 | const aboutPanelLines = [ 17 | isStoreBuild ? homepage : githubLink, 18 | '', 19 | `Copyright © 2016-${copyrightYear} Mikael Finstad ❤️ 🇳🇴`, 20 | ]; 21 | 22 | const aboutPanelOptions: AboutPanelOptionsOptions = { 23 | applicationName: appName, 24 | copyright: aboutPanelLines.join('\n'), 25 | version: '', // not very useful (supported on MacOS only, and same as applicationVersion) 26 | }; 27 | 28 | // https://github.com/electron/electron/issues/18918 29 | // https://github.com/mifi/lossless-cut/issues/1537 30 | if (isLinux) { 31 | aboutPanelOptions.applicationVersion = appVersion; 32 | } else if (!showVersion) { 33 | // https://github.com/mifi/lossless-cut/issues/1882 34 | aboutPanelOptions.applicationVersion = `${process.windowsStore ? 'Microsoft Store' : 'App Store'} edition, based on GitHub v${appVersion}`; 35 | } 36 | 37 | return aboutPanelOptions; 38 | } 39 | -------------------------------------------------------------------------------- /src/main/common.ts: -------------------------------------------------------------------------------- 1 | export const appName = 'LosslessCut'; 2 | export const copyrightYear = 2024; 3 | -------------------------------------------------------------------------------- /src/main/compatPlayer.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import { ExecaError } from 'execa'; 3 | 4 | import logger from './logger.js'; 5 | import { createMediaSourceProcess, readOneJpegFrame as readOneJpegFrameRaw } from './ffmpeg.js'; 6 | 7 | 8 | export function createMediaSourceStream({ path, videoStreamIndex, audioStreamIndexes, seekTo, size, fps }: { 9 | path: string, 10 | videoStreamIndex?: number | undefined, 11 | audioStreamIndexes: number[], 12 | seekTo: number, 13 | size?: number | undefined, 14 | fps?: number | undefined, 15 | }) { 16 | const abortController = new AbortController(); 17 | logger.info('Starting preview process', { videoStreamIndex, audioStreamIndexes, seekTo }); 18 | const process = createMediaSourceProcess({ path, videoStreamIndex, audioStreamIndexes, seekTo, size, fps }); 19 | 20 | // eslint-disable-next-line unicorn/prefer-add-event-listener 21 | abortController.signal.onabort = () => { 22 | logger.info('Aborting preview process', { videoStreamIndex, audioStreamIndexes, seekTo }); 23 | process.kill('SIGKILL'); 24 | }; 25 | 26 | const { stdout } = process; 27 | assert(stdout != null); 28 | 29 | stdout.pause(); 30 | 31 | const readChunk = async () => new Promise((resolve, reject) => { 32 | let cleanup: () => void; 33 | 34 | const onClose = () => { 35 | cleanup(); 36 | resolve(null); 37 | }; 38 | const onData = (chunk: Buffer) => { 39 | stdout.pause(); 40 | cleanup(); 41 | resolve(chunk); 42 | }; 43 | const onError = (err: Error) => { 44 | cleanup(); 45 | reject(err); 46 | }; 47 | cleanup = () => { 48 | stdout.off('data', onData); 49 | stdout.off('error', onError); 50 | stdout.off('close', onClose); 51 | }; 52 | 53 | stdout.once('data', onData); 54 | stdout.once('error', onError); 55 | stdout.once('close', onClose); 56 | 57 | stdout.resume(); 58 | }); 59 | 60 | function abort() { 61 | abortController.abort(); 62 | } 63 | 64 | let stderr = Buffer.alloc(0); 65 | process.stderr?.on('data', (chunk) => { 66 | stderr = Buffer.concat([stderr, chunk]); 67 | }); 68 | 69 | (async () => { 70 | try { 71 | await process; 72 | } catch (err) { 73 | if (err instanceof ExecaError && err.isTerminated) { 74 | return; 75 | } 76 | 77 | logger.warn((err as Error).message); 78 | logger.warn(stderr.toString('utf8')); 79 | } 80 | })(); 81 | 82 | return { abort, readChunk }; 83 | } 84 | 85 | export function readOneJpegFrame({ path, seekTo, videoStreamIndex }: { path: string, seekTo: number, videoStreamIndex: number }) { 86 | const abortController = new AbortController(); 87 | const process = readOneJpegFrameRaw({ path, seekTo, videoStreamIndex }); 88 | 89 | // eslint-disable-next-line unicorn/prefer-add-event-listener 90 | abortController.signal.onabort = () => process.kill('SIGKILL'); 91 | 92 | function abort() { 93 | abortController.abort(); 94 | } 95 | 96 | const promise = (async () => { 97 | try { 98 | const { stdout } = await process; 99 | return stdout; 100 | } catch (err) { 101 | // @ts-expect-error todo 102 | logger.error('renderOneJpegFrame', err.shortMessage); 103 | throw new Error('Failed to render JPEG frame'); 104 | } 105 | })(); 106 | 107 | return { promise, abort }; 108 | } 109 | -------------------------------------------------------------------------------- /src/main/constants.ts: -------------------------------------------------------------------------------- 1 | export const homepage = 'https://mifi.no/losslesscut/'; 2 | export const githubLink = 'https://github.com/mifi/lossless-cut/'; 3 | export const getReleaseUrl = (version: string) => `https://github.com/mifi/lossless-cut/releases/tag/v${version}`; 4 | export const licensesPage = 'https://losslesscut.mifi.no/licenses.txt'; 5 | -------------------------------------------------------------------------------- /src/main/contextMenu.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import { BrowserWindow, Menu } from 'electron'; 3 | 4 | // https://github.com/electron/electron/issues/4068#issuecomment-274159726 5 | export default (window: BrowserWindow) => { 6 | const selectionMenu = Menu.buildFromTemplate([ 7 | { role: 'copy' }, 8 | { type: 'separator' }, 9 | { role: 'selectAll' }, 10 | ]); 11 | 12 | const inputMenu = Menu.buildFromTemplate([ 13 | { role: 'undo' }, 14 | { role: 'redo' }, 15 | { type: 'separator' }, 16 | { role: 'cut' }, 17 | { role: 'copy' }, 18 | { role: 'paste' }, 19 | { type: 'separator' }, 20 | { role: 'selectAll' }, 21 | ]); 22 | 23 | window.webContents.on('context-menu', (_e, props) => { 24 | const { selectionText, isEditable } = props; 25 | if (isEditable) { 26 | inputMenu.popup({ window }); 27 | } else if (selectionText && selectionText.trim() !== '') { 28 | selectionMenu.popup({ window }); 29 | } 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /src/main/httpServer.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import morgan from 'morgan'; 3 | import http from 'node:http'; 4 | import asyncHandler from 'express-async-handler'; 5 | import assert from 'node:assert'; 6 | 7 | import { homepage } from './constants.js'; 8 | import logger from './logger.js'; 9 | 10 | 11 | export default ({ port, onKeyboardAction }: { 12 | port: number, onKeyboardAction: (action: string, args: unknown[]) => Promise, 13 | }) => { 14 | const app = express(); 15 | 16 | // https://expressjs.com/en/resources/middleware/morgan.html 17 | const morganFormat = ':remote-addr :method :url HTTP/:http-version :status - :response-time ms'; 18 | // https://stackoverflow.com/questions/27906551/node-js-logging-use-morgan-and-winston 19 | app.use(morgan(morganFormat, { 20 | stream: { write: (message) => logger.info(message.trim()) }, 21 | })); 22 | 23 | const apiRouter = express.Router(); 24 | 25 | app.get('/', (_req, res) => res.send(`See ${homepage}`)); 26 | 27 | app.use('/api', apiRouter); 28 | 29 | apiRouter.post('/action/:action', express.json(), asyncHandler(async (req, res) => { 30 | // eslint-disable-next-line prefer-destructuring 31 | const action = req.params['action']; 32 | const parameters = req.body as unknown; 33 | assert(action != null); 34 | await onKeyboardAction(action, [parameters]); 35 | res.end(); 36 | })); 37 | 38 | const server = http.createServer(app); 39 | 40 | server.on('error', (err) => logger.error('http server error', err)); 41 | 42 | const startHttpServer = async () => new Promise((resolve, reject) => { 43 | // force ipv4 44 | const host = '127.0.0.1'; 45 | server.listen(port, host, () => { 46 | logger.info('HTTP API listening on', `http://${host}:${port}/`); 47 | // @ts-expect-error tod 48 | resolve(); 49 | }); 50 | 51 | server.once('error', reject); 52 | }); 53 | 54 | return { 55 | startHttpServer, 56 | }; 57 | }; 58 | -------------------------------------------------------------------------------- /src/main/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18n from 'i18next'; 2 | import Backend from 'i18next-fs-backend'; 3 | 4 | import { commonI18nOptions, loadPath, addPath } from './i18nCommon.js'; 5 | 6 | // See also renderer 7 | 8 | // https://github.com/i18next/i18next/issues/869 9 | export default i18n 10 | .use(Backend) 11 | // detect user language 12 | // learn more: https://github.com/i18next/i18next-browser-languageDetector 13 | // TODO disabled for now because translations need more reviewing https://github.com/mifi/lossless-cut/issues/346 14 | // .use(LanguageDetector) 15 | // init i18next 16 | // for all options read: https://www.i18next.com/overview/configuration-options 17 | // See also i18next-parser.config.mjs 18 | .init({ 19 | ...commonI18nOptions, 20 | 21 | backend: { 22 | loadPath, 23 | addPath, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /src/main/i18nCommon.ts: -------------------------------------------------------------------------------- 1 | // intentionally disabled because I don't know the quality of the languages, so better to default to english 2 | // const LanguageDetector = window.require('i18next-electron-language-detector'); 3 | // eslint-disable-next-line import/no-extraneous-dependencies 4 | import { app } from 'electron'; 5 | import { join } from 'node:path'; 6 | import { InitOptions } from 'i18next'; 7 | 8 | 9 | let customLocalesPath: string | undefined; 10 | export function setCustomLocalesPath(p: string) { 11 | customLocalesPath = p; 12 | } 13 | 14 | function getLangPath(subPath: string) { 15 | if (customLocalesPath != null) return join(customLocalesPath, subPath); 16 | if (app.isPackaged) return join(process.resourcesPath, 'locales', subPath); 17 | return join('locales', subPath); 18 | } 19 | 20 | // Weblate hardcodes different lang codes than electron 21 | // https://www.electronjs.org/docs/api/app#appgetlocale 22 | // https://source.chromium.org/chromium/chromium/src/+/master:ui/base/l10n/l10n_util.cc 23 | const mapLang = (lng: string) => ({ 24 | nb: 'nb_NO', 25 | no: 'nb_NO', 26 | zh: 'zh_Hans', 27 | // https://source.chromium.org/chromium/chromium/src/+/master:ui/base/l10n/l10n_util.cc;l=354 28 | 'zh-CN': 'zh_Hans', // Chinese simplified (mainland China) 29 | 'zh-TW': 'zh_Hant', // Chinese traditional (Taiwan) 30 | fr: 'fr', 31 | 'fr-CA': 'fr', 32 | 'fr-CH': 'fr', 33 | 'fr-FR': 'fr', 34 | it: 'it', 35 | 'it-CH': 'it', 36 | 'it-IT': 'it', 37 | 'ru-RU': 'ru', 38 | }[lng] || lng); 39 | 40 | export const fallbackLng = 'en'; 41 | 42 | export const commonI18nOptions: InitOptions = { 43 | fallbackLng, 44 | // debug: isDev, 45 | // saveMissing: isDev, 46 | // updateMissing: isDev, 47 | // saveMissingTo: 'all', 48 | 49 | // Keep in sync between i18next-parser.config.js and i18nCommon.js: 50 | // TODO improve keys? 51 | // Maybe do something like this: https://stackoverflow.com/a/19405314/6519037 52 | keySeparator: false, 53 | nsSeparator: false, 54 | }; 55 | 56 | export const loadPath = (lng: string, ns: string) => getLangPath(`${mapLang(lng)}/${ns}.json`); 57 | export const addPath = (lng: string, ns: string) => getLangPath(`${mapLang(lng)}/${ns}.missing.json`); 58 | -------------------------------------------------------------------------------- /src/main/isDev.ts: -------------------------------------------------------------------------------- 1 | const isDev = import.meta.env.MODE === 'development'; 2 | export default isDev; 3 | -------------------------------------------------------------------------------- /src/main/isStoreBuild.ts: -------------------------------------------------------------------------------- 1 | const isStoreBuild = process.windowsStore || process.mas; 2 | 3 | export default isStoreBuild; 4 | -------------------------------------------------------------------------------- /src/main/logger.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | import util from 'node:util'; 3 | // eslint-disable-next-line import/no-extraneous-dependencies 4 | import { app } from 'electron'; 5 | import { join } from 'node:path'; 6 | // eslint-disable-next-line import/no-extraneous-dependencies 7 | import type { TransformableInfo } from 'logform'; 8 | 9 | 10 | // https://mifi.no/blog/winston-electron-logger/ 11 | 12 | // https://github.com/winstonjs/winston/issues/1427 13 | const combineMessageAndSplat = () => ({ 14 | transform(info: TransformableInfo) { 15 | // @ts-expect-error todo 16 | const { [Symbol.for('splat')]: args = [], message } = info; 17 | // eslint-disable-next-line no-param-reassign 18 | info.message = util.format(message, ...args); 19 | return info; 20 | }, 21 | }); 22 | 23 | const createLogger = () => winston.createLogger({ 24 | format: winston.format.combine( 25 | winston.format.timestamp(), 26 | combineMessageAndSplat(), 27 | winston.format.printf((info) => `${info['timestamp']} ${info.level}: ${info.message}`), 28 | ), 29 | }); 30 | 31 | const logDirPath = app.isPackaged ? app.getPath('userData') : '.'; 32 | export const logFilePath = join(logDirPath, 'app.log'); 33 | 34 | const logger = createLogger(); 35 | logger.add(new winston.transports.Console()); 36 | logger.add(new winston.transports.File({ level: 'debug', filename: logFilePath, options: { flags: 'a' }, maxsize: 1e6, maxFiles: 100, tailable: true })); 37 | 38 | export default logger; 39 | -------------------------------------------------------------------------------- /src/main/pathToFileURL.test.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-line unicorn/filename-case 2 | // eslint-disable-next-line import/no-extraneous-dependencies 3 | import { test, expect, describe } from 'vitest'; 4 | 5 | import { pathToFileURL } from 'node:url'; 6 | 7 | 8 | if (process.platform === 'win32') { 9 | describe('file uri windows only', () => { 10 | test('converts path to file url', () => { 11 | expect(pathToFileURL('C:\\Users\\sindresorhus\\dev\\te^st.jpg').href).toEqual('file:///C:/Users/sindresorhus/dev/te%5Est.jpg'); 12 | }); 13 | }); 14 | } else { 15 | describe('file uri non-windows', () => { 16 | // https://github.com/mifi/lossless-cut/issues/1941 17 | test('file with backslash', () => { 18 | expect(pathToFileURL('/has/back\\slash').href).toEqual('file:///has/back%5Cslash'); 19 | }); 20 | }); 21 | } 22 | 23 | // taken from https://github.com/sindresorhus/file-url 24 | describe('file uri both platforms', () => { 25 | test('converts path to file url', () => { 26 | expect(pathToFileURL('/test.jpg').href).toMatch(/^file:\/{3}.*test\.jpg$/); 27 | 28 | expect(pathToFileURL('/Users/sindresorhus/dev/te^st.jpg').href).toMatch(/^file:\/{2}.*\/Users\/sindresorhus\/dev\/te%5Est\.jpg$/); 29 | }); 30 | 31 | test('escapes more special characters in path', () => { 32 | expect(pathToFileURL('/a^?!@#$%&\'";<>').href).toMatch(/^file:\/{3}.*a%5E%3F!@%23\$%25&'%22;%3C%3E$/); 33 | }); 34 | 35 | test('escapes whitespace characters in path', () => { 36 | expect(pathToFileURL('/file with\r\nnewline').href).toMatch(/^file:\/{3}.*file%20with%0D%0Anewline$/); 37 | }); 38 | 39 | test('relative path', () => { 40 | expect(pathToFileURL('relative/test.jpg').href).toMatch(/^file:\/{3}.*\/relative\/test\.jpg$/); 41 | }); 42 | 43 | test('slash', () => { 44 | expect(pathToFileURL('/').href).toMatch(/^file:\/{2}.*\/$/); 45 | }); 46 | 47 | test('empty', () => { 48 | expect(pathToFileURL('').href).toMatch(/^file:\/{3}.*$/); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/main/progress.test.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import { describe, expect, test } from 'vitest'; 3 | import { parseFfmpegProgressLine } from './progress'; 4 | 5 | describe('parseFfmpegProgressLine', () => { 6 | test('parse video', () => { 7 | const str = 'frame= 2285 fps=135 q=4.0 Lsize=N/A time=00:01:31.36 bitrate=N/A speed=5.38x '; 8 | expect(parseFfmpegProgressLine({ line: str, duration: 60 + 31.36 })).toBe(1); 9 | }); 10 | test('parse audio 0', () => { 11 | const str = 'size= 0kB time=00:00:00.00 bitrate=N/A speed=N/A '; 12 | expect(parseFfmpegProgressLine({ line: str, duration: 1 })).toBe(0); 13 | }); 14 | test('parse audio 32.02', () => { 15 | const str = 'size= 501kB time=00:00:32.02 bitrate= 128.2kbits/s speed=2.29e+03x '; 16 | expect(parseFfmpegProgressLine({ line: str, duration: 32.02 })).toBe(1); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/main/progress.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/prefer-default-export 2 | export function parseFfmpegProgressLine({ line, customMatcher, duration: durationIn }: { 3 | line: string, 4 | customMatcher?: ((a: string) => void) | undefined, 5 | duration: number | undefined, 6 | }) { 7 | 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+/); 8 | if (!match) { 9 | // Audio only looks like this: "size= 233422kB time=01:45:50.68 bitrate= 301.1kbits/s speed= 353x " 10 | match = line.match(/(?:size|Lsize)=\s*\S+\s+time=\s*(\S+)\s+/); 11 | } 12 | if (!match) { 13 | customMatcher?.(line); 14 | return undefined; 15 | } 16 | 17 | if (durationIn == null) return undefined; 18 | const duration = Math.max(0, durationIn); 19 | if (duration === 0) return undefined; 20 | 21 | const timeStr = match[1]; 22 | // console.log(timeStr); 23 | const match2 = timeStr!.match(/^(-?)(\d+):(\d+):(\d+)\.(\d+)$/); 24 | if (!match2) throw new Error(`Invalid time from ffmpeg progress ${timeStr}`); 25 | 26 | const sign = match2[1]; 27 | 28 | if (sign === '-') { 29 | // For some reason, ffmpeg sometimes gives a negative progress, e.g. "-00:00:06.46" 30 | // let's just ignore those lines 31 | return undefined; 32 | } 33 | 34 | const h = parseInt(match2[2]!, 10); 35 | const m = parseInt(match2[3]!, 10); 36 | const s = parseInt(match2[4]!, 10); 37 | const cs = parseInt(match2[5]!, 10); 38 | const time = (((h * 60) + m) * 60 + s) + cs / 100; 39 | // console.log(time); 40 | 41 | const progressTime = Math.max(0, time); 42 | // console.log(progressTime); 43 | 44 | const progress = Math.min(progressTime / duration, 1); // sometimes progressTime will be greater than cutDuration 45 | return progress; 46 | } 47 | -------------------------------------------------------------------------------- /src/main/updateChecker.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import electron from 'electron'; 3 | import semver from 'semver'; 4 | import { Octokit } from '@octokit/core'; 5 | 6 | import logger from './logger.js'; 7 | 8 | 9 | const { app } = electron; 10 | 11 | const octokit = new Octokit(); 12 | 13 | 14 | // eslint-disable-next-line import/prefer-default-export 15 | export async function checkNewVersion() { 16 | try { 17 | // From API: https://developer.github.com/v3/repos/releases/#get-the-latest-release 18 | // View the latest published full release for the repository. 19 | // Draft releases and prereleases are not returned by this endpoint. 20 | 21 | const { data } = await octokit.request('GET /repos/{owner}/{repo}/releases/latest', { 22 | owner: 'mifi', 23 | repo: 'lossless-cut', 24 | headers: { 25 | 'X-GitHub-Api-Version': '2022-11-28', 26 | }, 27 | }); 28 | 29 | const newestVersion = data.tag_name.replace(/^v?/, ''); 30 | 31 | const currentVersion = app.getVersion(); 32 | // const currentVersion = '3.17.2'; 33 | 34 | logger.info('Current version', currentVersion); 35 | logger.info('Newest version', newestVersion); 36 | 37 | if (semver.lt(currentVersion, newestVersion)) return newestVersion; 38 | return undefined; 39 | } catch (err) { 40 | // @ts-expect-error todo 41 | logger.error('Failed to check github version', err.message); 42 | return undefined; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/util.ts: -------------------------------------------------------------------------------- 1 | import os from 'node:os'; 2 | 3 | export const platform = os.platform(); 4 | export const arch = os.arch(); 5 | 6 | export const isWindows = platform === 'win32'; 7 | export const isMac = platform === 'darwin'; 8 | export const isLinux = platform === 'linux'; 9 | -------------------------------------------------------------------------------- /src/preload/index.ts: -------------------------------------------------------------------------------- 1 | // todo 2 | console.log('preload'); 3 | -------------------------------------------------------------------------------- /src/renderer/errors.ts: -------------------------------------------------------------------------------- 1 | export class DirectoryAccessDeclinedError extends Error { 2 | constructor() { 3 | super(); 4 | this.name = 'DirectoryAccessDeclinedError'; 5 | } 6 | } 7 | 8 | export class UnsupportedFileError extends Error { 9 | constructor(message: string) { 10 | super(message); 11 | this.name = 'UnsupportedFileError'; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LosslessCut 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/renderer/src/App.module.css: -------------------------------------------------------------------------------- 1 | /* https://stackoverflow.com/questions/18270894/html5-video-does-not-hide-controls-in-fullscreen-mode-in-chrome */ 2 | video.video::-webkit-media-controls { 3 | display:none !important; 4 | } 5 | 6 | video.video::cue { 7 | background-color: rgba(0,0,0,0.3); 8 | } 9 | 10 | /* remove gap between subtitle lines https://stackoverflow.com/questions/41317044/need-help-styling-subtitles-on-html5-using-the-track-element */ 11 | video.video::-webkit-media-text-track-display { 12 | overflow: visible !important; 13 | } 14 | -------------------------------------------------------------------------------- /src/renderer/src/BetweenSegments.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { motion } from 'framer-motion'; 3 | import { FaTrashAlt, FaSave } from 'react-icons/fa'; 4 | 5 | import { mySpring } from './animations'; 6 | import { saveColor } from './colors'; 7 | import useUserSettings from './hooks/useUserSettings'; 8 | 9 | 10 | function BetweenSegments({ start, end, fileDurationNonZero, invertCutSegments }: { 11 | start: number, 12 | end: number, 13 | fileDurationNonZero: number, 14 | invertCutSegments: boolean, 15 | }) { 16 | const left = `${(start / fileDurationNonZero) * 100}%`; 17 | 18 | const { effectiveExportMode } = useUserSettings(); 19 | 20 | return ( 21 | 41 |
42 | {/* https://github.com/mifi/lossless-cut/issues/2157 */} 43 | {effectiveExportMode !== 'segments_to_chapters' && ( 44 | <> 45 | {invertCutSegments ? ( 46 | 47 | ) : ( 48 | 49 | )} 50 |
51 | 52 | )} 53 | 54 | ); 55 | } 56 | 57 | export default memo(BetweenSegments); 58 | -------------------------------------------------------------------------------- /src/renderer/src/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { Component, ErrorInfo, ReactNode } from 'react'; 2 | import { Trans } from 'react-i18next'; 3 | 4 | import { openSendReportDialog } from './reporting'; 5 | 6 | 7 | class ErrorBoundary extends Component<{ children: ReactNode }> { 8 | // eslint-disable-next-line react/state-in-constructor 9 | override state: { error: { message: string } | undefined }; 10 | 11 | constructor(props: { children: ReactNode }) { 12 | super(props); 13 | this.state = { error: undefined }; 14 | } 15 | 16 | static getDerivedStateFromError(error: unknown) { 17 | return { error }; 18 | } 19 | 20 | override componentDidCatch(error: Error, errorInfo: ErrorInfo) { 21 | console.error('componentDidCatch', error, errorInfo); 22 | } 23 | 24 | override render() { 25 | const { error } = this.state; 26 | if (error) { 27 | return ( 28 |
29 |

Something went wrong

30 |
{error.message}
31 |

32 |
33 | ); 34 | } 35 | 36 | // eslint-disable-next-line react/destructuring-assignment 37 | return this.props.children; 38 | } 39 | } 40 | 41 | export default ErrorBoundary; 42 | -------------------------------------------------------------------------------- /src/renderer/src/LastCommandsSheet.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, memo, SetStateAction } from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { DateTime } from 'luxon'; 4 | import sortBy from 'lodash/sortBy.js'; 5 | 6 | import CopyClipboardButton from './components/CopyClipboardButton'; 7 | import Sheet from './components/Sheet'; 8 | import { FfmpegCommandLog } from './types'; 9 | import Button from './components/Button'; 10 | 11 | function LastCommandsSheet({ visible, onTogglePress, ffmpegCommandLog, setFfmpegCommandLog }: { 12 | visible: boolean, 13 | onTogglePress: () => void, 14 | ffmpegCommandLog: FfmpegCommandLog, 15 | setFfmpegCommandLog: Dispatch>, 16 | }) { 17 | const { t } = useTranslation(); 18 | 19 | return ( 20 | 21 |

{t('Last ffmpeg commands')}

22 | 23 | {ffmpegCommandLog.length > 0 ? ( 24 |
25 | 26 | 27 | {sortBy(ffmpegCommandLog, (l) => -l.time).map(({ command, time }, i) => ( 28 | // eslint-disable-next-line react/no-array-index-key 29 |
30 | 31 | {DateTime.fromJSDate(time).toLocaleString(DateTime.TIME_WITH_SECONDS)} 32 | {command} 33 |
34 | ))} 35 |
36 | ) : ( 37 |

{t('The last executed ffmpeg commands will show up here after you run operations. You can copy them to clipboard and modify them to your needs before running on your command line.')}

38 | )} 39 |
40 | ); 41 | } 42 | 43 | export default memo(LastCommandsSheet); 44 | -------------------------------------------------------------------------------- /src/renderer/src/NoFileLoaded.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, memo, useMemo } from 'react'; 2 | 3 | import { useTranslation, Trans } from 'react-i18next'; 4 | 5 | import SetCutpointButton from './components/SetCutpointButton'; 6 | import SimpleModeButton from './components/SimpleModeButton'; 7 | import useUserSettings from './hooks/useUserSettings'; 8 | import { StateSegment } from './types'; 9 | import { KeyBinding, KeyboardAction } from '../../../types'; 10 | import { splitKeyboardKeys } from './util'; 11 | 12 | const electron = window.require('electron'); 13 | 14 | function Keys({ keys }: { keys: string }) { 15 | const split = splitKeyboardKeys(keys); 16 | return split.map((key, i) => ( 17 | {key.toUpperCase()}{i < split.length - 1 && {' + '}} 18 | )); 19 | } 20 | 21 | function NoFileLoaded({ mifiLink, currentCutSeg, onClick, darkMode, keyBindingByAction }: { 22 | mifiLink: unknown, 23 | currentCutSeg: StateSegment | undefined, 24 | onClick: () => void, 25 | darkMode?: boolean, 26 | keyBindingByAction: Record, 27 | }) { 28 | const { t } = useTranslation(); 29 | const { simpleMode } = useUserSettings(); 30 | 31 | const currentCutSegOrDefault = useMemo(() => currentCutSeg ?? { segColorIndex: 0 }, [currentCutSeg]); 32 | 33 | return ( 34 |
40 |
{t('DROP FILE(S)')}
41 | 42 |
43 | See Help menu for help 44 |
45 | 46 |
47 | or to set cutpoints 48 |
49 | 50 |
e.stopPropagation()}> 51 | {simpleMode ? ( 52 | to show advanced view 53 | ) : ( 54 | to show simple view 55 | )} 56 |
57 | 58 | {mifiLink && typeof mifiLink === 'object' && 'loadUrl' in mifiLink && typeof mifiLink.loadUrl === 'string' && mifiLink.loadUrl ? ( 59 |
60 |