diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ca374b5e..9766abfb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -## [Translations](docs/translation.md) +## [Translations / i18n](docs/translation.md) ## Development environment setup @@ -28,12 +28,30 @@ yarn download-ffmpeg-win32-arm64 For Windows, you may have to install [7z](https://www.7-zip.org/download.html), and then put the 7z folder in your `PATH`. -### Running +### Run app in development mode ```bash yarn dev ``` +### Run all code checks and tests + +Run linting, code style, types, tests etc with the following command: + +```bash +yarn check +``` + +Most of these checks are automatically run in GitHub Actions. + +### Other scripts + +See [package.json](./package.json) "scripts" section. + +### Contributing code + +To contribute code, use [pull requests](https://github.com/mifi/lossless-cut/pulls). If you would like to contribute a lot of code, please first create an issue to check the viability of your change. + ## `mas-dev` (Mac App Store) local build This will sign using the development provisioning profile: @@ -109,14 +127,6 @@ Before releasing, consider [Maintainence chores](#maintainence-chores) first. For per-platform build/signing setup, see [this article](https://mifi.no/blog/automated-electron-build-with-release-to-mac-app-store-microsoft-store-snapcraft/). -## Weblate - -`yarn scan-i18n` to get the newest English strings and push so Weblate gets them. - -Find the [latest PR](https://github.com/mifi/lossless-cut/pulls) from Weblate and **rebase+merge** it. - -**Warning:** Do not squash and merge (see [here why](docs/translation.md#weblate))! - ## Minimum OS version See [requirements](docs/requirements.md). @@ -152,10 +162,13 @@ Links: - `@electron/remote` - `package.json` / `yarn.lock` -### i18n -```bash -yarn scan-i18n -``` +### i18n strings / Weblate + +Run `yarn scan-i18n` to get the newest English strings and push so Weblate gets them. + +Find the [latest PR](https://github.com/mifi/lossless-cut/pulls) from Weblate and **rebase+merge** it. + +**Warning:** Do not squash and merge (see [here why](docs/translation.md#weblate))! ### Regenerate licenses file diff --git a/README.md b/README.md index d9736ccb..c2d0ca30 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

-

LosslessCut

+

LosslessCut

LosslessCut

The swiss army knife of lossless video/audio editing
@@ -130,6 +130,7 @@ LosslessCut uses the Chromium browser's HTML5 video player, and not all formats/ ## Documentation +- Official website: [LosslessCut.app](https://losslesscut.app) - **[Getting started, FAQ and usage documentation](docs/index.md)** - [Troubleshooting and known issues and limitations](docs/troubleshooting.md) - [Contributing](CONTRIBUTING.md) diff --git a/docs/index.md b/docs/index.md index de09c5ba..ed35ce22 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,6 +6,8 @@ Please read the documentation before creating an issue. Thank you 🙏 - **Q:** Is there a keyboard shortcut to do X? - **A:** First check the Keyboard shortcuts dialog. If you cannot find your shortcut there, [see this issue.](https://github.com/mifi/lossless-cut/issues/254) +- **Q:** How can I import multiple files into my project and edit them together? + - **A:** LosslessCut is not a [non-linear editor](https://github.com/mifi/lossless-cut/issues/976) (like Adobe Premiere) and it is not a linear editor (like Avidemux) - it is something in-between. You create segments on the timeline and the segments represent which portions of video will be exported. If you want more advanced workflows, you have to do it in multiple separate operations. - **Q:** Can LosslessCut be automated using a CLI or API or do external post-processing? - **A:** While it was never designed for advanced batching/automation, it does have a [basic CLI and a HTTP API](cli.md). More info: [#980](https://github.com/mifi/lossless-cut/issues/980) [#868](https://github.com/mifi/lossless-cut/issues/868). - **Q:** I cannot find a particular button or function. @@ -17,7 +19,7 @@ Please read the documentation before creating an issue. Thank you 🙏 - **Q** What's the difference between the app in the Apple/Microsoft App Store vs. GitHub releases? - **A** LosslessCut version in the App Stores is often a few versions behind the latest GitHub version, because I want to be sure that the new versions work perfectly before releasing in the App Stores. The GitHub version will contain new, untested features and may contain some bugs (even in existing functionality). I consider the newest GitHub versions to be a public "beta" test. Then, once I'm sure that the new version works well, I will release it in the App Stores as well to give a frictionless as possible experience for customers. They have exactly the same in-app features, except for a few platform limitations: Apple doesn't allow opening VOB files with App Store apps. Apple App Store apps run in a sandbox, and therefore need to prompt for output directory before allowing writing files. - **Q**: What are all these `.llc` files that get created? - - **A***: Their purpase is to save your segments so that when you exit/re-open LosslessCut you can continue where you left off. You can turn them off in app settings. See also [#2115](https://github.com/mifi/lossless-cut/issues/2115). + - **A***: Their purpose is to save your segments so that when you exit/re-open LosslessCut you can continue where you left off. You can turn them off in app settings. See also [#2115](https://github.com/mifi/lossless-cut/issues/2115). ## Commonly requested features @@ -53,7 +55,7 @@ If you have a problem with the app or with a file, please see the [🤔 Troubles - **Drag and drop** a video file into player or use /CTRL + O. - SPACE to play/pause or , . or mouse/trackpad wheel to seek back/forth. - Set the start/end times of the current segment by first moving the timeline cursor and then pressing I to set start time and O to set end time. You can also press hold SHIFT while dragging a segment with the mouse to move or resize it -- + to create a new segment. +- + to create another marker and repeat the previous step to convert it into a segment. - B to split the segment at the timeline cursor. - BACKSPACE to remove cutpoint/segment. - If you create segments without an end time, it is a [marker](#markers) instead of a segment. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 62f6660f..24075581 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -14,23 +14,29 @@ If the video exports successfully without any error from LosslessCut, but it doe ## Cutting times are not accurate -Each segment's *start cut time* normally (but not always) will be "rounded" to the nearest **previous** keyframe. This means that you often have to move the **start cut time** to **few frames after** the desired keyframe. +Each segment's *start cut time* will normally (but not always) be "rounded" to the nearest **previous** keyframe. This means that you often have to move the **start cut time** to **few frames after** the desired keyframe. - Lossless cutting is not an exact science. For some files, it just works. For others, you may need to trial and error to get the best cut. See [#330](https://github.com/mifi/lossless-cut/issues/330) - Your mileage may vary when it comes to *Keyframe cut mode*. Most common video files need *Keyframe cut* enabled, but you may need to try both values. [ffmpeg](https://trac.ffmpeg.org/wiki/Seeking) also has documentation about these two seek/cut modes. In `ffmpeg`, *Keyframe cut* corresponds to `-ss` *before* `-i`. - Try to change `avoid_negative_ts` (in export options). - Try also to set the **start**-cutpoint a few frames **before or after** the nearest keyframe (may also solve audio sync issues). -- You may try to enable the new "Smart cut" mode to allow cutting between keyframes. However it is very experimental and may not work for many files. +- You may try to enable the experimental "Smart cut" mode to allow cutting between keyframes. However it will not work for many files. - Currently, the only way to review the exported file (to check the actual cutpoints) is to run the export (possibly with only one segment enabled to speed up) and then manually check the output file. See also [#1887](https://github.com/mifi/lossless-cut/issues/1887) ### Cut starts from wrong keyframe -For some files, when you place segment start cutpoints at keyframes, and you export, it will instead cut from the keyframe **before** the keyframe that you wanted. This is because with some videos, ffmpeg struggles to find the nearest previous keyframe, see [#1216](https://github.com/mifi/lossless-cut/issues/1216). To workaround this, you can try to shift your segments' **start**-cutpoints forward by a few frames, so that ffmpeg correctly cuts from the *previous* keyframe. You can also enable the Export Option "Shift all start times" by +1, +2, +3 frames or so. +For some files, when you place segment start cutpoints at keyframes, and you export, it will instead cut from the keyframe **before** the keyframe that you wanted. This is because with some videos, FFmpeg struggles to find the nearest previous keyframe, see [#1216](https://github.com/mifi/lossless-cut/issues/1216). To workaround this, you can try to shift your segments' **start**-cutpoints forward by a few frames, so that ffmpeg correctly cuts from the *previous* keyframe. - Menu: "Edit" -> "Segments" -> "Shift all segments on timeline" - Enter `00:00:00.200` (or a larger value if it doesn't help) - When asked about Start or End timestamps, Select **Start** -This will effectively shift all start times of segments by 6 frames (`6/30=0.2` for 30fps video). +This will effectively shift all start times of segments by 6 frames (`6/30=0.2` for 30fps video). Alternatively, to always shift start times, you can enable the Export Option "Shift all start times" by +1, +2, +3 frames or so. + +In some videos (like VP9 from `yt-dlp`), some start keyframes are being ignored if LosslessCut output format is set to Matroska. A workaround is to set it to MP4 instead. See [#2804](https://github.com/mifi/lossless-cut/issues/2804). + +## Start cut is ignored + +Check whether your video has enough keyframes before your cutpoint. You can see keyframes on the timeline as vertical lines. You may have to zoom in to see them. If there are no keyframes, then it is most likely not possible to cut your file at the desired cutpoints losslessly. You can try to enable the Smart Cut mode, alternatively you may try to disable "keyframe cut" mode. See also (#2864)(https://github.com/mifi/lossless-cut/issues/2864). ## Cut file has same length as input @@ -85,6 +91,10 @@ LosslessCut uses the same video player that is used by Chrome. You can try to op Some formats or codecs are not natively supported by LosslessCut's built in player, and LosslessCut will automatically use FFmpeg-assisted software decoding to playback in a lower quality. For better playback you may convert these files to a different format from the menu: *File -> Convert to supported format*. Note that this will not affect the output from LosslessCut, it is only used for playback, see [#88](https://github.com/mifi/lossless-cut/issues/88). +### Auto convert to supported format on certain timestamp + +If LosslessCut automatically tries to convert your file into a supported format when you start playing/seeking and reach a certain timestamp, see issue [#2893](https://github.com/mifi/lossless-cut/issues/2893). + ## Linux specific issues - If the app crashes on startup or you get an error like `FATAL:setuid_sandbox_host.cc(157)] The SUID sandbox helper binary was found, but is not configured correctly...`, try to run it as `./lossless-cut --no-sandbox`. See [#258](https://github.com/mifi/lossless-cut/issues/258) and [#2614](https://github.com/mifi/lossless-cut/issues/2614) diff --git a/locales/en/translation.json b/locales/en/translation.json index 38ea1809..506f62c8 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -457,6 +457,7 @@ "Manually input current segment's end time": "Manually input current segment's end time", "Manually input current segment's start time": "Manually input current segment's start time", "Markers": "Markers", + "Markers are segments without an end time and will not be exported. Convert markers to segments by setting their end time.": "Markers are segments without an end time and will not be exported. Convert markers to segments by setting their end time.", "Markers:": "Markers:", "Max length": "Max length", "Maximize": "Maximize", @@ -517,6 +518,7 @@ "Open": "Open", "Open file": "Open file", "Open folder": "Open folder", + "Open link in browser": "Open link in browser", "Open media from URL": "Open media from URL", "Open next file": "Open next file", "Open previous file": "Open previous file", @@ -742,6 +744,7 @@ "The audio track is not supported while previewing. You can convert to a supported format from the menu": "The audio track is not supported while previewing. You can convert to a supported format from the menu", "The expression must return an object": "The expression must return an object", "The file name template is missing {{ext}} and will result in a file without the suggested extension. This may result in an unplayable output file.": "The file name template is missing {{ext}} and will result in a file without the suggested extension. This may result in an unplayable output file.", + "The language tag (ISO 639-2 code). For example \"eng\" for English. This is used by some players to select the appropriate audio/subtitle track based on the user's language preferences.": "The language tag (ISO 639-2 code). For example \"eng\" for English. This is used by some players to select the appropriate audio/subtitle track based on the user's language preferences.", "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.": "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.", "The media file referenced by the project file you tried to open does not exist in the same directory as the project file: {{mediaFileName}}": "The media file referenced by the project file you tried to open does not exist in the same directory as the project file: {{mediaFileName}}", "The media you tried to open does not exist": "The media you tried to open does not exist", @@ -821,6 +824,7 @@ "User interface": "User interface", "Using built-in ffmpeg": "Using built-in ffmpeg", "Using external ffmpeg": "Using external ffmpeg", + "Value must be shorter than total duration ({{totalDuration}})": "Value must be shorter than total duration ({{totalDuration}})", "Variables": "Variables", "video": "video", "Video FPS": "Video FPS", diff --git a/no.mifi.losslesscut.appdata.xml b/no.mifi.losslesscut.appdata.xml index 06094cbc..7866e385 100644 --- a/no.mifi.losslesscut.appdata.xml +++ b/no.mifi.losslesscut.appdata.xml @@ -18,7 +18,7 @@ A screenshot of the main window of LosslessCut - https://mifi.no/losslesscut/ + https://losslesscut.app/ https://github.com/mifi/lossless-cut/issues https://paypal.me/mifino/usd diff --git a/package.json b/package.json index 2da7fbff..ccd958eb 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/themes": "^3.3.0", "@tanstack/react-virtual": "3.13.18", "@tsconfig/node22": "^22.0.5", "@tsconfig/strictest": "^2.0.8", @@ -99,13 +100,13 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-unicorn": "56.0.1", - "fast-xml-parser": "^5.4.1", + "fast-xml-parser": "^5.7.0", "i18next-cli": "^1.34.1", "icon-gen": "^5.0.0", "immer": "^11.1.3", "ky": "^1.14.2", "leaflet": "^1.9.4", - "lodash": "^4.17.23", + "lodash": "^4.18.1", "luxon": "^3.7.2", "mkdirp": "^3.0.1", "motion": "^12.24.7", @@ -134,7 +135,7 @@ "typescript": "~5.9.3", "use-debounce": "^5.2.1", "use-trace-update": "^1.3.3", - "vite": "^7.3.0", + "vite": "^7.3.2", "vitest": "^4.0.16", "yargs": "^18.0.0" }, @@ -150,7 +151,7 @@ "express-async-handler": "^1.2.0", "file-type": "^21.3.2", "i18next": "^25.7.3", - "i18next-fs-backend": "^2.6.1", + "i18next-fs-backend": "^2.6.4", "json5": "^2.2.3", "lodash.debounce": "^4.0.8", "mime-types": "^3.0.2", diff --git a/src/common/constants.ts b/src/common/constants.ts index 3fa2d2db..fc96689f 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -1,4 +1,4 @@ -export const homepageUrl = 'https://mifi.no/losslesscut/'; +export const homepageUrl = 'https://losslesscut.app/'; export const githubUrl = 'https://github.com/mifi/lossless-cut/'; export const getReleaseUrl = (version: string) => `https://github.com/mifi/lossless-cut/releases/tag/v${version}`; export const compareReleasesUrl = (fromVersion: string, toVersion: string) => `https://github.com/mifi/lossless-cut/compare/v${fromVersion}...v${toVersion}`; diff --git a/src/common/util.ts b/src/common/util.ts index 1d1849b6..5f0db614 100644 --- a/src/common/util.ts +++ b/src/common/util.ts @@ -5,3 +5,17 @@ export const parseFfprobeDuration = (durationStr: string | undefined) => ( ); export const getHwaccelArgs = (hwaccel: FfmpegHwAccel) => (hwaccel !== 'none' ? ['-hwaccel', hwaccel] : []); + +// Used to be 5, but we recently increased to 6 because https://github.com/mifi/lossless-cut/issues/2838 +// I don't remember why 5 was chosen initially, but if we don't truncate, ffmpeg can sometimes give an error when too many decimal places are used in the time argument, see: +export const formatFfmpegTime = (time: number) => time.toFixed(6); + +export function parseRatio(str: string, char = '/') { + const split = str.split(char); + if (split.length !== 2) return undefined; + const num = parseInt(split[0]!, 10); + const den = parseInt(split[1]!, 10); + if (Number.isNaN(num) || Number.isNaN(den)) return undefined; + if (den <= 0) return undefined; + return num / den; +} diff --git a/src/main/configStore.ts b/src/main/configStore.ts index c9f7e6a2..58a495de 100644 --- a/src/main/configStore.ts +++ b/src/main/configStore.ts @@ -26,8 +26,8 @@ const defaultKeyBindings: KeyBinding[] = [ { keys: 'Comma', action: 'seekPreviousFrame' }, { keys: 'Period', action: 'seekNextFrame' }, { keys: 'KeyC', action: 'captureSnapshot' }, - { keys: 'ControlLeft+KeyC', action: 'copySegmentsToClipboard' }, - { keys: 'MetaLeft+KeyC', action: 'copySegmentsToClipboard' }, + { keys: 'ControlLeft+AltLeft+KeyC', action: 'copySegmentsToClipboard' }, + { keys: 'MetaLeft+AltLeft+KeyC', action: 'copySegmentsToClipboard' }, { keys: 'ShiftLeft+KeyC', action: 'captureSnapshotToClipboard' }, { keys: 'KeyI', action: 'setCutStart' }, diff --git a/src/main/ffmpeg.ts b/src/main/ffmpeg.ts index 3e2e71c9..eeab304f 100644 --- a/src/main/ffmpeg.ts +++ b/src/main/ffmpeg.ts @@ -14,7 +14,7 @@ import type { FFprobeFormat } from '../common/ffprobe.js'; import isDev from './isDev.js'; import logger from './logger.js'; import { parseFfmpegProgressLine } from './progress.js'; -import { getHwaccelArgs, parseFfprobeDuration } from '../common/util.js'; +import { formatFfmpegTime, getHwaccelArgs, parseFfprobeDuration } from '../common/util.js'; import { getFfmpegJpegQuality } from './ffmpegUtil.js'; @@ -41,11 +41,13 @@ function escapeCliArg(arg: string) { return /[^\w-]/.test(arg) ? `'${String(arg).replaceAll("'", '\'"\'"\'')}'` : arg; } -export function getFfCommandLine(cmd: string, args: readonly string[]) { +export type FfCommand = 'ffmpeg' | 'ffprobe'; + +export function getFfCommandLine(cmd: FfCommand, args: readonly string[]) { return `${cmd} ${args.map((arg) => escapeCliArg(arg)).join(' ')}`; } -function getFfPath(cmd: string) { +function getFfPath(cmd: FfCommand) { const exeName = isWindows ? `${cmd}.exe` : cmd; if (customFfPath) return join(customFfPath, exeName); @@ -55,10 +57,12 @@ function getFfPath(cmd: string) { } // local dev - const components = ['ffmpeg', `${platform}-${arch}`]; - if (isWindows || isLinux) components.push('lib'); - components.push(exeName); - return join(...components); + return join( + 'ffmpeg', + `${platform}-${arch}`, + ...(isWindows || isLinux ? ['lib'] : []), + exeName, + ); } const getFfprobePath = () => getFfPath('ffprobe'); @@ -256,9 +260,9 @@ export async function renderWaveformPng({ filePath, start, duration, resample, c } const getInputSeekArgs = ({ filePath, from, to }: { filePath: string, from?: number | undefined, to?: number | undefined }) => [ - ...(from != null ? ['-ss', from.toFixed(5)] : []), + ...(from != null ? ['-ss', formatFfmpegTime(from)] : []), '-i', filePath, - ...(from != null && to != null ? ['-t', (to - from).toFixed(5)] : []), + ...(from != null && to != null ? ['-t', formatFfmpegTime(to - from)] : []), ]; export function mapTimesToSegments(times: number[], includeLast: boolean) { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 8a358eba..2cd1a0df 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -13,6 +13,7 @@ import fromPairs from 'lodash/fromPairs'; import sum from 'lodash/sum'; import invariant from 'tiny-invariant'; import type { SweetAlertOptions } from 'sweetalert2'; +import { Theme } from '@radix-ui/themes'; import useTimelineScroll from './hooks/useTimelineScroll'; import useUserSettingsRoot from './hooks/useUserSettingsRoot'; @@ -83,7 +84,7 @@ import { import getSwal, { errorToast, showPlaybackFailedMessage } from './swal'; import { adjustRate } from './util/rate-calculator'; import { askExtractFramesAsImages } from './dialogs/extractFrames'; -import type { CleanupChoicesType } from './dialogs'; +import type { CleanupChoicesType, OpenFileResponse } from './dialogs'; import { askForOutDir, askForImportChapters, askForFileOpenAction, showDiskFull, showExportFailedDialog, showConcatFailedDialog, openYouTubeChaptersDialog, showRefuseToOverwrite, showOpenDialog, showMuxNotSupported, promptDownloadMediaUrl, showOutputNotWritable, deleteFiles, mustDisallowVob, toastError } from './dialogs'; import { openSendReportDialog } from './reporting'; import { sortSegments, convertSegmentsToChaptersWithGaps, hasAnySegmentOverlap, isDurationValid, getPlaybackAction, getSegmentTags, filterNonMarkers, isInitialSegment } from './segments'; @@ -194,14 +195,14 @@ function App() { const { withErrorHandling, handleError, genericError, setGenericError } = useErrorHandling(); - const { showGenericDialog, genericDialog, closeGenericDialog, confirmDialog, openExportFinishedDialog, openCutFinishedDialog, openConcatFinishedDialog, openCleanupFilesDialog } = useDialog(); + const { showGenericDialog, genericDialog, closeGenericDialog, confirmDialog, openExportFinishedDialog, openCutFinishedDialog, openConcatFinishedDialog, openCleanupFilesDialog, openShiftSegmentsDialog } = useDialog(); // Note that each action may be multiple key bindings and this will only be the first binding for each action const keyBindingByAction = useMemo(() => Object.fromEntries(keyBindings.map((binding) => [binding.action, binding])), [keyBindings]); const { working, setWorking, workingRef, abortWorking } = useLoading(); const { videoRef, videoContainerRef, playbackRate, setPlaybackRate, outputPlaybackRate, setOutputPlaybackRate, commandedTime, seekAbs, playingRef, getRelevantTime, setPlaying, onSeeked, relevantTime, onStartPlaying, setCommandedTime, setOutputPlaybackRateState, commandedTimeRef, onStopPlaying, onVideoAbort, playerTime, setPlayerTime, playbackMode, setPlaybackMode, playbackModeRef, playing, play, pause, seekRel } = useVideo({ filePath }); - const { timecodePlaceholder, formatTimecode, formatTimeAndFrames, parseTimecode, getFrameCount, promptTimecode } = useTimecode({ detectedFps, timecodeFormat, showGenericDialog }); + const { timecodePlaceholder, formatTimecode, parseTimecode, getFrameCount, promptTimecode } = useTimecode({ detectedFps, timecodeFormat, showGenericDialog }); const { loadSubtitle, subtitlesByStreamId, setSubtitlesByStreamId } = useSubtitles(); const fileDurationNonZero = isDurationValid(fileDuration) ? fileDuration : 1; @@ -230,6 +231,10 @@ function App() { const isFileOpened = !!filePath; + const [hoveringTime, setHoveringTime] = useState(); + + const displayTime = (hoveringTime != null && isFileOpened && !playing ? hoveringTime : relevantTime) + startTimeOffset; + const onOutputFormatUserChange = useCallback((newFormat: string) => { setFileFormat(newFormat); if (outFormatLocked) { @@ -345,7 +350,7 @@ function App() { }, [isFileOpened]); const { - cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, labelSegment, splitCurrentSegment, focusSegmentAtCursor, selectSegmentsAtCursor, createNumSegments, createFixedDurationSegments, createFixedByteSizedSegments, createRandomSegments, getSegEstimatedSize, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, inverseCutSegments, clearSegments, clearSegColorCounter, loadCutSegments, setCutTime, setCurrentSegIndex, labelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, selectSegmentsByLabel, selectSegmentsByExpr, selectAllMarkers, mutateSegmentsByExpr, toggleSegmentSelected, selectOnlySegment, selectedSegments, segmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment, updateSegAtIndex, findSegmentsAtCursor, maybeCreateFullLengthSegment, currentCutSegOrWholeTimeline, segColorCounter, + cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, modifySelectedSegmentTimes, alignSegmentTimesToKeyframes, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, labelSegment, splitCurrentSegment, focusSegmentAtCursor, selectSegmentsAtCursor, createNumSegments, createFixedDurationSegments, createFixedByteSizedSegments, createRandomSegments, getSegEstimatedSize, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, inverseCutSegments, clearSegments, clearSegColorCounter, loadCutSegments, setCutTime, setCurrentSegIndex, labelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, selectSegmentsByLabel, selectSegmentsByExpr, selectAllMarkers, mutateSegmentsByExpr, toggleSegmentSelected, selectOnlySegment, selectedSegments, segmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment, updateSegAtIndex, findSegmentsAtCursor, maybeCreateFullLengthSegment, currentCutSegOrWholeTimeline, segColorCounter, } = useSegments({ filePath, workingRef, setWorking, setProgress, videoStream: activeVideoStream, fileDuration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode, appendFfmpegCommandLog, fileDurationNonZero, mainFileMeta: mainFileMeta?.ffprobeMeta, seekAbs, activeVideoStreamIndex, activeAudioStreamIndexes, handleError, showGenericDialog, simpleMode, ffmpegHwaccel }); const { getEdlFilePath, projectFileSavePath, getProjectFileSavePath } = useSegmentsAutoSave({ autoSaveProjectFile, storeProjectInWorkingDir, filePath, customOutDir, cutSegments }); @@ -864,8 +869,8 @@ function App() { const generateCutMergedFileNames = useCallback(async (template: string) => { invariant(fileFormat != null && outputDir != null && filePath != null); - return generateCutMergedFileNamesRaw({ template, isCustomFormatSelected, fileFormat, sourceFile: { path: filePath }, outputDir, safeOutputFileName, maxLabelLength, exportCount, currentFileExportCount, segLabels: segmentsToExport.map((seg) => seg.name ?? '') }); - }, [currentFileExportCount, exportCount, fileFormat, filePath, isCustomFormatSelected, maxLabelLength, outputDir, safeOutputFileName, segmentsToExport]); + return generateCutMergedFileNamesRaw({ template, isCustomFormatSelected, fileFormat, sourceFile: { path: filePath, ...mainFileMeta }, outputDir, safeOutputFileName, maxLabelLength, exportCount, currentFileExportCount, segLabels: segmentsToExport.map((seg) => seg.name ?? '') }); + }, [currentFileExportCount, exportCount, fileFormat, filePath, isCustomFormatSelected, mainFileMeta, maxLabelLength, outputDir, safeOutputFileName, segmentsToExport]); const generateMergedFileNames = useCallback(async (params: GenerateMergedOutFileNamesParams) => ( generateMergedFileNamesRaw({ ...params, isCustomFormatSelected, safeOutputFileName, maxLabelLength, exportCount }) @@ -913,11 +918,9 @@ function App() { invariant(fileName != null); const outPath = getOutPath({ customOutDir, filePath: firstPath, fileName }); - let chaptersFromSegments: Awaited>; - if (segmentsToChapters) { - const chapterNames = paths.map((path) => parsePath(path).name); - chaptersFromSegments = await createChaptersFromSegments({ segmentPaths: paths, chapterNames }); - } + const chaptersFromSegments = segmentsToChapters + ? await createChaptersFromSegments({ paths, defaultChapterNames: paths.map((path) => parsePath(path).name), useFileChapters: true }) + : undefined; let inputSize: number; try { @@ -1269,6 +1272,23 @@ function App() { } }, [filePath, workingRef, setWorking, withErrorHandling, getRelevantTime, videoRef, usingPreviewFile, captureFrameMethod, captureFrameFromFfmpeg, customOutDir, captureFormat, captureFrameQuality, captureFrameFromTag, simpleMode, prefersReducedMotion, hideAllNotifications, openExportFinishedDialog]); + const shiftAllSegmentTimes = useCallback(async () => { + const shift = await openShiftSegmentsDialog({ inputPlaceholder: timecodePlaceholder, parseTimecode }); + if (shift == null) return; + + const { startShift, endShift } = shift; + await modifySelectedSegmentTimes((segment) => { + const newSegment = { ...segment }; + if (startShift != null) { + newSegment.start += startShift; + } + if (endShift != null && newSegment.end != null) { + newSegment.end += endShift; + } + return newSegment; + }); + }, [modifySelectedSegmentTimes, openShiftSegmentsDialog, parseTimecode, timecodePlaceholder]); + const captureSnapshotToClipboard = useCallback(async () => { if (!filePath || workingRef.current) return; try { @@ -1527,10 +1547,11 @@ function App() { const toggleSettings = useCallback(() => setSettingsVisible((val) => !val), []); const seekClosestKeyframe = useCallback((direction: number) => { - const time = findNearestKeyFrameTime({ time: getRelevantTime(), direction }); + const sigma = detectedFps ? (1 / detectedFps) : 0.1; // because we don't want it to find the keyframe we're currently at. + const time = findNearestKeyFrameTime({ time: getRelevantTime() + direction * sigma, direction }); if (time == null) return; seekAbs(time); - }, [findNearestKeyFrameTime, getRelevantTime, seekAbs]); + }, [detectedFps, findNearestKeyFrameTime, getRelevantTime, seekAbs]); const onTimelineWheel = useTimelineScroll({ wheelSensitivity, mouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, invertTimelineScroll, zoomRel, seekRel, shortStep, seekClosestKeyframe }); @@ -1838,11 +1859,12 @@ function App() { if (isFileOpened) inputOptions.mergeWithCurrentFile = i18n.t('Merge/concatenate with current file'); if (batchFiles.length > 0 || newFilePaths.length > 1) inputOptions.addToBatch = i18n.t('Add the file to the batch list'); - const inputOptionsKeys = Object.keys(inputOptions); + const inputOptionsKeys = Object.keys(inputOptions) as (keyof typeof inputOptions)[]; - let openFileResponse: string | undefined; + let openFileResponse: OpenFileResponse | undefined; if (inputOptionsKeys.length === 1) [openFileResponse] = inputOptionsKeys; - if (enableAskForFileOpenAction && inputOptionsKeys.length > 1) openFileResponse = await askForFileOpenAction(inputOptions); + if (!enableAskForFileOpenAction && inputOptionsKeys.length > 1) openFileResponse = 'addToBatch'; + if (enableAskForFileOpenAction && inputOptionsKeys.length > 1) openFileResponse = await askForFileOpenAction(Object.entries(inputOptions) as [OpenFileResponse, string][]); else if (newFilePaths.length === 1) openFileResponse = 'open'; if (openFileResponse === 'open') { @@ -1902,8 +1924,8 @@ function App() { setConcatDialogOpen(true); }, [batchFiles.length, openFilesDialog]); - const togglePlaySelectedSegments = useCallback(() => togglePlay({ resetPlaybackRate: true, requestPlaybackMode: 'play-selected-segments' }), [togglePlay]); - const toggleLoopSelectedSegments = useCallback(() => togglePlay({ resetPlaybackRate: true, requestPlaybackMode: 'loop-selected-segments' }), [togglePlay]); + const togglePlaySelectedSegments = useCallback(() => togglePlay({ resetPlaybackRate: false, requestPlaybackMode: 'play-selected-segments' }), [togglePlay]); + const toggleLoopSelectedSegments = useCallback(() => togglePlay({ resetPlaybackRate: false, requestPlaybackMode: 'loop-selected-segments' }), [togglePlay]); const copySegmentsToClipboard = useCallback(async () => { if (!isFileOpened || selectedSegments.length === 0) return; @@ -2443,348 +2465,353 @@ function App() { const rootStyle = useMemo(() => ({ ...baseColorStyle, display: 'flex', flexDirection: 'column', height: '100vh', transition: darkModeTransition }), [baseColorStyle]); return ( - - - - -
- - -
- - {showLeftBar && ( - - )} - - - {/* Middle part (also shown in fullscreen): */} -
- {!isFileOpened && } - -
- {/* eslint-disable-next-line jsx-a11y/media-has-caption */} - - - {filePath != null && compatPlayerEnabled && } -
+ + + + + +
+ - {bigWaveformEnabled && } - - {compatPlayerEnabled && ( -
- {isRotationSet ? ( - <> - - {t('Rotation preview')} - - ) : ( - <> - {t('FFmpeg-assisted playback')} - - )} +
+ + {showLeftBar && ( + + )} + + + {/* Middle part (also shown in fullscreen): */} +
+ {!isFileOpened && } + +
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption */} + + + {filePath != null && compatPlayerEnabled && } +
-
incrementMediaSourceQuality()} title={t('Select playback quality')}>{mediaSourceQualities[mediaSourceQuality]}
+ {bigWaveformEnabled && } + + {compatPlayerEnabled && ( +
+ {isRotationSet ? ( + <> + + {t('Rotation preview')} + + ) : ( + <> + {t('FFmpeg-assisted playback')} + + )} + +
incrementMediaSourceQuality()} title={t('Select playback quality')}>{mediaSourceQualities[mediaSourceQuality]}
+ + {!compatPlayerRequired && } +
+ )} - {!compatPlayerRequired && } -
- )} + {isFileOpened && ( +
+ + + {shouldShowPlaybackStreamSelector && ( + + )} + + {!showRightBar && ( + + )} +
+ )} +
- {isFileOpened && ( -
- + + {showRightBar && isFileOpened && filePath != null && ( + + )} + +
- {shouldShowPlaybackStreamSelector && ( - - )} +
+ + + +
- {!showRightBar && ( - setTunerVisible(undefined)} />} + + {/* Dialogs */} + + + + + + + + {t('Tracks')} + {t('Click to select which tracks to keep when exporting:')} + + {mainStreams && filePath != null && ( + )} -
- )} -
+ + + + + + + + + + + + {t('Settings')} + {t('Hover mouse over buttons in the main interface to see which function they have')} + + + + + + + + 0 && concatDialogOpen} onHide={() => setConcatDialogOpen(false)} paths={batchFilePaths} mergedFileTemplate={mergedFileTemplateOrDefault} generateMergedFileNames={generateMergedFileNames} onConcat={userConcatFiles} setAlwaysConcatMultipleFiles={setAlwaysConcatMultipleFiles} alwaysConcatMultipleFiles={alwaysConcatMultipleFiles} fileFormat={fileFormat} setFileFormat={setFileFormat} detectedFileFormat={detectedFileFormat} setDetectedFileFormat={setDetectedFileFormat} onOutputFormatUserChange={onOutputFormatUserChange} /> + + setKeyboardShortcutsVisible(false)} keyBindings={keyBindings} setKeyBindings={setKeyBindings} currentCutSeg={currentCutSeg} resetKeyBindings={resetKeyBindings} getKeyboardAction={getKeyboardAction} /> + + {/* This should probably be last, so that it's always on top */} - {showRightBar && isFileOpened && filePath != null && ( - - )} + {working && } -
-
- + !open && closeGenericDialog()} /> - -
+ - {tunerVisible != null && setTunerVisible(undefined)} />} - - {/* Dialogs */} - - - - - - - - {t('Tracks')} - {t('Click to select which tracks to keep when exporting:')} - - {mainStreams && filePath != null && ( - - )} + !open && setGenericError(undefined)} /> +
- - - - - - - - - - - - {t('Settings')} - {t('Hover mouse over buttons in the main interface to see which function they have')} - - - - - - - - 0 && concatDialogOpen} onHide={() => setConcatDialogOpen(false)} paths={batchFilePaths} mergedFileTemplate={mergedFileTemplateOrDefault} generateMergedFileNames={generateMergedFileNames} onConcat={userConcatFiles} setAlwaysConcatMultipleFiles={setAlwaysConcatMultipleFiles} alwaysConcatMultipleFiles={alwaysConcatMultipleFiles} fileFormat={fileFormat} setFileFormat={setFileFormat} detectedFileFormat={detectedFileFormat} setDetectedFileFormat={setDetectedFileFormat} onOutputFormatUserChange={onOutputFormatUserChange} /> - - setKeyboardShortcutsVisible(false)} keyBindings={keyBindings} setKeyBindings={setKeyBindings} currentCutSeg={currentCutSeg} resetKeyBindings={resetKeyBindings} getKeyboardAction={getKeyboardAction} /> - - {/* This should probably be last, so that it's always on top */} - - {working && } - - - !open && closeGenericDialog()} /> - - - - !open && setGenericError(undefined)} /> -
- - -
-
-
-
+ + + + + + ); } diff --git a/src/renderer/src/BottomBar.tsx b/src/renderer/src/BottomBar.tsx index 127544ec..2c4463ab 100644 --- a/src/renderer/src/BottomBar.tsx +++ b/src/renderer/src/BottomBar.tsx @@ -25,7 +25,7 @@ import { useSegColors } from './contexts'; import { isExactDurationMatch } from './util/duration'; import useUserSettings from './hooks/useUserSettings'; import { askForPlaybackRate, checkAppPath } from './dialogs'; -import type { FormatTimecode, ParseTimecode, PlaybackMode, SegmentColorIndex, SegmentToExport, StateSegment } from './types'; +import type { FormatTimecode, GetFrameCount, ParseTimecode, PlaybackMode, SegmentColorIndex, SegmentToExport, StateSegment } from './types'; import type { WaveformMode } from '../../common/types'; import type { Frame } from './ffmpeg'; @@ -36,6 +36,16 @@ const zoomOptions = Array.from({ length: 13 }).fill(undefined).map((_unused, z) const leftRightWidth = 100; +const timeWrapperStyle: CSSProperties = { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + position: 'absolute', + inset: 0, + marginLeft: '1.5em', + pointerEvents: 'none', +}; + // eslint-disable-next-line react/display-name const InvertCutModeButton = memo(({ invertCutSegments, setInvertCutSegments }: { invertCutSegments: boolean, setInvertCutSegments: Dispatch> }) => { const { t } = useTranslation(); @@ -218,8 +228,9 @@ const CutTimeInput = memo(({ disabled, darkMode, cutTime, setCutTime, startTimeO marginLeft: isStart ? 0 : 5, marginRight: isStart ? 5 : 0, boxSizing: 'border-box', - fontFamily: 'inherit', - width: 90, + fontFamily: 'monospace', + letterSpacing: '-.05em', + width: 94, outline: 'none', color: error ? dangerColor : (isCutTimeManualSet() ? 'var(--gray-12)' : 'var(--gray-11)'), }), [border, error, isCutTimeManualSet, isStart]); @@ -260,7 +271,7 @@ function BottomBar({ toggleShowThumbnails, toggleWaveformMode, waveformMode, showThumbnails, outputPlaybackRate, setOutputPlaybackRate, formatTimecode, parseTimecode, playbackRate, - currentFrame, playbackMode, + currentFrame, playbackMode, displayTime, fileDurationNonZero, getFrameCount, }: { zoom: number, setZoom: (fn: (z: number) => number) => void, @@ -310,10 +321,15 @@ function BottomBar({ playbackRate: number, currentFrame: Frame | undefined, playbackMode: PlaybackMode | undefined, + displayTime: number, + fileDurationNonZero: number, + getFrameCount: GetFrameCount, }) { const { t } = useTranslation(); const { getSegColor } = useSegColors(); + const isZoomed = zoom > 1; + const playStyle = useMemo(() => ({ paddingLeft: playing ? 0 : '.1em', color: 'white', @@ -372,7 +388,7 @@ function BottomBar({ const playbackRateRef = useRef(null); useEffect(() => { - playbackRateRef.current?.animate([{ transform: 'scale(1.7)', color: 'var(--gray-12)' }, {}], { duration: 200 }); + playbackRateRef.current?.animate([{ transform: 'scale(2)', color: 'var(--gray-12)', backgroundColor: playbackRate === 1 ? 'var(--cyan-10)' : (playbackRate < 1 ? 'var(--yellow-8)' : 'var(--orange-10)') }, {}], { duration: 200 }); }, [playbackRate]); function renderJumpCutpointButton(direction: number) { @@ -404,9 +420,11 @@ function BottomBar({ const currentCutSegOrDefault = useMemo(() => currentCutSeg ?? { segColorIndex: 0 }, [currentCutSeg]); + const displayTimeFrameCount = useMemo(() => getFrameCount(displayTime), [displayTime, getFrameCount]); + return ( <> -
+
{!simpleMode && ( <> @@ -530,10 +548,7 @@ function BottomBar({
-
+
@@ -555,9 +570,9 @@ function BottomBar({ ))} -
{playbackRate.toFixed(1)}
+
{playbackRate.toFixed(1)}
-
+
{detectedFps != null && ( @@ -568,7 +583,7 @@ function BottomBar({ )} {isFileOpened && !simpleMode && hasVideo && ( -
+
+
+
+ {formatTimecode({ seconds: displayTime })} + + {displayTimeFrameCount ?? 0}f + {isZoomed && {Math.round((displayTime / fileDurationNonZero) * 100)}%} + +
+
+ {!simpleMode && isFileOpened && ( +