Merge branch 'master' into feat/643-lossless-crop-rotate-aspect

pull/2846/head
Mikael Finstad 4 months ago committed by GitHub
commit c2913aa12d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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

@ -1,6 +1,6 @@
<div align="center">
<br>
<p><a href="https://mifi.no/losslesscut/"><img src="src/renderer/src/icon.svg" width="120" alt="LosslessCut" /></a></p>
<p><a href="https://losslesscut.app/"><img src="src/renderer/src/icon.svg" width="120" alt="LosslessCut" /></a></p>
<p><b>LosslessCut</b></p>
The swiss army knife of lossless video/audio editing
<br>
@ -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)

@ -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 <kbd></kbd>/<kbd>CTRL</kbd> + <kbd>O</kbd>.
- <kbd>SPACE</kbd> to play/pause or <kbd></kbd> <kbd></kbd> <kbd>,</kbd> <kbd>.</kbd> 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 <kbd>I</kbd> to set start time and <kbd>O</kbd> to set end time. You can also press hold <kbd>SHIFT</kbd> while dragging a segment with the mouse to move or resize it
- <kbd>+</kbd> to create a new segment.
- <kbd>+</kbd> to create another marker and repeat the previous step to convert it into a segment.
- <kbd>B</kbd> to split the segment at the timeline cursor.
- <kbd>BACKSPACE</kbd> to remove cutpoint/segment.
- If you create segments without an end time, it is a [marker](#markers) instead of a segment.

@ -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)

@ -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",

@ -18,7 +18,7 @@
<caption>A screenshot of the main window of LosslessCut</caption>
</screenshot>
</screenshots>
<url type="homepage">https://mifi.no/losslesscut/</url>
<url type="homepage">https://losslesscut.app/</url>
<url type="bugtracker">https://github.com/mifi/lossless-cut/issues</url>
<url type="donation">https://paypal.me/mifino/usd</url>
<releases>

@ -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",

@ -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}`;

@ -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;
}

@ -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' },

@ -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) {

@ -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<number>();
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<ReturnType<typeof createChaptersFromSegments>>;
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<CSSProperties>(() => ({ ...baseColorStyle, display: 'flex', flexDirection: 'column', height: '100vh', transition: darkModeTransition }), [baseColorStyle]);
return (
<MotionConfig reducedMotion={reducedMotion}>
<AppContext.Provider value={appContext}>
<SegColorsContext.Provider value={segColorsContext}>
<UserSettingsContext.Provider value={userSettingsContext}>
<div className={rootClass} style={rootStyle} id="app-root">
<TopMenu
filePath={filePath}
fileFormat={fileFormat}
changeEnabledStreamsFilter={changeEnabledStreamsFilter}
applyEnabledStreamsFilter={applyEnabledStreamsFilter}
enabledStreamsFilter={enabledStreamsFilter}
isCustomFormatSelected={isCustomFormatSelected}
renderOutFmt={renderOutFmt}
toggleSettings={toggleSettings}
numStreamsToCopy={numStreamsToCopy}
numStreamsTotal={numStreamsTotal}
setStreamsSelectorShown={setStreamsSelectorShown}
selectedSegments={segmentsOrInverse.selected}
toggleDarkMode={toggleDarkMode}
/>
<div style={{ flexGrow: 1, display: 'flex', overflowY: 'hidden' }}>
<AnimatePresence>
{showLeftBar && (
<BatchFilesList
selectedBatchFiles={selectedBatchFiles}
filePath={filePath}
width={leftBarWidth}
batchFiles={batchFiles}
setBatchFiles={setBatchFiles}
onBatchFileSelect={onBatchFileSelect}
batchListRemoveFile={batchListRemoveFile}
closeBatch={closeBatch}
onMergeFilesClick={concatBatch}
onBatchConvertToSupportedFormatClick={convertFormatBatch}
onDrop={handleBatchFilesDrop}
/>
)}
</AnimatePresence>
{/* Middle part (also shown in fullscreen): */}
<div style={{ position: 'relative', flexGrow: 1, overflow: 'hidden' }} ref={videoContainerRef}>
{!isFileOpened && <NoFileLoaded mifiLink={mifiLink} currentCutSeg={currentCutSeg} onClick={openFilesDialog} darkMode={darkMode} keyBindingByAction={keyBindingByAction} />}
<div className="no-user-select" style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, visibility: !isFileOpened || !hasVideo || bigWaveformEnabled ? 'hidden' : undefined }} onWheel={onTimelineWheel}>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
className={styles['video']}
tabIndex={-1}
muted={playbackVolume === 0 || compatPlayerEnabled}
ref={videoRef}
style={videoStyle}
src={fileUri}
onPlay={onStartPlaying}
onPause={onStopPlaying}
onAbort={onVideoAbort}
onDurationChange={onDurationChange}
onTimeUpdate={onTimeUpdate}
onError={onVideoError}
onClick={onVideoClick}
onDoubleClick={toggleFullscreenVideo}
onFocusCapture={onVideoFocus}
onSeeked={onSeeked}
>
{renderSubtitles()}
</video>
{filePath != null && compatPlayerEnabled && <MediaSourcePlayer rotate={effectiveRotation} filePath={filePath} videoStream={activeVideoStream} audioStreams={activeAudioStreams} masterVideoRef={videoRef} mediaSourceQuality={mediaSourceQuality} ffmpegHwaccel={ffmpegHwaccel} />}
</div>
<Theme appearance={darkMode ? 'dark' : 'light'} accentColor="cyan">
<MotionConfig reducedMotion={reducedMotion}>
<AppContext.Provider value={appContext}>
<SegColorsContext.Provider value={segColorsContext}>
<UserSettingsContext.Provider value={userSettingsContext}>
<div className={rootClass} style={rootStyle} id="app-root">
<TopMenu
filePath={filePath}
fileFormat={fileFormat}
changeEnabledStreamsFilter={changeEnabledStreamsFilter}
applyEnabledStreamsFilter={applyEnabledStreamsFilter}
enabledStreamsFilter={enabledStreamsFilter}
isCustomFormatSelected={isCustomFormatSelected}
renderOutFmt={renderOutFmt}
toggleSettings={toggleSettings}
numStreamsToCopy={numStreamsToCopy}
numStreamsTotal={numStreamsTotal}
setStreamsSelectorShown={setStreamsSelectorShown}
selectedSegments={segmentsOrInverse.selected}
toggleDarkMode={toggleDarkMode}
/>
{bigWaveformEnabled && <BigWaveform waveforms={waveforms} relevantTime={relevantTime} playing={playing} fileDurationNonZero={fileDurationNonZero} zoom={zoomUnrounded} seekRel={seekRel} darkMode={darkMode} />}
{compatPlayerEnabled && (
<div style={{ position: 'absolute', top: 0, right: 0, left: 0, marginTop: '1em', marginLeft: '1em', color: 'var(--gray-12)', opacity: 0.7, display: 'flex', alignItems: 'center', pointerEvents: 'none' }}>
{isRotationSet ? (
<>
<MdRotate90DegreesCcw size={26} style={{ marginRight: 5 }} />
{t('Rotation preview')}
</>
) : (
<>
{t('FFmpeg-assisted playback')}
</>
)}
<div style={{ flexGrow: 1, display: 'flex', overflowY: 'hidden' }}>
<AnimatePresence>
{showLeftBar && (
<BatchFilesList
selectedBatchFiles={selectedBatchFiles}
filePath={filePath}
width={leftBarWidth}
batchFiles={batchFiles}
setBatchFiles={setBatchFiles}
onBatchFileSelect={onBatchFileSelect}
batchListRemoveFile={batchListRemoveFile}
closeBatch={closeBatch}
onMergeFilesClick={concatBatch}
onBatchConvertToSupportedFormatClick={convertFormatBatch}
onDrop={handleBatchFilesDrop}
/>
)}
</AnimatePresence>
{/* Middle part (also shown in fullscreen): */}
<div style={{ position: 'relative', flexGrow: 1, overflow: 'hidden' }} ref={videoContainerRef}>
{!isFileOpened && <NoFileLoaded mifiLink={mifiLink} currentCutSeg={currentCutSeg} onClick={openFilesDialog} darkMode={darkMode} keyBindingByAction={keyBindingByAction} />}
<div className="no-user-select" style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, visibility: !isFileOpened || !hasVideo || bigWaveformEnabled ? 'hidden' : undefined }} onWheel={onTimelineWheel}>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
className={styles['video']}
tabIndex={-1}
muted={playbackVolume === 0 || compatPlayerEnabled}
ref={videoRef}
style={videoStyle}
src={fileUri}
onPlay={onStartPlaying}
onPause={onStopPlaying}
onAbort={onVideoAbort}
onDurationChange={onDurationChange}
onTimeUpdate={onTimeUpdate}
onError={onVideoError}
onClick={onVideoClick}
onDoubleClick={toggleFullscreenVideo}
onFocusCapture={onVideoFocus}
onSeeked={onSeeked}
>
{renderSubtitles()}
</video>
{filePath != null && compatPlayerEnabled && <MediaSourcePlayer rotate={effectiveRotation} filePath={filePath} videoStream={activeVideoStream} audioStreams={activeAudioStreams} masterVideoRef={videoRef} mediaSourceQuality={mediaSourceQuality} ffmpegHwaccel={ffmpegHwaccel} />}
</div>
<div style={{ cursor: 'pointer', pointerEvents: 'initial', color: 'var(--gray-12)', opacity: 0.7, padding: '.2em', marginLeft: '.5em' }} role="button" onClick={() => incrementMediaSourceQuality()} title={t('Select playback quality')}>{mediaSourceQualities[mediaSourceQuality]}</div>
{bigWaveformEnabled && <BigWaveform waveforms={waveforms} relevantTime={relevantTime} playing={playing} fileDurationNonZero={fileDurationNonZero} zoom={zoomUnrounded} seekRel={seekRel} darkMode={darkMode} />}
{compatPlayerEnabled && (
<div style={{ position: 'absolute', top: 0, right: 0, left: 0, marginTop: '1em', marginLeft: '1em', color: 'var(--gray-12)', opacity: 0.7, display: 'flex', alignItems: 'center', pointerEvents: 'none' }}>
{isRotationSet ? (
<>
<MdRotate90DegreesCcw size={26} style={{ marginRight: 5 }} />
{t('Rotation preview')}
</>
) : (
<>
{t('FFmpeg-assisted playback')}
</>
)}
<div style={{ cursor: 'pointer', pointerEvents: 'initial', color: 'var(--gray-12)', opacity: 0.7, padding: '.2em', marginLeft: '.5em' }} role="button" onClick={() => incrementMediaSourceQuality()} title={t('Select playback quality')}>{mediaSourceQualities[mediaSourceQuality]}</div>
{!compatPlayerRequired && <FaRegTimesCircle role="button" style={{ cursor: 'pointer', pointerEvents: 'initial', verticalAlign: 'middle', padding: '.2em' }} onClick={handleHideCompatPlayerClick} />}
</div>
)}
{!compatPlayerRequired && <FaRegTimesCircle role="button" style={{ cursor: 'pointer', pointerEvents: 'initial', verticalAlign: 'middle', padding: '.2em' }} onClick={handleHideCompatPlayerClick} />}
</div>
)}
{isFileOpened && (
<div className="no-user-select" style={{ position: 'absolute', right: 0, bottom: 0, marginBottom: 10, display: 'flex', alignItems: 'flex-end' }}>
<VolumeControl playbackVolume={playbackVolume} setPlaybackVolume={setPlaybackVolume} onToggleMutedClick={toggleMuted} />
{shouldShowPlaybackStreamSelector && (
<PlaybackStreamSelector subtitleStreams={subtitleStreams} videoStreams={videoStreams} audioStreams={audioStreams} activeSubtitleStreamIndex={activeSubtitleStreamIndex} activeVideoStreamIndex={activeVideoStreamIndex} activeAudioStreamIndexes={activeAudioStreamIndexes} onActiveSubtitleChange={onActiveSubtitleChange} onActiveVideoStreamChange={onActiveVideoStreamChange} onActiveAudioStreamsChange={onActiveAudioStreamsChange} />
)}
{!showRightBar && (
<IoMdMenu
title={t('Show sidebar')}
size={30}
role="button"
style={{ marginRight: 10, color: 'var(--gray-12)', opacity: 0.7 }}
onClick={toggleSegmentsList}
/>
)}
</div>
)}
</div>
{isFileOpened && (
<div className="no-user-select" style={{ position: 'absolute', right: 0, bottom: 0, marginBottom: 10, display: 'flex', alignItems: 'flex-end' }}>
<VolumeControl playbackVolume={playbackVolume} setPlaybackVolume={setPlaybackVolume} onToggleMutedClick={toggleMuted} />
<AnimatePresence>
{showRightBar && isFileOpened && filePath != null && (
<SegmentList
width={rightBarWidth}
currentSegIndex={currentSegIndexSafe}
cutSegments={cutSegments}
inverseCutSegments={inverseCutSegments}
getFrameCount={getFrameCount}
formatTimecode={formatTimecode}
onSegClick={setCurrentSegIndex}
updateSegOrder={updateSegOrder}
updateSegOrders={updateSegOrders}
onLabelSegment={labelSegment}
currentCutSeg={currentCutSeg}
firstSegmentAtCursor={firstSegmentAtCursor}
addSegment={addSegment}
onDuplicateSegmentClick={duplicateSegment}
removeSegment={removeSegment}
onRemoveSelected={removeSelectedSegments}
toggleSegmentsList={toggleSegmentsList}
splitCurrentSegment={splitCurrentSegment}
selectedSegments={segmentsOrInverse.selected}
onSelectSingleSegment={selectOnlySegment}
onToggleSegmentSelected={toggleSegmentSelected}
onDeselectAllSegments={deselectAllSegments}
onSelectAllSegments={selectAllSegments}
onInvertSelectedSegments={invertSelectedSegments}
onExtractSegmentsFramesAsImages={extractSegmentsFramesAsImages}
onExtractSelectedSegmentsFramesAsImages={extractSelectedSegmentsFramesAsImages}
jumpSegStart={jumpSegStart}
jumpSegEnd={jumpSegEnd}
onSelectSegmentsByLabel={selectSegmentsByLabel}
onSelectSegmentsByExpr={selectSegmentsByExpr}
onSelectAllMarkers={selectAllMarkers}
onMutateSegmentsByExpr={mutateSegmentsByExpr}
onLabelSelectedSegments={labelSelectedSegments}
updateSegAtIndex={updateSegAtIndex}
editingSegmentTags={editingSegmentTags}
editingSegmentTagsSegmentIndex={editingSegmentTagsSegmentIndex}
setEditingSegmentTags={setEditingSegmentTags}
setEditingSegmentTagsSegmentIndex={setEditingSegmentTagsSegmentIndex}
onEditSegmentTags={onEditSegmentTags}
getSegEstimatedSize={getSegEstimatedSize}
/>
)}
</AnimatePresence>
</div>
{shouldShowPlaybackStreamSelector && (
<PlaybackStreamSelector subtitleStreams={subtitleStreams} videoStreams={videoStreams} audioStreams={audioStreams} activeSubtitleStreamIndex={activeSubtitleStreamIndex} activeVideoStreamIndex={activeVideoStreamIndex} activeAudioStreamIndexes={activeAudioStreamIndexes} onActiveSubtitleChange={onActiveSubtitleChange} onActiveVideoStreamChange={onActiveVideoStreamChange} onActiveAudioStreamsChange={onActiveAudioStreamsChange} />
)}
<div style={bottomStyle}>
<Timeline
shouldShowKeyframes={shouldShowKeyframes}
waveforms={waveforms}
overviewWaveform={overviewWaveform}
shouldShowWaveform={shouldShowWaveform}
waveformEnabled={waveformEnabled}
waveformHeight={waveformHeight}
showThumbnails={showThumbnails}
neighbouringKeyFrames={neighbouringKeyFrames}
thumbnails={thumbnailsSorted}
playerTime={playerTime}
commandedTime={commandedTime}
relevantTime={relevantTime}
commandedTimeRef={commandedTimeRef}
startTimeOffset={startTimeOffset}
zoom={zoom}
seekAbs={seekAbs}
fileDurationNonZero={fileDurationNonZero}
cutSegments={cutSegments}
setCurrentSegIndex={setCurrentSegIndex}
currentSegIndexSafe={currentSegIndexSafe}
currentCutSeg={currentCutSeg}
inverseCutSegments={inverseCutSegments}
formatTimecode={formatTimecode}
zoomWindowStartTime={zoomWindowStartTime}
zoomWindowEndTime={zoomWindowEndTime}
onZoomWindowStartTimeChange={setZoomWindowStartTime}
onGenerateOverviewWaveformClick={generateOverviewWaveform}
playing={playing}
isFileOpened={isFileOpened}
onWheel={onTimelineWheel}
goToTimecode={goToTimecode}
darkMode={darkMode}
setCutTime={setCutTime}
setHoveringTime={setHoveringTime}
/>
<BottomBar
zoom={zoom}
setZoom={zoomAbs}
timelineToggleComfortZoom={timelineToggleComfortZoom}
hasVideo={hasVideo}
isRotationSet={isRotationSet}
rotation={rotation}
areWeCutting={areWeCutting}
increaseRotation={increaseRotation}
cleanupFilesDialog={cleanupFilesDialog}
captureSnapshot={captureSnapshot}
onExportPress={onExportPress}
segmentsToExport={segmentsToExport}
seekAbs={seekAbs}
currentSegIndexSafe={currentSegIndexSafe}
cutSegments={cutSegments}
currentCutSeg={currentCutSeg}
selectedSegments={selectedSegments}
setCutStart={setCutStart}
setCutEnd={setCutEnd}
setCurrentSegIndex={setCurrentSegIndex}
jumpCutEnd={jumpCutEnd}
jumpCutStart={jumpCutStart}
jumpTimelineStart={jumpTimelineStart}
jumpTimelineEnd={jumpTimelineEnd}
startTimeOffset={startTimeOffset}
setCutTime={setCutTime}
playing={playing}
shortStep={shortStep}
seekClosestKeyframe={seekClosestKeyframe}
togglePlay={togglePlay}
showThumbnails={showThumbnails}
toggleShowThumbnails={toggleShowThumbnails}
toggleWaveformMode={toggleWaveformMode}
waveformMode={waveformMode}
hasAudio={hasAudio}
keyframesEnabled={keyframesEnabled}
toggleShowKeyframes={toggleShowKeyframes}
detectedFps={detectedFps}
toggleLoopSelectedSegments={toggleLoopSelectedSegments}
isFileOpened={isFileOpened}
darkMode={darkMode}
outputPlaybackRate={outputPlaybackRate}
setOutputPlaybackRate={setOutputPlaybackRate}
formatTimecode={formatTimecode}
parseTimecode={parseTimecode}
playbackRate={playbackRate}
currentFrame={currentFrame}
playbackMode={playbackMode}
displayTime={displayTime}
fileDurationNonZero={fileDurationNonZero}
getFrameCount={getFrameCount}
/>
</div>
{!showRightBar && (
<IoMdMenu
title={t('Show sidebar')}
size={30}
role="button"
style={{ marginRight: 10, color: 'var(--gray-12)', opacity: 0.7 }}
onClick={toggleSegmentsList}
{tunerVisible != null && <ValueTuners type={tunerVisible} onFinished={() => setTunerVisible(undefined)} />}
{/* Dialogs */}
<ExportConfirm areWeCutting={areWeCutting} segmentsOrInverse={segmentsOrInverse} segmentsToExport={segmentsToExport} willMerge={willMerge} visible={exportConfirmOpen} onClosePress={closeExportConfirm} onExportConfirm={onExportConfirm} renderOutFmt={renderOutFmt} outputDir={outputDir} numStreamsTotal={numStreamsTotal} numStreamsToCopy={numStreamsToCopy} onShowStreamsSelectorClick={handleShowStreamsSelectorClick} outFormat={fileFormat} cutFileTemplate={cutFileTemplateOrDefault} cutMergedFileTemplate={cutMergedFileTemplateOrDefault} generateCutFileNames={generateCutFileNames} generateCutMergedFileNames={generateCutMergedFileNames} currentSegIndexSafe={currentSegIndexSafe} mainCopiedThumbnailStreams={mainCopiedThumbnailStreams} needSmartCut={needSmartCut} isEncoding={isEncoding} encBitrate={encBitrate} setEncBitrate={setEncBitrate} toggleSettings={toggleSettings} outputPlaybackRate={outputPlaybackRate} lossyMode={lossyMode} neighbouringKeyFrames={neighbouringKeyFrames} findNearestKeyFrameTime={findNearestKeyFrameTime} />
<Dialog.Root open={streamsSelectorShown} onOpenChange={setStreamsSelectorShown}>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content style={{ width: '80em' }}>
<Dialog.Title>{t('Tracks')}</Dialog.Title>
<Dialog.Description>{t('Click to select which tracks to keep when exporting:')}</Dialog.Description>
{mainStreams && filePath != null && (
<StreamsSelector
mainFilePath={filePath}
mainFileFormat={mainFileFormat}
mainFileChapters={mainFileChapters}
allFilesMeta={allFilesMeta}
externalFilesMeta={externalFilesMeta}
setExternalFilesMeta={setExternalFilesMeta}
showAddStreamSourceDialog={showIncludeExternalStreamsDialog}
mainFileStreams={mainStreams}
isCopyingStreamId={isCopyingStreamId}
toggleCopyStreamId={toggleCopyStreamId}
setCopyStreamIdsForPath={setCopyStreamIdsForPath}
onExtractAllStreamsPress={extractAllStreams}
onExtractStreamPress={extractSingleStream}
shortestFlag={shortestFlag}
setShortestFlag={setShortestFlag}
nonCopiedExtraStreams={nonCopiedExtraStreams}
customTagsByFile={customTagsByFile}
setCustomTagsByFile={setCustomTagsByFile}
paramsByStreamId={paramsByStreamId}
updateStreamParams={updateStreamParams}
formatTimecode={formatTimecode}
loadSubtitleTrackToSegments={loadSubtitleTrackToSegments}
toggleCopyStreamIds={toggleCopyStreamIds}
changeEnabledStreamsFilter={changeEnabledStreamsFilter}
toggleCopyAllStreamsForPath={toggleCopyAllStreamsForPath}
onStreamSourceFileDrop={handleStreamSourceFileDrop}
/>
)}
</div>
)}
</div>
<Dialog.CloseButton />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<LastCommands visible={lastCommandsVisible} onTogglePress={toggleLastCommands} ffmpegCommandLog={ffmpegCommandLog} setFfmpegCommandLog={setFfmpegCommandLog} />
<Dialog.Root open={settingsVisible} onOpenChange={toggleSettings}>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content style={{ width: '60em' }}>
<Dialog.Title>{t('Settings')}</Dialog.Title>
<Dialog.Description>{t('Hover mouse over buttons in the main interface to see which function they have')}</Dialog.Description>
<Settings
onTunerRequested={onTunerRequested}
onKeyboardShortcutsDialogRequested={toggleKeyboardShortcuts}
askForCleanupChoices={askForCleanupChoices}
toggleStoreProjectInWorkingDir={toggleStoreProjectInWorkingDir}
showAdvancedSettings={showAdvancedSettings}
setShowAdvancedSettings={setShowAdvancedSettings}
/>
<Dialog.CloseButton />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<ConcatDialog isShown={batchFiles.length > 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} />
<KeyboardShortcuts isShown={keyboardShortcutsVisible} onHide={() => setKeyboardShortcutsVisible(false)} keyBindings={keyBindings} setKeyBindings={setKeyBindings} currentCutSeg={currentCutSeg} resetKeyBindings={resetKeyBindings} getKeyboardAction={getKeyboardAction} />
{/* This should probably be last, so that it's always on top */}
<AnimatePresence>
{showRightBar && isFileOpened && filePath != null && (
<SegmentList
width={rightBarWidth}
currentSegIndex={currentSegIndexSafe}
cutSegments={cutSegments}
inverseCutSegments={inverseCutSegments}
getFrameCount={getFrameCount}
formatTimecode={formatTimecode}
onSegClick={setCurrentSegIndex}
updateSegOrder={updateSegOrder}
updateSegOrders={updateSegOrders}
onLabelSegment={labelSegment}
currentCutSeg={currentCutSeg}
firstSegmentAtCursor={firstSegmentAtCursor}
addSegment={addSegment}
onDuplicateSegmentClick={duplicateSegment}
removeSegment={removeSegment}
onRemoveSelected={removeSelectedSegments}
toggleSegmentsList={toggleSegmentsList}
splitCurrentSegment={splitCurrentSegment}
selectedSegments={segmentsOrInverse.selected}
onSelectSingleSegment={selectOnlySegment}
onToggleSegmentSelected={toggleSegmentSelected}
onDeselectAllSegments={deselectAllSegments}
onSelectAllSegments={selectAllSegments}
onInvertSelectedSegments={invertSelectedSegments}
onExtractSegmentsFramesAsImages={extractSegmentsFramesAsImages}
onExtractSelectedSegmentsFramesAsImages={extractSelectedSegmentsFramesAsImages}
jumpSegStart={jumpSegStart}
jumpSegEnd={jumpSegEnd}
onSelectSegmentsByLabel={selectSegmentsByLabel}
onSelectSegmentsByExpr={selectSegmentsByExpr}
onSelectAllMarkers={selectAllMarkers}
onMutateSegmentsByExpr={mutateSegmentsByExpr}
onLabelSelectedSegments={labelSelectedSegments}
updateSegAtIndex={updateSegAtIndex}
editingSegmentTags={editingSegmentTags}
editingSegmentTagsSegmentIndex={editingSegmentTagsSegmentIndex}
setEditingSegmentTags={setEditingSegmentTags}
setEditingSegmentTagsSegmentIndex={setEditingSegmentTagsSegmentIndex}
onEditSegmentTags={onEditSegmentTags}
getSegEstimatedSize={getSegEstimatedSize}
/>
)}
{working && <Working text={working.text} progress={progress} onAbortClick={abortWorking} />}
</AnimatePresence>
</div>
<div className="no-user-select" style={bottomStyle}>
<Timeline
shouldShowKeyframes={shouldShowKeyframes}
waveforms={waveforms}
overviewWaveform={overviewWaveform}
shouldShowWaveform={shouldShowWaveform}
waveformEnabled={waveformEnabled}
waveformHeight={waveformHeight}
showThumbnails={showThumbnails}
neighbouringKeyFrames={neighbouringKeyFrames}
thumbnails={thumbnailsSorted}
playerTime={playerTime}
commandedTime={commandedTime}
relevantTime={relevantTime}
commandedTimeRef={commandedTimeRef}
startTimeOffset={startTimeOffset}
zoom={zoom}
seekAbs={seekAbs}
fileDurationNonZero={fileDurationNonZero}
cutSegments={cutSegments}
setCurrentSegIndex={setCurrentSegIndex}
currentSegIndexSafe={currentSegIndexSafe}
currentCutSeg={currentCutSeg}
inverseCutSegments={inverseCutSegments}
formatTimecode={formatTimecode}
formatTimeAndFrames={formatTimeAndFrames}
zoomWindowStartTime={zoomWindowStartTime}
zoomWindowEndTime={zoomWindowEndTime}
onZoomWindowStartTimeChange={setZoomWindowStartTime}
onGenerateOverviewWaveformClick={generateOverviewWaveform}
playing={playing}
isFileOpened={isFileOpened}
onWheel={onTimelineWheel}
goToTimecode={goToTimecode}
darkMode={darkMode}
setCutTime={setCutTime}
/>
<GenericDialog dialog={genericDialog} onOpenChange={(open) => !open && closeGenericDialog()} />
<BottomBar
zoom={zoom}
setZoom={zoomAbs}
timelineToggleComfortZoom={timelineToggleComfortZoom}
hasVideo={hasVideo}
isRotationSet={isRotationSet}
rotation={rotation}
areWeCutting={areWeCutting}
increaseRotation={increaseRotation}
cleanupFilesDialog={cleanupFilesDialog}
captureSnapshot={captureSnapshot}
onExportPress={onExportPress}
segmentsToExport={segmentsToExport}
seekAbs={seekAbs}
currentSegIndexSafe={currentSegIndexSafe}
cutSegments={cutSegments}
currentCutSeg={currentCutSeg}
selectedSegments={selectedSegments}
setCutStart={setCutStart}
setCutEnd={setCutEnd}
setCurrentSegIndex={setCurrentSegIndex}
jumpCutEnd={jumpCutEnd}
jumpCutStart={jumpCutStart}
jumpTimelineStart={jumpTimelineStart}
jumpTimelineEnd={jumpTimelineEnd}
startTimeOffset={startTimeOffset}
setCutTime={setCutTime}
playing={playing}
shortStep={shortStep}
seekClosestKeyframe={seekClosestKeyframe}
togglePlay={togglePlay}
showThumbnails={showThumbnails}
toggleShowThumbnails={toggleShowThumbnails}
toggleWaveformMode={toggleWaveformMode}
waveformMode={waveformMode}
hasAudio={hasAudio}
keyframesEnabled={keyframesEnabled}
toggleShowKeyframes={toggleShowKeyframes}
detectedFps={detectedFps}
toggleLoopSelectedSegments={toggleLoopSelectedSegments}
isFileOpened={isFileOpened}
darkMode={darkMode}
outputPlaybackRate={outputPlaybackRate}
setOutputPlaybackRate={setOutputPlaybackRate}
formatTimecode={formatTimecode}
parseTimecode={parseTimecode}
playbackRate={playbackRate}
currentFrame={currentFrame}
playbackMode={playbackMode}
/>
</div>
<WhatsNew />
{tunerVisible != null && <ValueTuners type={tunerVisible} onFinished={() => setTunerVisible(undefined)} />}
{/* Dialogs */}
<ExportConfirm areWeCutting={areWeCutting} segmentsOrInverse={segmentsOrInverse} segmentsToExport={segmentsToExport} willMerge={willMerge} visible={exportConfirmOpen} onClosePress={closeExportConfirm} onExportConfirm={onExportConfirm} renderOutFmt={renderOutFmt} outputDir={outputDir} numStreamsTotal={numStreamsTotal} numStreamsToCopy={numStreamsToCopy} onShowStreamsSelectorClick={handleShowStreamsSelectorClick} outFormat={fileFormat} cutFileTemplate={cutFileTemplateOrDefault} cutMergedFileTemplate={cutMergedFileTemplateOrDefault} generateCutFileNames={generateCutFileNames} generateCutMergedFileNames={generateCutMergedFileNames} currentSegIndexSafe={currentSegIndexSafe} mainCopiedThumbnailStreams={mainCopiedThumbnailStreams} needSmartCut={needSmartCut} isEncoding={isEncoding} encBitrate={encBitrate} setEncBitrate={setEncBitrate} toggleSettings={toggleSettings} outputPlaybackRate={outputPlaybackRate} lossyMode={lossyMode} neighbouringKeyFrames={neighbouringKeyFrames} findNearestKeyFrameTime={findNearestKeyFrameTime} />
<Dialog.Root open={streamsSelectorShown} onOpenChange={setStreamsSelectorShown}>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content style={{ width: '80em' }}>
<Dialog.Title>{t('Tracks')}</Dialog.Title>
<Dialog.Description>{t('Click to select which tracks to keep when exporting:')}</Dialog.Description>
{mainStreams && filePath != null && (
<StreamsSelector
mainFilePath={filePath}
mainFileFormat={mainFileFormat}
mainFileChapters={mainFileChapters}
allFilesMeta={allFilesMeta}
externalFilesMeta={externalFilesMeta}
setExternalFilesMeta={setExternalFilesMeta}
showAddStreamSourceDialog={showIncludeExternalStreamsDialog}
mainFileStreams={mainStreams}
isCopyingStreamId={isCopyingStreamId}
toggleCopyStreamId={toggleCopyStreamId}
setCopyStreamIdsForPath={setCopyStreamIdsForPath}
onExtractAllStreamsPress={extractAllStreams}
onExtractStreamPress={extractSingleStream}
shortestFlag={shortestFlag}
setShortestFlag={setShortestFlag}
nonCopiedExtraStreams={nonCopiedExtraStreams}
customTagsByFile={customTagsByFile}
setCustomTagsByFile={setCustomTagsByFile}
paramsByStreamId={paramsByStreamId}
updateStreamParams={updateStreamParams}
formatTimecode={formatTimecode}
loadSubtitleTrackToSegments={loadSubtitleTrackToSegments}
toggleCopyStreamIds={toggleCopyStreamIds}
changeEnabledStreamsFilter={changeEnabledStreamsFilter}
toggleCopyAllStreamsForPath={toggleCopyAllStreamsForPath}
onStreamSourceFileDrop={handleStreamSourceFileDrop}
/>
)}
<ErrorDialog error={genericError} onOpenChange={(open) => !open && setGenericError(undefined)} />
</div>
<Dialog.CloseButton />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<LastCommands visible={lastCommandsVisible} onTogglePress={toggleLastCommands} ffmpegCommandLog={ffmpegCommandLog} setFfmpegCommandLog={setFfmpegCommandLog} />
<Dialog.Root open={settingsVisible} onOpenChange={toggleSettings}>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content style={{ width: '60em' }}>
<Dialog.Title>{t('Settings')}</Dialog.Title>
<Dialog.Description>{t('Hover mouse over buttons in the main interface to see which function they have')}</Dialog.Description>
<Settings
onTunerRequested={onTunerRequested}
onKeyboardShortcutsDialogRequested={toggleKeyboardShortcuts}
askForCleanupChoices={askForCleanupChoices}
toggleStoreProjectInWorkingDir={toggleStoreProjectInWorkingDir}
showAdvancedSettings={showAdvancedSettings}
setShowAdvancedSettings={setShowAdvancedSettings}
/>
<Dialog.CloseButton />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<ConcatDialog isShown={batchFiles.length > 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} />
<KeyboardShortcuts isShown={keyboardShortcutsVisible} onHide={() => setKeyboardShortcutsVisible(false)} keyBindings={keyBindings} setKeyBindings={setKeyBindings} currentCutSeg={currentCutSeg} resetKeyBindings={resetKeyBindings} getKeyboardAction={getKeyboardAction} />
{/* This should probably be last, so that it's always on top */}
<AnimatePresence>
{working && <Working text={working.text} progress={progress} onAbortClick={abortWorking} />}
</AnimatePresence>
<GenericDialog dialog={genericDialog} onOpenChange={(open) => !open && closeGenericDialog()} />
<WhatsNew />
<ErrorDialog error={genericError} onOpenChange={(open) => !open && setGenericError(undefined)} />
</div>
<SwalContainer darkMode={darkMode} style={baseColorStyle} />
</UserSettingsContext.Provider>
</SegColorsContext.Provider>
</AppContext.Provider>
</MotionConfig>
<SwalContainer darkMode={darkMode} style={baseColorStyle} />
</UserSettingsContext.Provider>
</SegColorsContext.Provider>
</AppContext.Provider>
</MotionConfig>
</Theme>
);
}

@ -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<SetStateAction<boolean>> }) => {
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<CSSProperties>(() => ({
paddingLeft: playing ? 0 : '.1em',
color: 'white',
@ -372,7 +388,7 @@ function BottomBar({
const playbackRateRef = useRef<HTMLDivElement>(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 (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', opacity: isFileOpened ? 1 : 0.5 }}>
<div className="no-user-select" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', opacity: isFileOpened ? 1 : 0.5 }}>
<div style={{ display: 'flex', alignItems: 'center', flexBasis: leftRightWidth }}>
{!simpleMode && (
<>
@ -530,10 +548,7 @@ function BottomBar({
<div style={{ flexBasis: leftRightWidth }} />
</div>
<div
className="no-user-select"
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '.1em .3em', gap: '.5em', height: '2em' }}
>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '.1em .3em', gap: '.5em', height: '2em' }}>
<InvertCutModeButton invertCutSegments={invertCutSegments} setInvertCutSegments={setInvertCutSegments} />
<div style={{ display: 'flex', alignItems: 'center' }}>
@ -555,9 +570,9 @@ function BottomBar({
))}
</Select>
<div ref={playbackRateRef} title={t('Playback rate')} style={{ color: 'var(--gray-11)', fontSize: '.7em' }}>{playbackRate.toFixed(1)}</div>
<div ref={playbackRateRef} title={t('Playback rate')} style={{ color: 'var(--gray-11)', fontSize: '.7em', borderRadius: '.5em' }}>{playbackRate.toFixed(1)}</div>
<div>
<div style={{ whiteSpace: 'nowrap' }}>
<IoMdSpeedometer title={t('Change FPS')} style={{ fontSize: '1.3em', verticalAlign: 'middle' }} role="button" onClick={handleChangePlaybackRateClick} />
{detectedFps != null && (
@ -568,7 +583,7 @@ function BottomBar({
)}
{isFileOpened && !simpleMode && hasVideo && (
<div onClick={increaseRotation} role="button">
<div onClick={increaseRotation} role="button" style={{ whiteSpace: 'nowrap' }}>
<MdRotate90DegreesCcw
style={{ fontSize: '1.3em', verticalAlign: 'middle', color: isRotationSet ? primaryTextColor : undefined }}
title={`${t('Set output rotation. Current: ')} ${isRotationSet ? rotationStr : t('Don\'t modify')}`}
@ -579,6 +594,16 @@ function BottomBar({
<div style={{ flexGrow: 1 }} />
<div style={timeWrapperStyle}>
<div style={{ fontFamily: 'monospace', letterSpacing: '-0.08em', pointerEvents: 'auto' }}>
{formatTimecode({ seconds: displayTime })}
<span style={{ display: 'inline-block', minWidth: '3.5em', marginLeft: '.5em' }}>
{displayTimeFrameCount ?? 0}<span style={{ opacity: 0.5, userSelect: 'none' }}>f</span>
{isZoomed && <span style={{ marginLeft: '.5em' }}>{Math.round((displayTime / fileDurationNonZero) * 100)}<span style={{ opacity: 0.5, userSelect: 'none' }}>%</span></span>}
</span>
</div>
</div>
{!simpleMode && isFileOpened && (
<FaTrashAlt
title={t('Close file and clean up')}
@ -589,7 +614,7 @@ function BottomBar({
)}
{hasVideo && (
<div>
<div style={{ whiteSpace: 'nowrap' }}>
<IoIosCamera
role="button"
style={{ fontSize: '1.9em', verticalAlign: 'middle' }}

@ -88,7 +88,7 @@ function NoFileLoaded({ mifiLink, currentCutSeg, onClick, darkMode, keyBindingBy
<div style={{ position: 'relative', margin: '.3em', width: '24em', height: '8em' }}>
<iframe src={`${mifiLink.loadUrl}#dark=${darkMode ? 'true' : 'false'}`} title="iframe" style={{ background: 'rgba(0,0,0,0)', border: 'none', pointerEvents: 'none', width: '100%', height: '100%', position: 'absolute', colorScheme: 'initial' }} />
{/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */}
<div style={{ width: '100%', height: '100%', position: 'absolute', cursor: 'pointer' }} role="button" onClick={(e) => { e.stopPropagation(); if ('targetUrl' in mifiLink && typeof mifiLink.targetUrl === 'string') electron.shell.openExternal(mifiLink.targetUrl); }} />
<div style={{ width: '100%', height: '100%', position: 'absolute', cursor: 'pointer' }} title={t('Open link in browser')} role="button" onClick={(e) => { e.stopPropagation(); if ('targetUrl' in mifiLink && typeof mifiLink.targetUrl === 'string') electron.shell.openExternal(mifiLink.targetUrl); }} />
</div>
) : undefined}
</motion.div>

@ -394,6 +394,8 @@ function SegmentList({
const sortableList = useMemo(() => segmentsOrInverse.map((seg) => ({ id: seg.segId, seg })), [segmentsOrInverse]);
const isOnlyMarkers = useMemo(() => segmentsOrInverse.length > 0 && segmentsOrInverse.every((seg) => seg.end == null), [segmentsOrInverse]);
function getHeader() {
if (segmentsOrInverse.length === 0) {
if (invertCutSegments) {
@ -404,7 +406,7 @@ function SegmentList({
return t('No segments to export.');
}
if (segmentsOrInverse.every((s) => s.end == null)) {
if (isOnlyMarkers) {
return t('Markers:');
}
return t('Segments to export:');
@ -681,6 +683,12 @@ function SegmentList({
</DragOverlay>
</DndContext>
{isOnlyMarkers && (
<div style={{ padding: '1em .7em', color: 'var(--gray-11)', fontSize: '.85em' }}>
{t('Markers are segments without an end time and will not be exported. Convert markers to segments by setting their end time.')}
</div>
)}
{renderFooter()}
</motion.div>
</>

@ -13,11 +13,10 @@ import AutoExportToggler from './components/AutoExportToggler';
import Select from './components/Select';
import type { FileStream } from './ffmpeg';
import { getStreamFps } from './ffmpeg';
import { deleteDispositionValue } from './util';
import { getActiveDisposition, attachedPicDisposition, isGpsStream } from './util/streams';
import TagEditor from './components/TagEditor';
import type { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../common/ffprobe';
import type { CustomTagsByFile, FilesMeta, FormatTimecode, ParamsByStreamId, StreamParams } from './types';
import { contentDispositionOptionsSchema, deleteDispositionValue, dispositionOptions, type ContentDispositionOptions, type CustomTagsByFile, type FilesMeta, type FormatTimecode, type ParamsByStreamId, type StreamParams } from './types';
import Button, { DialogButton } from './components/Button';
import styles from './StreamsSelector.module.css';
import Json5Dialog from './components/Json5Dialog';
@ -26,9 +25,6 @@ import TextInput from './components/TextInput';
import Switch from './components/Switch';
const dispositionOptions = ['default', 'dub', 'original', 'comment', 'lyrics', 'karaoke', 'forced', 'hearing_impaired', 'visual_impaired', 'clean_effects', 'attached_pic', 'captions', 'descriptions', 'dependent', 'metadata'];
const unchangedDispositionValue = 'llc_disposition_unchanged';
type UpdateStreamParams = (fileId: string, streamId: number, setter: (a: StreamParams) => void) => void;
interface EditingStream {
@ -260,7 +256,7 @@ const EditStreamDialog = memo(({ editingStream: { streamId: editingStreamId, pat
if (params.customTags == null) params.customTags = {};
const tags = params.customTags;
Object.entries(keyValues).forEach(([tag, value]) => {
tags[tag] = value;
tags[tag] = tag === 'language' ? value.toLowerCase() : value;
});
});
}, [editingFile, editingStreamId, updateStreamParams]);
@ -274,6 +270,13 @@ const EditStreamDialog = memo(({ editingStream: { streamId: editingStreamId, pat
});
}, [editingFile, editingStreamId, updateStreamParams]);
const tagInfo = useMemo(() => ({
language: {
description: t('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.'),
url: 'https://en.wikipedia.org/wiki/List_of_ISO_639-2_codes',
},
}), [t]);
return (
<Dialog.Root open={editingStream != null} onOpenChange={(v) => v === false && setEditingStream(undefined)}>
<Dialog.Portal>
@ -286,7 +289,7 @@ const EditStreamDialog = memo(({ editingStream: { streamId: editingStreamId, pat
<h2>Tags</h2>
<TagEditor existingTags={existingTags} customTags={customTags} editingTag={editingTag} setEditingTag={setEditingTag} onTagsChange={onTagsChange} onTagReset={onTagReset} addTagTitle={t('Add metadata')} />
<TagEditor existingTags={existingTags} customTags={customTags} editingTag={editingTag} setEditingTag={setEditingTag} onTagsChange={onTagsChange} onTagReset={onTagReset} addTagTitle={t('Add metadata')} tagInfo={tagInfo} />
<Dialog.ButtonRow>
<Dialog.Close asChild>
@ -301,6 +304,8 @@ const EditStreamDialog = memo(({ editingStream: { streamId: editingStreamId, pat
);
});
const unchangedDispositionValue = 'llc_disposition_unchanged';
// eslint-disable-next-line react/display-name
const Stream = memo(({ filePath, stream, onToggle, toggleCopyStreamIds, copyStream, fileDuration, setEditingStream, onExtractStreamPress, paramsByStreamId, updateStreamParams, formatTimecode, loadSubtitleTrackToSegments }: {
filePath: string,
@ -355,9 +360,10 @@ const Stream = memo(({ filePath, stream, onToggle, toggleCopyStreamIds, copyStre
const onClick = () => onToggle && onToggle(stream.index);
const onDispositionChange = useCallback<ChangeEventHandler<HTMLSelectElement>>((e) => {
let newDisposition: string;
if (dispositionOptions.includes(e.target.value)) {
newDisposition = e.target.value;
let newDisposition: ContentDispositionOptions | 'llc_disposition_remove' | undefined;
const dispositionParsed = contentDispositionOptionsSchema.safeParse(e.target.value);
if (dispositionParsed.success) {
newDisposition = dispositionParsed.data;
} else if (e.target.value === deleteDispositionValue) {
newDisposition = deleteDispositionValue; // needs a separate value (not a real disposition)
} // else unchanged (undefined)

@ -1,21 +1,3 @@
.time-wrapper {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.time {
background: rgba(0,0,0,0.4);
border-radius: 3px;
padding: 2px 4px;
color: rgba(255, 255, 255, 0.8);
transition: opacity 0.2s;
}
@keyframes background-animation {
0% {background-position: 0% 50%}
100% {background-position: 100% 50%}

@ -1,5 +1,5 @@
import type { MutableRefObject, CSSProperties, WheelEventHandler, MouseEventHandler } from 'react';
import { memo, useRef, useMemo, useCallback, useEffect, useState } from 'react';
import { memo, useRef, useMemo, useCallback, useEffect } from 'react';
import { motion, useMotionValue, useSpring } from 'motion/react';
import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next';
@ -84,11 +84,8 @@ const CommandedTime = memo(({ commandedTimePercent }: { commandedTimePercent: st
const timelineHeight = 36;
const timeWrapperStyle: CSSProperties = { height: timelineHeight };
function Timeline({
fileDurationNonZero,
startTimeOffset,
playerTime,
commandedTime,
relevantTime,
@ -101,7 +98,6 @@ function Timeline({
currentCutSeg,
inverseCutSegments,
formatTimecode,
formatTimeAndFrames,
waveforms,
overviewWaveform,
shouldShowWaveform,
@ -114,13 +110,12 @@ function Timeline({
waveformEnabled,
waveformHeight,
showThumbnails,
playing,
isFileOpened,
onWheel,
commandedTimeRef,
goToTimecode,
darkMode,
setCutTime,
setHoveringTime,
} : {
fileDurationNonZero: number,
startTimeOffset: number,
@ -136,7 +131,6 @@ function Timeline({
currentCutSeg: StateSegment | undefined,
inverseCutSegments: InverseCutSegment[],
formatTimecode: FormatTimecode,
formatTimeAndFrames: (a: number) => string,
waveforms: WaveformSlice[],
overviewWaveform: OverviewWaveform | undefined,
shouldShowWaveform: boolean,
@ -155,7 +149,8 @@ function Timeline({
commandedTimeRef: MutableRefObject<number>,
goToTimecode: () => void,
darkMode: boolean,
setCutTime: UseSegments['setCutTime'];
setCutTime: UseSegments['setCutTime'],
setHoveringTime: (time: number | undefined) => void,
}) {
const { t } = useTranslation();
@ -166,11 +161,6 @@ function Timeline({
const timelineScrollerSkipEventDebounce = useRef<() => void>();
const timelineWrapperRef = useRef<HTMLDivElement>(null);
const [hoveringTime, setHoveringTime] = useState<number>();
const displayTime = (hoveringTime != null && isFileOpened && !playing ? hoveringTime : relevantTime) + startTimeOffset;
const displayTimePercent = useMemo(() => `${Math.round((displayTime / fileDurationNonZero) * 100)}%`, [displayTime, fileDurationNonZero]);
const isZoomed = zoom > 1;
const keyFramesInZoomWindow = useMemo(() => (zoomWindowEndTime == null ? [] : neighbouringKeyFrames.filter((f) => f.time >= zoomWindowStartTime && f.time <= zoomWindowEndTime)), [neighbouringKeyFrames, zoomWindowEndTime, zoomWindowStartTime]);
@ -292,7 +282,7 @@ function Timeline({
useEffect(() => {
setHoveringTime(undefined);
}, [relevantTime]);
}, [relevantTime, setHoveringTime]);
// for performance
const currentCutSegRef = useRef<StateSegment | undefined>(currentCutSeg);
@ -358,29 +348,7 @@ function Timeline({
window.addEventListener('mousemove', onMouseMove);
}, [fileDurationNonZero, getMouseTimelinePos, seekAbs, segmentMouseModifierKey, setCutTime, zoom]);
const timeRef = useRef<HTMLDivElement>(null);
const timeFadeTimeoutRef = useRef<NodeJS.Timeout>();
const onMouseMove = useCallback<MouseEventHandler<HTMLDivElement>>((e) => {
// need to manually check, because we cannot use css :hover when pointer-events: none
// and we need pointer-events: none on time because we want to be able to click through it to segments behind (and they are not parent)
const rect = timeRef.current?.getBoundingClientRect();
const isInBounds = rect && e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
const showHide = (show: boolean) => timeRef.current?.style.setProperty('opacity', show ? '0.2' : '1');
if (isInBounds != null) showHide(isInBounds);
// console.log('isInBounds', isInBounds);
// https://github.com/mifi/lossless-cut/issues/2592#issuecomment-3476211496
if (timeFadeTimeoutRef.current) clearTimeout(timeFadeTimeoutRef.current);
timeFadeTimeoutRef.current = setTimeout(() => showHide(false), 1000);
if (!mouseDownRef.current) { // no button pressed
setHoveringTime(getMouseTimelinePos(e.nativeEvent));
}
e.preventDefault();
}, [getMouseTimelinePos]);
const onMouseOut = useCallback(() => setHoveringTime(undefined), []);
const onMouseOut = useCallback(() => setHoveringTime(undefined), [setHoveringTime]);
const contextMenuTemplate = useMemo(() => [
{ label: t('Seek to timecode'), click: goToTimecode },
@ -396,9 +364,9 @@ function Timeline({
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions,jsx-a11y/mouse-events-have-key-events
<div
className="no-user-select"
style={{ position: 'relative', borderTop: '1px solid var(--gray-7)', borderBottom: '1px solid var(--gray-7)' }}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
// eslint-disable-next-line jsx-a11y/mouse-events-have-key-events
onMouseOut={onMouseOut}
>
@ -486,12 +454,6 @@ function Timeline({
)}
</div>
</div>
<div style={timeWrapperStyle} className={styles['time-wrapper']}>
<div className={styles['time']} ref={timeRef}>
{formatTimeAndFrames(displayTime)}{isZoomed ? ` ${displayTimePercent}` : ''}
</div>
</div>
</div>
);
}

@ -1,7 +1,53 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`combineOverlappingSegments 1`] = `
exports[`combineOverlappingSegments > adjacent 1`] = `
[
{
"end": 2,
"start": 1,
},
{
"end": 3,
"start": 2,
},
{
"end": 4,
"start": 3,
},
]
`;
exports[`combineOverlappingSegments > non touching 1`] = `
[
{
"end": 2,
"start": 1,
},
{
"end": 4,
"start": 3,
},
]
`;
exports[`combineOverlappingSegments > overlapping and non overlapping 1`] = `
[
{
"end": 3,
"start": 1,
},
{
"end": 5,
"start": 4,
},
]
`;
exports[`combineOverlappingSegments > various 1`] = `
[
{
"start": 0,
},
{
"end": 87,
"start": 0,
@ -11,9 +57,16 @@ exports[`combineOverlappingSegments 1`] = `
"start": 229,
},
{
"end": 1487,
"end": 1101,
"start": 838,
},
{
"end": 1487,
"start": 1101,
},
{
"start": 1487,
},
{
"end": 1831,
"start": 1561,

@ -2,9 +2,10 @@ import type { CSSProperties, Dispatch, SetStateAction } from 'react';
import { memo, useState, useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { AiOutlineMergeCells } from 'react-icons/ai';
import { FaQuestionCircle, FaExclamationTriangle, FaCog, FaCheck, FaNotEqual } from 'react-icons/fa';
import { FaQuestionCircle, FaExclamationTriangle, FaCog, FaCheck } from 'react-icons/fa';
import invariant from 'tiny-invariant';
import pMap from 'p-map';
import { Table } from '@radix-ui/themes';
import Checkbox from './Checkbox';
import type { FileFfprobeMeta } from '../ffmpeg';
@ -23,6 +24,7 @@ import FileNameTemplateEditor from './FileNameTemplateEditor';
import HighlightedText from './HighlightedText';
import type { FileStats } from '../types';
import OutDirSelector from './OutDirSelector';
import { parseRatio } from '../../../common/util';
const { basename } = window.require('path');
@ -37,8 +39,17 @@ function Alert({ text }: { text: string }) {
);
}
type Problem = { index: number, type: 'extraneous' }
| { index: number, type: 'parameter_mismatch', key: string, values: [string | number | undefined, string | number | undefined] };
type ProblemValue = string | number | undefined;
type Problem = {
index: number,
} & ({
type: 'extraneous',
} | {
type: 'parameter_mismatch',
key: string,
values: [ProblemValue, ProblemValue],
});
function ConcatDialog({ isShown, onHide, paths, mergedFileTemplate, generateMergedFileNames, onConcat, alwaysConcatMultipleFiles, setAlwaysConcatMultipleFiles, fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, onOutputFormatUserChange }: {
isShown: boolean,
@ -128,10 +139,20 @@ function ConcatDialog({ isShown, onHide, paths, mergedFileTemplate, generateMerg
}
// check all these parameters
(['codec_name', 'width', 'height', 'pix_fmt', 'level', 'profile', 'sample_fmt', 'avg_frame_rate', 'r_frame_rate', 'time_base'] as const).forEach((key) => {
const val = stream[key];
const referenceVal = referenceStream[key];
if (val !== referenceVal) {
addProblem(path, { type: 'parameter_mismatch', index: stream.index, key, values: [String(val), referenceVal] });
// special handling: https://github.com/mifi/lossless-cut/discussions/2740
if (key === 'avg_frame_rate') {
const val = parseRatio(stream[key]);
const referenceVal = parseRatio(referenceStream[key]);
const sigma = 0.01;
if ((val == null && referenceVal != null) || (val != null && referenceVal == null) || (val != null && referenceVal != null && Math.abs(val - referenceVal) >= sigma)) {
addProblem(path, { type: 'parameter_mismatch', index: stream.index, key, values: [String(val), referenceVal] });
}
} else {
const val = stream[key];
const referenceVal = referenceStream[key];
if (val !== referenceVal) {
addProblem(path, { type: 'parameter_mismatch', index: stream.index, key, values: [String(val), referenceVal] });
}
}
});
});
@ -236,24 +257,44 @@ function ConcatDialog({ isShown, onHide, paths, mergedFileTemplate, generateMerg
<Dialog.Content aria-describedby={undefined}>
<Dialog.Title>{t('Mismatches detected')}</Dialog.Title>
<ul style={{ margin: '10px 0', textAlign: 'left' }}>
{(problemsByFile[path] ?? []).map((problem) => (
<li key={JSON.stringify(problem)}>
<span style={{ marginRight: '.5em', color: 'var(--gray-11)' }}>{t('Track {{num}}', { num: problem.index + 1 })}:</span>
{problem.type === 'extraneous' && t('Extraneous')}
{problem.type === 'parameter_mismatch' && (
<>
<span style={{ marginRight: '1em' }}>{problem.key}</span>
<span style={{ fontWeight: 'bold' }}>{problem.values[0] ?? t('N/A')}</span>
<FaNotEqual style={{ fontSize: '.7em', marginLeft: '.7em', marginRight: '.7em', color: dangerColor, fontWeight: 'bold' }} />
<span style={{ fontWeight: 'bold', color: dangerColor }}>{problem.values[1] ?? t('N/A')}</span>
</>
)}
</li>
))}
</ul>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell>{t('Track')}</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>{t('Parameter')}</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>{t('Expected')}</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>{t('Actual')}</Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{(problemsByFile[path] ?? []).map((problem) => (
<Table.Row key={JSON.stringify(problem)}>
<Table.Cell style={{ marginRight: '.5em', color: 'var(--gray-11)' }}>
{problem.index + 1}
</Table.Cell>
{problem.type === 'extraneous' && (
<>
<Table.Cell />
<Table.Cell />
<Table.Cell style={{ color: dangerColor, fontWeight: 'bold' }}>{t('Extraneous')}</Table.Cell>
</>
)}
{problem.type === 'parameter_mismatch' && (
<>
<Table.Cell style={{ marginRight: '1em' }}>{problem.key}</Table.Cell>
<Table.Cell style={{ fontWeight: 'bold' }}>{problem.values[0] ?? t('N/A')}</Table.Cell>
<Table.Cell>
<span style={{ fontWeight: 'bold', color: dangerColor }}>{problem.values[1] ?? t('N/A')}</span>
</Table.Cell>
</>
)}
</Table.Row>
))}
</Table.Body>
</Table.Root>
<Dialog.CloseButton />
</Dialog.Content>

@ -170,11 +170,11 @@ function ExportConfirm({
const areWeCuttingProblematicStreams = areWeCutting && mainCopiedThumbnailStreams.length > 0;
const haveSegmentWithProblematicKeyframe = useMemo(() => {
if (neighbouringKeyFrames.length === 0) return false;
if (neighbouringKeyFrames.length === 0) return false; // we don't know
return segmentsToExport.some(({ start, end }) => {
const previousKeyframeTime = findNearestKeyFrameTime({ time: start, direction: -1 }) ?? 0;
const nearestPreviousKeyframeTime = findNearestKeyFrameTime({ time: start, direction: -1 }) ?? 0;
const segmentDuration = end - start;
const estimatedExportedSegmentDuration = end - previousKeyframeTime;
const estimatedExportedSegmentDuration = end - nearestPreviousKeyframeTime;
// if estimated actual output length of segment is more than 1.5 times the intended segment duration, then we consider it problematic and warn the user about it.
return estimatedExportedSegmentDuration > segmentDuration * 1.5;
});

@ -1,8 +1,9 @@
import type { MouseEventHandler, ReactNode } from 'react';
import type { FormEventHandler, MouseEventHandler, ReactNode } from 'react';
import React, { useCallback, useContext, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import invariant from 'tiny-invariant';
import { FaCheckCircle, FaInfoCircle } from 'react-icons/fa';
import { TextField } from '@radix-ui/themes';
import * as Dialog from './Dialog';
import * as AlertDialog from './AlertDialog';
@ -298,6 +299,83 @@ export function useDialog() {
});
}
const openShiftSegmentsDialog = useCallback(({ inputPlaceholder, parseTimecode }: {
inputPlaceholder: string,
parseTimecode: (s: string) => number | undefined,
}) => new Promise<{ startShift?: number, endShift?: number } | undefined>((resolve) => {
function ShiftDialog() {
const { onOpenChange } = useGenericDialogContext();
const [startValue, setStartValue] = useState('');
const [endValue, setEndValue] = useState('');
const parseValue = useCallback((value: string) => {
let parseableValue = value.trim();
if (!parseableValue) return undefined;
let sign = 1;
if (parseableValue[0] === '-') {
sign = -1;
parseableValue = parseableValue.slice(1);
}
const duration = parseTimecode(parseableValue);
invariant(duration != null);
if (duration === 0) return undefined;
return duration * sign;
}, []);
const handleSubmit = useCallback<FormEventHandler<HTMLFormElement>>((e) => {
e.preventDefault();
try {
const start = parseValue(startValue);
const end = parseValue(endValue);
// require at least one of them to be set
invariant(start != null || end != null);
const out: { startShift?: number, endShift?: number } = {};
if (start != null) out.startShift = start;
if (end != null) out.endShift = end;
resolve(out);
onOpenChange(false);
} catch (err) {
console.warn(err);
}
}, [parseValue, startValue, endValue, onOpenChange]);
return (
<Dialog.Content aria-describedby={undefined} style={{ width: '50vw' }}>
<Dialog.Title>{t('Shift segments')}</Dialog.Title>
<Dialog.Description>{t('Shift all segments on the timeline by this amount. Negative values will be shifted back, while positive value will be shifted forward in time.')}</Dialog.Description>
<form onSubmit={handleSubmit}>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label style={{ display: 'block', marginBottom: '.3em' }}>
{t('Shift start by')}<br />
<TextField.Root value={startValue} placeholder={inputPlaceholder} onChange={(e) => setStartValue(e.target.value)} />
</label>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label style={{ display: 'block', marginBottom: '.3em' }}>
{t('Shift end by')}<br />
<TextField.Root value={endValue} placeholder={inputPlaceholder} onChange={(e) => setEndValue(e.target.value)} />
</label>
<Dialog.ButtonRow>
<Dialog.Close asChild>
<DialogButton>{t('Cancel')}</DialogButton>
</Dialog.Close>
<DialogButton type="submit" primary>{t('Confirm')}</DialogButton>
</Dialog.ButtonRow>
</form>
</Dialog.Content>
);
}
showGenericDialog({
render: () => <ShiftDialog />,
onClose: () => resolve(undefined),
});
}), [showGenericDialog, t]);
return {
genericDialog,
closeGenericDialog,
@ -307,6 +385,7 @@ export function useDialog() {
openCutFinishedDialog,
openConcatFinishedDialog,
openCleanupFilesDialog,
openShiftSegmentsDialog,
};
}

@ -1,14 +1,15 @@
import type { CSSProperties, HTMLAttributes } from 'react';
import { memo } from 'react';
import { memo, forwardRef } from 'react';
import { primaryTextColor } from '../colors';
import styles from './HighlightedText.module.css';
export const highlightedTextStyle: CSSProperties = { textDecorationColor: primaryTextColor };
function HighlightedText({ children, style, ...props }: HTMLAttributes<HTMLButtonElement>) {
// eslint-disable-next-line react/display-name
const HighlightedText = forwardRef<HTMLButtonElement, HTMLAttributes<HTMLButtonElement>>(({ children, style, ...props }, ref) => (
// eslint-disable-next-line react/jsx-props-no-spreading
return <button type="button" {...props} className={styles['button']} style={{ ...highlightedTextStyle, ...style }}>{children}</button>;
}
<button ref={ref} type="button" {...props} className={styles['button']} style={{ ...highlightedTextStyle, ...style }}>{children}</button>
));
export default memo(HighlightedText);

@ -3,7 +3,8 @@ import { memo, useRef, useState, useMemo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import invariant from 'tiny-invariant';
import { motion } from 'motion/react';
import { FaCheck, FaEdit, FaPlus, FaTrash, FaUndo } from 'react-icons/fa';
import { FaCheck, FaEdit, FaInfo, FaPlus, FaTrash, FaUndo } from 'react-icons/fa';
import { IconButton, Tooltip } from '@radix-ui/themes';
import type { SegmentTags } from '../types';
import { segmentTagsSchema } from '../types';
@ -15,12 +16,14 @@ import Warning from './Warning';
const { clipboard } = window.require('electron');
const remote = window.require('@electron/remote');
const { shell } = remote;
const activeColor = 'var(--gray-12)';
const emptyObject = {};
function TagEditor({ existingTags = emptyObject, customTags = emptyObject, editingTag, setEditingTag, onTagsChange, onTagReset, addTagTitle }: {
function TagEditor({ existingTags = emptyObject, customTags = emptyObject, editingTag, setEditingTag, onTagsChange, onTagReset, addTagTitle, tagInfo }: {
existingTags?: SegmentTags,
customTags?: SegmentTags | undefined,
editingTag: string | undefined,
@ -28,6 +31,7 @@ function TagEditor({ existingTags = emptyObject, customTags = emptyObject, editi
onTagsChange: (keyValues: Record<string, string>) => void,
onTagReset: (tag: string) => void,
addTagTitle: string,
tagInfo?: Record<string, { description: string, url?: string }>,
}) {
const { t } = useTranslation();
const ref = useRef<HTMLInputElement>(null);
@ -65,8 +69,7 @@ function TagEditor({ existingTags = emptyObject, customTags = emptyObject, editi
const saveTag = useCallback(() => {
invariant(editingTag != null);
invariant(editingTagVal != null);
const editingValTransformed = editingTag === 'language' ? editingTagVal.toLowerCase() : editingTagVal;
onTagsChange({ [editingTag]: editingValTransformed });
onTagsChange({ [editingTag]: editingTagVal });
setEditingTag(undefined);
}, [editingTag, editingTagVal, onTagsChange, setEditingTag]);
@ -140,7 +143,7 @@ function TagEditor({ existingTags = emptyObject, customTags = emptyObject, editi
<td style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
{editingThis ? (
<form style={{ display: 'inline' }} onSubmit={onSubmit}>
<TextInput ref={ref} placeholder={t('Enter value')} value={editingTagVal || ''} onChange={(e) => setEditingTagVal(e.target.value)} style={{ padding: '.4em', textTransform: editingTag === 'language' ? 'lowercase' : undefined }} />
<TextInput ref={ref} placeholder={t('Enter value')} value={editingTagVal || ''} onChange={(e) => setEditingTagVal(e.target.value)} style={{ padding: '.4em' }} />
</form>
) : (
<span style={{ padding: '.3em 0', color: thisTagCustom ? activeColor : 'var(--gray-11)', fontWeight: thisTagCustom ? 'bold' : undefined }}>{effectiveTags[tag] ? String(effectiveTags[tag]) : `<${t('empty')}>`}</span>
@ -151,6 +154,11 @@ function TagEditor({ existingTags = emptyObject, customTags = emptyObject, editi
{thisTagNew ? <FaTrash style={{ fontSize: '.9em', padding: '.5em', verticalAlign: 'middle' }} /> : <FaUndo style={{ fontSize: '.9em', padding: '.5em', verticalAlign: 'middle' }} />}
</Button>
)}
{tagInfo?.[tag] && (
<Tooltip content={tagInfo[tag].description}>
<IconButton size="1" style={{ marginLeft: '.5em', cursor: tagInfo[tag]?.url ? 'pointer' : undefined }} onClick={() => tagInfo[tag]?.url && shell.openExternal(tagInfo[tag].url)}><FaInfo /></IconButton>
</Tooltip>
)}
</td>
</motion.tr>
);

@ -99,10 +99,12 @@ export async function askForFfPath(defaultPath?: string | undefined) {
return (filePaths && filePaths.length === 1) ? filePaths[0] : undefined;
}
export async function askForFileOpenAction(inputOptions: Record<string, string>) {
let value: string | undefined;
export type OpenFileResponse = 'open' | 'project' | 'tracks' | 'subtitles' | 'addToBatch' | 'mergeWithCurrentFile';
function onClick(key?: string) {
export async function askForFileOpenAction(inputOptions: [OpenFileResponse, string][]) {
let value: OpenFileResponse | undefined;
function onClick(key?: OpenFileResponse) {
value = key;
getSwal().Swal.close();
}
@ -112,7 +114,7 @@ export async function askForFileOpenAction(inputOptions: Record<string, string>)
<div style={{ textAlign: 'left' }}>
<div style={{ marginBottom: '1em' }}>{i18n.t('You opened a new file. What do you want to do?')}</div>
{Object.entries(inputOptions).map(([key, text]) => (
{inputOptions.map(([key, text]) => (
<button type="button" key={key} onClick={() => onClick(key)} className="button-unstyled" style={{ display: 'block', marginBottom: '.5em' }}>
<FaArrowRight style={{ color: 'var(--gray-10)', verticalAlign: 'middle' }} /> {text}
</button>
@ -223,7 +225,12 @@ export async function askForSegmentDuration({ totalDuration, inputPlaceholder, p
const segmentDuration = parseTimecode(v);
if (segmentDuration != null) {
const numSegments = Math.ceil(totalDuration / segmentDuration);
if (segmentDuration > 0 && segmentDuration < totalDuration && numSegments <= maxSegments) return null;
if (segmentDuration > 0 && numSegments <= maxSegments) {
if (segmentDuration < totalDuration) {
return null;
}
return i18n.t('Value must be shorter than total duration ({{totalDuration}})', { totalDuration: formatDuration({ seconds: totalDuration, shorten: true }) });
}
}
return i18n.t('Please input a valid duration. Example: {{example}}', { example: inputPlaceholder });
},
@ -286,50 +293,6 @@ async function askForSegmentsStartOrEnd(text: string) {
return value === 'both' ? ['start', 'end'] as const : [value as 'start' | 'end'] as const;
}
export async function askForShiftSegments({ inputPlaceholder, parseTimecode }: {
inputPlaceholder: string,
parseTimecode: ParseTimecode,
}) {
function parseValue(value: string) {
let parseableValue = value;
let sign = 1;
if (parseableValue[0] === '-') {
parseableValue = parseableValue.slice(1);
sign = -1;
}
const duration = parseTimecode(parseableValue);
if (duration != null && duration > 0) {
return duration * sign;
}
return undefined;
}
const { value } = await getSwal().Swal.fire<string>({
input: 'text',
showCancelButton: true,
inputValue: inputPlaceholder,
text: i18n.t('Shift all segments on the timeline by this amount. Negative values will be shifted back, while positive value will be shifted forward in time.'),
inputValidator: (v) => {
const parsed = parseValue(v);
if (parsed == null) return i18n.t('Please input a valid duration. Example: {{example}}', { example: inputPlaceholder });
return null;
},
});
if (value == null) return undefined;
const parsed = parseValue(value);
invariant(parsed != null);
const startOrEnd = await askForSegmentsStartOrEnd(i18n.t('Do you want to shift the start or end timestamp by {{time}}?', { time: formatDuration({ seconds: parsed, shorten: true }) }));
if (startOrEnd == null) return undefined;
return {
shiftAmount: parsed,
shiftKeys: startOrEnd,
};
}
export async function askForAlignSegments() {
const startOrEnd = await askForSegmentsStartOrEnd(i18n.t('Do you want to align the segment start or end timestamps to keyframes?'));
if (startOrEnd == null) return undefined;

@ -14,6 +14,7 @@ import type { FFprobeChapter, FFprobeFormat, FFprobeProbeResult, FFprobeStream }
import { parseSrt, parseSrtToSegments } from './edlFormats';
import { UnsupportedFileError, UserFacingError } from '../errors';
import mainApi from './mainApi';
import { parseFfprobeDuration } from '../../common/util';
const { ffmpeg } = window.require('@electron/remote').require('./index.js');
@ -191,9 +192,8 @@ export function getSafeCutTime(frames: Frame[], cutTime: number, nextMode: boole
return frames[index - 1]!.time;
}
export function findNearestKeyFrameTime({ frames, time, direction, fps }: { frames: Frame[], time: number, direction: number, fps: number | undefined }) {
const sigma = fps ? (1 / fps) : 0.1;
const keyframes = frames.filter((f) => f.keyframe && (direction > 0 ? f.time > time + sigma : f.time < time - sigma));
export function findNearestKeyFrameTime({ frames, time, direction }: { frames: Frame[], time: number, direction: number }) {
const keyframes = frames.filter((f) => f.keyframe && (direction > 0 ? f.time >= time : f.time <= time));
if (keyframes.length === 0) return undefined;
const nearestKeyFrame = sortBy(keyframes, (keyframe) => (direction > 0 ? keyframe.time - time : time - keyframe.time))[0];
if (!nearestKeyFrame) return undefined;
@ -221,22 +221,6 @@ export function tryMapChaptersToEdl(chapters: FFprobeChapter[]) {
}
}
export async function createChaptersFromSegments({ segmentPaths, chapterNames }: { segmentPaths: string[], chapterNames?: (string | undefined)[] | undefined }) {
if (!chapterNames) return undefined;
try {
const durations = await pMap(segmentPaths, async (segmentPath) => (await getDuration(segmentPath)) ?? 0, { concurrency: 3 });
let timeAt = 0;
return durations.map((duration, i) => {
const ret = { start: timeAt, end: timeAt + duration, name: chapterNames[i] };
timeAt += duration;
return ret;
});
} catch (err) {
console.error('Failed to create chapters from segments', err);
return undefined;
}
}
/**
* Some of the detected input formats are not the same as the muxer name used for encoding.
* Therefore we have to map between detected input format and encode format
@ -577,3 +561,49 @@ export async function runFfmpegStartupCheck() {
export const getExperimentalArgs = (ffmpegExperimental: boolean) => (ffmpegExperimental ? ['-strict', 'experimental'] : []);
export const getVideoTimescaleArgs = (videoTimebase: number | undefined) => (videoTimebase != null ? ['-video_track_timescale', String(videoTimebase)] : []);
export async function createChaptersFromSegments({ paths, defaultChapterNames, useFileChapters }: {
paths: string[],
defaultChapterNames?: (string | undefined)[] | undefined,
useFileChapters?: boolean,
}) {
if (defaultChapterNames == null) return undefined;
try {
const filesMeta = await pMap(paths, async (path) => readFileFfprobeMeta(path), { concurrency: 3 });
let timeAt = 0;
return filesMeta.flatMap((meta, i) => {
const duration = parseFfprobeDuration(meta.format.duration) ?? 0;
try {
// if the file has chapters, use it (but only when not cutting)
if (useFileChapters && meta.chapters.length > 0) {
return meta.chapters.map((ch) => {
const start = parseFloat(ch.start_time);
const end = parseFloat(ch.end_time);
invariant(!Number.isNaN(start) && !Number.isNaN(end));
return {
start: timeAt + start,
end: timeAt + start,
name: ch.tags.title,
};
});
}
// if no chapters, or if cutting, then create one chapter for the whole file with the default name
return [{
start: timeAt,
end: timeAt + duration,
name: defaultChapterNames[i],
}];
} finally {
timeAt += duration;
}
});
} catch (err) {
console.error('Failed to create chapters', err);
return undefined;
}
}

@ -5,18 +5,18 @@ import pMap from 'p-map';
import invariant from 'tiny-invariant';
import i18n from 'i18next';
import { getSuffixedOutPath, transferTimestamps, getOutFileExtension, getOutDir, deleteDispositionValue, getHtml5ifiedPath, unlinkWithRetry, getFrameDuration, isMac, html5ifiedPrefix, html5dummySuffix, assertFileExists } from '../util';
import { getSuffixedOutPath, transferTimestamps, getOutFileExtension, getOutDir, getHtml5ifiedPath, unlinkWithRetry, getFrameDuration, isMac, html5ifiedPrefix, html5dummySuffix, assertFileExists } from '../util';
import { isCuttingStart, isCuttingEnd, runFfmpegWithProgress, getFfCommandLine, getDuration, createChaptersFromSegments, readFileFfprobeMeta, getExperimentalArgs, getVideoTimescaleArgs, logStdoutStderr, runFfmpegConcat, RefuseOverwriteError, runFfmpeg } from '../ffmpeg';
import { getMapStreamsArgs, getStreamIdsToCopy } from '../util/streams';
import { needsSmartCut, getCodecParams } from '../smartcut';
import { getGuaranteedSegments, isDurationValid } from '../segments';
import type { FFprobeStream } from '../../../common/ffprobe';
import type { AvoidNegativeTs, FfmpegHwAccel, Html5ifyMode, PreserveMetadata } from '../../../common/types';
import type { AllFilesMeta, Chapter, CopyfileStreams, CustomTagsByFile, LiteFFprobeStream, ParamsByStreamId, SegmentToExport } from '../types';
import { deleteDispositionValue, type AllFilesMeta, type Chapter, type CopyfileStreams, type CustomTagsByFile, type LiteFFprobeStream, type ParamsByStreamId, type SegmentToExport } from '../types';
import type { LossyMode } from '../../../main';
import { UserFacingError } from '../../errors';
import mainApi from '../mainApi';
import { getHwaccelArgs } from '../../../common/util';
import { formatFfmpegTime, getHwaccelArgs } from '../../../common/util';
const { join, resolve, dirname } = window.require('path');
const { writeFile, mkdir, access, constants: { W_OK } } = window.require('fs/promises');
@ -278,8 +278,8 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
if (detectedFps != null) cutDuration = Math.max(cutDuration, frameDuration); // ensure at least one frame duration
// Don't cut if no need: https://github.com/mifi/lossless-cut/issues/50
const cutFromArgs = cuttingStart ? ['-ss', cutFromWithAdjustment.toFixed(5)] : [];
const cutToArgs = cuttingEnd ? ['-t', cutDuration.toFixed(5)] : [];
const cutFromArgs = cuttingStart ? ['-ss', formatFfmpegTime(cutFromWithAdjustment)] : [];
const cutToArgs = cuttingEnd ? ['-t', formatFfmpegTime(cutDuration)] : [];
const copyFileStreamsFiltered = copyFileStreams.filter(({ streamIds }) => streamIds.length > 0);
@ -289,7 +289,15 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
// If cutting multiple files, `-ss` must be before `-i`, regardless of `ssBeforeInput` choice
// and it seems that `-t` must be after `-i` #896
const inputFilesArgs = copyFileStreamsFiltered.length > 1
? flatMap(copyFileStreamsFiltered, ({ path }) => [...cutFromArgs, '-i', path, ...cutToArgs])
? flatMap(copyFileStreamsFiltered, ({ streamIds, path }) => {
// don't "cut"/seek cover art or images attached by users, see https://github.com/mifi/lossless-cut/issues/2884
const streamParams = streamIds.map((streamId) => paramsByStreamId.get(path)?.get(streamId));
if (streamIds.length === 1 && streamParams[0]?.disposition === 'attached_pic') {
return ['-i', path];
}
return [...cutFromArgs, '-i', path, ...cutToArgs];
})
: [
...(ssBeforeInput ? cutFromArgs : []),
'-i', copyFileStreamsFiltered[0]!.path,
@ -509,10 +517,10 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
// No progress if we set loglevel warning :(
// '-loglevel', 'warning',
'-ss', cutFrom.toFixed(5), // if we don't -ss before -i, seeking will be slow for long files, see https://github.com/mifi/lossless-cut/issues/126#issuecomment-1135451043
'-ss', formatFfmpegTime(cutFrom), // if we don't -ss before -i, seeking will be slow for long files, see https://github.com/mifi/lossless-cut/issues/126#issuecomment-1135451043
'-i', filePath,
'-ss', '0', // If we don't do this, the output seems to start with an empty black after merging with the encoded part
'-t', (cutTo - cutFrom).toFixed(5),
'-t', formatFfmpegTime(cutTo - cutFrom),
...mapStreamsArgs,
@ -717,7 +725,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
if (await shouldSkipExistingFile(mergedOutFilePath)) return;
const chapters = await createChaptersFromSegments({ segmentPaths, chapterNames });
const chapters = await createChaptersFromSegments({ paths: segmentPaths, defaultChapterNames: chapterNames });
const metadataFromPath = segmentPaths[0];
invariant(metadataFromPath != null);

@ -42,7 +42,7 @@ function useKeyframes({ keyframesEnabled, filePath, commandedTime, videoStream,
return map;
}, [detectedFps, neighbouringKeyFrames]);
const findNearestKeyFrameTime = useCallback(({ time, direction }: { time: number, direction: number }) => ffmpegFindNearestKeyFrameTime({ frames: neighbouringKeyFrames, time, direction, fps: detectedFps }), [neighbouringKeyFrames, detectedFps]);
const findNearestKeyFrameTime = useCallback(({ time, direction }: { time: number, direction: number }) => ffmpegFindNearestKeyFrameTime({ frames: neighbouringKeyFrames, time, direction }), [neighbouringKeyFrames]);
useEffect(() => setNeighbouringKeyFrames({}), [filePath, videoStream]);

@ -12,7 +12,7 @@ import TextInput from '../components/TextInput';
import { detectSceneChanges as ffmpegDetectSceneChanges, readFrames, mapTimesToSegments, findKeyframeNearTime } from '../ffmpeg';
import { getFileSize, shuffleArray } from '../util';
import { errorToast } from '../swal';
import { createNumSegments as createNumSegmentsDialog, createFixedByteSixedSegments as createFixedByteSixedSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, labelSegmentDialog, askForShiftSegments, askForAlignSegments, selectSegmentsByLabelDialog, askForSegmentDuration, toastError } from '../dialogs';
import { createNumSegments as createNumSegmentsDialog, createFixedByteSixedSegments as createFixedByteSixedSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, labelSegmentDialog, askForAlignSegments, selectSegmentsByLabelDialog, askForSegmentDuration, toastError } from '../dialogs';
import { createSegment, sortSegments, invertSegments, combineOverlappingSegments as combineOverlappingSegments2, combineSelectedSegments as combineSelectedSegments2, isDurationValid, addSegmentColorIndex, filterNonMarkers, makeDurationSegments, isInitialSegment } from '../segments';
import type { FfmpegDialog } from '../ffmpegParameters';
import { parameters as allFfmpegParameters, getHint, getLabel } from '../ffmpegParameters';
@ -495,20 +495,6 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
safeSetCutSegments(newSegments, fileDuration);
}, [cutSegments, fileDuration, safeSetCutSegments]);
const shiftAllSegmentTimes = useCallback(async () => {
const shift = await askForShiftSegments({ inputPlaceholder: timecodePlaceholder, parseTimecode });
if (shift == null) return;
const { shiftAmount, shiftKeys } = shift;
await modifySelectedSegmentTimes((segment) => {
const newSegment = { ...segment };
shiftKeys.forEach((key) => {
if (newSegment[key] != null) newSegment[key] += shiftAmount;
});
return newSegment;
});
}, [modifySelectedSegmentTimes, parseTimecode, timecodePlaceholder]);
const alignSegmentTimesToKeyframes = useCallback(async () => {
if (!videoStream || workingRef.current) return;
try {
@ -986,7 +972,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
fillSegmentsGaps,
combineOverlappingSegments,
combineSelectedSegments,
shiftAllSegmentTimes,
modifySelectedSegmentTimes,
alignSegmentTimesToKeyframes,
updateSegOrder,
updateSegOrders,

@ -54,16 +54,6 @@ export default ({ detectedFps, timecodeFormat, showGenericDialog }: {
return parseDuration(val);
}, [detectedFps, frameCountToDuration, timecodeFormat]);
const formatTimeAndFrames = useCallback((seconds: number) => {
const frameCount = getFrameCount(seconds);
const timeStr = timecodeFormat === 'timecodeWithFramesFraction'
? formatDuration({ seconds, fps: detectedFps })
: formatDuration({ seconds });
return `${timeStr} (${frameCount ?? '0'})`;
}, [detectedFps, timecodeFormat, getFrameCount]);
const promptTimecode = useCallback(async ({ initialValue, title, description, inputPlaceholder, allowRelative = false }: {
initialValue?: string | undefined,
title: string,
@ -141,7 +131,6 @@ export default ({ detectedFps, timecodeFormat, showGenericDialog }: {
return {
parseTimecode,
formatTimecode,
formatTimeAndFrames,
timecodePlaceholder,
getFrameCount,
promptTimecode,

@ -22,6 +22,8 @@ import '@fontsource/open-sans/700-italic.css';
import '@fontsource/open-sans/800.css';
import '@fontsource/open-sans/800-italic.css';
import '@radix-ui/themes/styles.css';
import type { KeyboardLayoutMap } from './types';
import App from './App';
import ErrorBoundary from './ErrorBoundary';

@ -101,57 +101,6 @@ it('detects overlapping segments', () => {
]);
});
test('combineOverlappingSegments', () => {
const segments = [
{
start: 0,
},
{
start: 838,
end: 1101,
},
{
start: 1101,
end: 1244,
},
{
start: 1216,
end: 1487,
},
{
start: 1487,
},
{
start: 1625,
},
{
start: 503,
end: 669,
},
{
start: 392,
end: 716,
},
{
start: 229,
end: 784,
},
{
start: 0,
end: 87,
},
{
start: 1561,
end: 1831,
},
{
start: 2027,
},
];
expect(combineOverlappingSegments(segments)).toMatchSnapshot();
});
it('detects overlapping segments, undefined end', () => {
expect(partitionIntoOverlappingRanges([
{ start: 1, end: undefined },
@ -213,3 +162,77 @@ describe('invertSegments', () => {
], false, false)).toMatchSnapshot();
});
});
describe('combineOverlappingSegments', () => {
test('non touching', () => {
expect(combineOverlappingSegments([
{ start: 1, end: 2 },
{ start: 3, end: 4 },
])).toMatchSnapshot();
});
test('overlapping and non overlapping', () => {
expect(combineOverlappingSegments([
{ start: 1, end: 2 },
{ start: 1.5, end: 3 },
{ start: 4, end: 5 },
])).toMatchSnapshot();
});
test('adjacent', () => {
expect(combineOverlappingSegments([
{ start: 1, end: 2 },
{ start: 2, end: 3 },
{ start: 3, end: 4 },
])).toMatchSnapshot();
});
test('various', () => {
const segments = [
{
start: 0,
},
{
start: 838,
end: 1101,
},
{
start: 1101,
end: 1244,
},
{
start: 1216,
end: 1487,
},
{
start: 1487,
},
{
start: 1625,
},
{
start: 503,
end: 669,
},
{
start: 392,
end: 716,
},
{
start: 229,
end: 784,
},
{
start: 0,
end: 87,
},
{
start: 1561,
end: 1831,
},
{
start: 2027,
},
];
expect(combineOverlappingSegments(segments)).toMatchSnapshot();
});
});

@ -129,8 +129,8 @@ export function combineOverlappingSegments<T extends SegmentBase>(existingSegmen
const currentSegmentEndOrStart = currentSegment.end ?? currentSegment.start;
// Check if the current segment overlaps or is adjacent to the next segment
if (currentSegmentEndOrStart >= nextSegment.start) {
// Check if the current segment overlaps with the next segment
if (currentSegmentEndOrStart > nextSegment.start) {
currentSegment = {
...currentSegment,
end: Math.max(currentSegmentEndOrStart, nextSegment.end ?? nextSegment.start),

@ -175,9 +175,15 @@ export interface BsfAspectRatioParams {
den: number;
}
export const dispositionOptions = ['default', 'dub', 'original', 'comment', 'lyrics', 'karaoke', 'forced', 'hearing_impaired', 'visual_impaired', 'clean_effects', 'attached_pic', 'captions', 'descriptions', 'dependent', 'metadata'] as const;
export const contentDispositionOptionsSchema = z.enum(dispositionOptions);
export type ContentDispositionOptions = z.infer<typeof contentDispositionOptionsSchema>;
export const deleteDispositionValue = 'llc_disposition_remove' as const;
export interface StreamParams {
customTags?: Record<string, string>,
disposition?: string,
disposition?: ContentDispositionOptions | 'llc_disposition_remove' | undefined,
bsfH264Mp4toannexb?: boolean,
bsfHevcMp4toannexb?: boolean,
bsfHevcAudInsert?: boolean,

@ -273,9 +273,6 @@ export function getHtml5ifiedPath(cod: string | undefined, fp: string, type: Exc
return getSuffixedOutPath({ customOutDir: cod, filePath: fp, nameSuffix: `${html5ifiedPrefix}${type}.${ext}` });
}
export const deleteDispositionValue = 'llc_disposition_remove';
export const mirrorTransform = 'matrix(-1, 0, 0, 1, 0, 0)';
// todo this is not a correct assumption

@ -197,10 +197,18 @@ function getPerStreamFlags({ stream, outputIndex, outFormat, manuallyCopyDisposi
return args;
}
export type AllFilesStreamMeta = Record<string, Pick<AllFilesMeta[string], 'streams'>>;
export const getStreamById = ({ allFilesMeta, streamId, path }: {
allFilesMeta: AllFilesStreamMeta,
streamId: number,
path: string,
}) => allFilesMeta?.[path]?.streams.find((s) => s.index === streamId);
export function getMapStreamsArgs({ startIndex = 0, outFormat, allFilesMeta, copyFileStreams, manuallyCopyDisposition, getVideoArgs, needFlac }: {
startIndex?: number,
outFormat: string | undefined,
allFilesMeta: Record<string, Pick<AllFilesMeta[string], 'streams'>>,
allFilesMeta: AllFilesStreamMeta,
copyFileStreams: CopyfileStreams,
manuallyCopyDisposition?: boolean,
getVideoArgs?: GetVideoArgsFn,
@ -211,9 +219,9 @@ export function getMapStreamsArgs({ startIndex = 0, outFormat, allFilesMeta, cop
copyFileStreams.forEach(({ streamIds, path }, fileIndex) => {
streamIds.forEach((streamId) => {
const { streams } = allFilesMeta[path]!;
const stream = streams.find((s) => s.index === streamId);
const stream = getStreamById({ allFilesMeta, streamId, path });
invariant(stream != null);
args = [
...args,
'-map', `${fileIndex}:${streamId}`,

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save