40 |
{t('DROP FILE(S)')}
41 |
42 |
43 | See Help menu for help
44 |
45 |
46 |
47 | or to set cutpoints
48 |
49 |
50 |
e.stopPropagation()}>
51 | {simpleMode ? (
52 | to show advanced view
53 | ) : (
54 | to show simple view
55 | )}
56 |
57 |
58 | {mifiLink && typeof mifiLink === 'object' && 'loadUrl' in mifiLink && typeof mifiLink.loadUrl === 'string' && mifiLink.loadUrl ? (
59 |
60 |
61 | {/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */}
62 |
{ e.stopPropagation(); if ('targetUrl' in mifiLink && typeof mifiLink.targetUrl === 'string') electron.shell.openExternal(mifiLink.targetUrl); }} />
63 |
64 | ) : undefined}
65 |
66 | );
67 | }
68 |
69 | export default memo(NoFileLoaded);
70 |
--------------------------------------------------------------------------------
/src/renderer/src/StreamsSelector.module.css:
--------------------------------------------------------------------------------
1 | .table {
2 | font-size: .8em;
3 | width: 100%;
4 | border-collapse: collapse;
5 |
6 | th {
7 | padding-bottom: .3em;
8 | }
9 |
10 | th, td {
11 | padding-left: .2em;
12 | padding-right: .2em;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/renderer/src/Timeline.module.css:
--------------------------------------------------------------------------------
1 | .time-wrapper {
2 | position: absolute;
3 | left: 0;
4 | right: 0;
5 | bottom: 0;
6 | display: flex;
7 | align-items: center;
8 | justify-content: center;
9 | pointer-events: none;
10 | }
11 | .time {
12 | background: rgba(0,0,0,0.4);
13 | border-radius: 3px;
14 | padding: 2px 4px;
15 | color: rgba(255, 255, 255, 0.8);
16 | transition: opacity 0.2s;
17 | }
18 |
19 | @keyframes background-animation {
20 | 0% {background-position: 0% 50%}
21 | 100% {background-position: 100% 50%}
22 | }
23 |
24 | .loading-bg {
25 | --c: rgba(255,255,255,0.3);
26 | background: repeating-linear-gradient(45deg, var(--c), var(--c) 1px, transparent 1.5px, transparent 4.4px);
27 | background-size: 50% 100%;
28 | animation: background-animation 3s linear infinite;
29 | }
30 |
--------------------------------------------------------------------------------
/src/renderer/src/__snapshots__/segments.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`combineOverlappingSegments 1`] = `
4 | [
5 | {
6 | "end": 87,
7 | "start": 0,
8 | },
9 | {
10 | "end": 784,
11 | "start": 229,
12 | },
13 | {
14 | "end": 1487,
15 | "start": 838,
16 | },
17 | {
18 | "end": 1831,
19 | "start": 1561,
20 | },
21 | {
22 | "start": 2027,
23 | },
24 | ]
25 | `;
26 |
27 | exports[`converts segments to chapters with gaps 1`] = `
28 | [
29 | {
30 | "end": 104.612,
31 | "start": 0,
32 | },
33 | {
34 | "end": 189.053,
35 | "name": "label 1",
36 | "start": 104.612,
37 | },
38 | {
39 | "end": 300.448,
40 | "start": 189.053,
41 | },
42 | {
43 | "end": 476.194,
44 | "name": "label 2",
45 | "start": 300.448,
46 | },
47 | {
48 | "end": 567.075,
49 | "start": 476.194,
50 | },
51 | {
52 | "end": 704.264,
53 | "name": "label 3",
54 | "start": 567.075,
55 | },
56 | {
57 | "end": 855.455,
58 | "name": "label 4",
59 | "start": 704.264,
60 | },
61 | ]
62 | `;
63 |
64 | exports[`converts segments to chapters with no gaps 1`] = `
65 | [
66 | {
67 | "end": 2,
68 | "name": "label 1",
69 | "start": 0,
70 | },
71 | {
72 | "end": 3,
73 | "name": "label 2",
74 | "start": 2,
75 | },
76 | ]
77 | `;
78 |
79 | exports[`converts segments to chapters with single long segment 1`] = `
80 | [
81 | {
82 | "end": 1,
83 | "name": "label 1",
84 | "start": 0,
85 | },
86 | ]
87 | `;
88 |
89 | exports[`invertSegments > adjacent 1`] = `
90 | [
91 | {
92 | "end": 2,
93 | "start": 0,
94 | },
95 | {
96 | "end": 100,
97 | "name": "Segment 2",
98 | "start": 4,
99 | },
100 | ]
101 | `;
102 |
103 | exports[`invertSegments > none 1`] = `[]`;
104 |
105 | exports[`invertSegments > normal 1`] = `
106 | [
107 | {
108 | "end": 1,
109 | "start": 0,
110 | },
111 | {
112 | "end": 2,
113 | "name": "Marker 1",
114 | "start": 1,
115 | },
116 | {
117 | "end": 5,
118 | "name": "Segment 2",
119 | "start": 3,
120 | },
121 | {
122 | "end": 100,
123 | "name": "Segment 3",
124 | "start": 5,
125 | },
126 | ]
127 | `;
128 |
129 | exports[`invertSegments > overlap 1 1`] = `[]`;
130 |
131 | exports[`invertSegments > overlap 2 1`] = `[]`;
132 |
133 | exports[`invertSegments > undefined duration 1`] = `
134 | [
135 | {
136 | "end": 3,
137 | "start": 0,
138 | },
139 | {
140 | "end": undefined,
141 | "name": "Marker 1",
142 | "start": 3,
143 | },
144 | ]
145 | `;
146 |
147 | exports[`invertSegments > undefined duration 2 1`] = `[]`;
148 |
--------------------------------------------------------------------------------
/src/renderer/src/animations.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line import/prefer-default-export
2 | export const mySpring = { type: 'spring', damping: 50, stiffness: 700 };
3 |
--------------------------------------------------------------------------------
/src/renderer/src/cmx3600.ts:
--------------------------------------------------------------------------------
1 | export interface EDLEvent {
2 | eventNumber: string;
3 | reelNumber: string;
4 | trackType: string;
5 | transition: string;
6 | sourceIn: string;
7 | sourceOut: string;
8 | recordIn: string;
9 | recordOut: string;
10 | }
11 |
12 | export default function parseCmx3600(edlContent: string) {
13 | const [firstLine, ...lines] = edlContent.split('\n');
14 | const events: EDLEvent[] = [];
15 |
16 | // trim BOM from first line.
17 | for (const line of [...(firstLine ? [firstLine.trim()] : []), ...lines]) {
18 | if (/^\d+\s+/.test(line)) {
19 | const parts = line.trim().split(/\s+/);
20 | if (parts.length >= 8) {
21 | events.push({
22 | eventNumber: parts[0]!,
23 | reelNumber: parts[1]!,
24 | trackType: parts[2]!,
25 | transition: parts[3]!,
26 | sourceIn: parts[4]!,
27 | sourceOut: parts[5]!,
28 | recordIn: parts[6]!,
29 | recordOut: parts[7]!,
30 | });
31 | }
32 | }
33 | }
34 |
35 | return { events };
36 | }
37 |
--------------------------------------------------------------------------------
/src/renderer/src/colors.ts:
--------------------------------------------------------------------------------
1 | export const saveColor = 'var(--green-11)';
2 | export const primaryColor = 'var(--cyan-9)';
3 | export const primaryTextColor = 'var(--cyan-11)';
4 | export const controlsBackground = 'var(--gray-4)';
5 | export const timelineBackground = 'var(--gray-2)';
6 | export const darkModeTransition = 'background .5s';
7 |
--------------------------------------------------------------------------------
/src/renderer/src/components/Action.tsx:
--------------------------------------------------------------------------------
1 | import { TakeActionIcon } from 'evergreen-ui';
2 |
3 | import { KeyboardAction } from '../../../../types';
4 |
5 |
6 | export default function Action({ name }: { name: KeyboardAction }) {
7 | return (
8 |
9 |
10 | {name}
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/src/renderer/src/components/AutoExportToggler.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import { ForkIcon, DisableIcon } from 'evergreen-ui';
4 |
5 | import useUserSettings from '../hooks/useUserSettings';
6 | import Button from './Button';
7 |
8 | function AutoExportToggler() {
9 | const { t } = useTranslation();
10 | const { autoExportExtraStreams, setAutoExportExtraStreams } = useUserSettings();
11 |
12 | const Icon = autoExportExtraStreams ? ForkIcon : DisableIcon;
13 |
14 | return (
15 |
setAutoExportExtraStreams(!autoExportExtraStreams)}>
16 | {autoExportExtraStreams ? t('Extract') : t('Discard')}
17 |
18 | );
19 | }
20 |
21 | export default memo(AutoExportToggler);
22 |
--------------------------------------------------------------------------------
/src/renderer/src/components/BatchFile.tsx:
--------------------------------------------------------------------------------
1 | import { memo, useRef, useMemo } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import { FaAngleRight, FaFile } from 'react-icons/fa';
4 |
5 | import useContextMenu from '../hooks/useContextMenu';
6 | import { primaryTextColor } from '../colors';
7 |
8 | function BatchFile({ path, index, isOpen, isSelected, name, onSelect, onDelete }: {
9 | path: string,
10 | index: number,
11 | isOpen: boolean,
12 | isSelected: boolean,
13 | name: string,
14 | onSelect: (a: string) => void,
15 | onDelete: (a: string) => void,
16 | }) {
17 | const ref = useRef
(null);
18 |
19 | const { t } = useTranslation();
20 | const contextMenuTemplate = useMemo(() => [
21 | { label: t('Remove'), click: () => onDelete(path) },
22 | ], [t, onDelete, path]);
23 |
24 | useContextMenu(ref, contextMenuTemplate);
25 |
26 | return (
27 | onSelect(path)}>
28 |
29 |
30 |
{index + 1}. {name}
31 |
32 | {isOpen &&
}
33 |
34 | );
35 | }
36 |
37 | export default memo(BatchFile);
38 |
--------------------------------------------------------------------------------
/src/renderer/src/components/Button.module.css:
--------------------------------------------------------------------------------
1 | .button {
2 | appearance: none;
3 | font: inherit;
4 | line-height: 140%;
5 | font-size: .8em;
6 | background-color: var(--gray-3);
7 | color: var(--gray-12);
8 | border-radius: .3em;
9 | padding: 0 .5em 0 .3em;
10 | border: .05em solid var(--gray-7);
11 | cursor: pointer;
12 | outline-offset: 0;
13 | }
14 |
15 | .button:disabled {
16 | opacity: .5;
17 | cursor: not-allowed;
18 | }
19 |
20 | .button:focus {
21 | outline: .05em solid var(--gray-11);
22 | }
23 |
24 | .button:hover {
25 | filter: brightness(1.1);
26 | }
27 |
--------------------------------------------------------------------------------
/src/renderer/src/components/Button.tsx:
--------------------------------------------------------------------------------
1 | import { ButtonHTMLAttributes, DetailedHTMLProps, forwardRef } from 'react';
2 |
3 | import styles from './Button.module.css';
4 |
5 | export type ButtonProps = DetailedHTMLProps, HTMLButtonElement>;
6 |
7 | // eslint-disable-next-line react/display-name
8 | const Button = forwardRef(({ type = 'button', className, ...props }, ref) => (
9 | // eslint-disable-next-line react/jsx-props-no-spreading, react/button-has-type
10 |
11 | ));
12 |
13 | export default Button;
14 |
--------------------------------------------------------------------------------
/src/renderer/src/components/CaptureFormatButton.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import { FaImage } from 'react-icons/fa';
4 |
5 | import useUserSettings from '../hooks/useUserSettings';
6 | import { withBlur } from '../util';
7 | import Button from './Button';
8 |
9 |
10 | function CaptureFormatButton({ showIcon = false, ...props }: { showIcon?: boolean } & Parameters[0]) {
11 | const { t } = useTranslation();
12 | const { captureFormat, toggleCaptureFormat } = useUserSettings();
13 | return (
14 |
20 | {showIcon && }
21 | {captureFormat.toUpperCase()}
22 |
23 | );
24 | }
25 |
26 | export default memo(CaptureFormatButton);
27 |
--------------------------------------------------------------------------------
/src/renderer/src/components/Checkbox.module.css:
--------------------------------------------------------------------------------
1 | .CheckboxRoot {
2 | all: unset
3 | }
4 |
5 | .CheckboxRoot {
6 | flex-shrink: 0;
7 | background-color: var(--gray-8);
8 | width: 1em;
9 | height: 1em;
10 | border-radius: .2em;
11 | display: flex;
12 | align-items: center;
13 | justify-content: center;
14 | box-shadow: 0 2px 10px var(--gray-1);
15 | }
16 | .CheckboxRoot:hover {
17 | background-color: var(--gray-9);
18 | }
19 | .CheckboxRoot:focus {
20 | box-shadow: 0 0 0 2px var(--gray-1);
21 | }
22 |
23 | .CheckboxIndicator {
24 | color: var(--gray-12);
25 | }
26 |
27 | .CheckboxRoot[data-disabled]{
28 | opacity: .5;
29 | }
30 |
31 | .Label {
32 | padding-left: .5em;
33 | line-height: 1.2;
34 | }
--------------------------------------------------------------------------------
/src/renderer/src/components/Checkbox.tsx:
--------------------------------------------------------------------------------
1 | import { useId } from 'react';
2 | import { Root, Indicator, CheckboxProps } from '@radix-ui/react-checkbox';
3 | import { FaCheck } from 'react-icons/fa';
4 |
5 | import classes from './Checkbox.module.css';
6 |
7 |
8 | export default function Checkbox({ label, disabled, style, ...props }: CheckboxProps & { label?: string | undefined }) {
9 | const id = useId();
10 | return (
11 |
12 | {/* eslint-disable-next-line react/jsx-props-no-spreading */}
13 |
14 |
15 |
16 |
17 |
18 |
19 | {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
20 |
21 | {label}
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/renderer/src/components/CloseButton.module.css:
--------------------------------------------------------------------------------
1 | .close-button {
2 | all: unset;
3 | position: absolute;
4 | padding: .5em;
5 | margin: .2em;
6 | display: inline-flex;
7 | align-items: center;
8 | justify-content: center;
9 | cursor: pointer;
10 | border-radius: 100%;
11 | font-size: 1.3em;
12 |
13 | border: .1em solid transparent;
14 | color: var(--gray-12);
15 | background-color: none;
16 | transition: background-color 0.2s ease-in-out, transform 0.2s ease-in-out;
17 | }
18 |
19 | .close-button:hover {
20 | background-color: var(--gray-4);
21 | transform: rotate(180deg);
22 | }
23 |
24 | .close-button:focus {
25 | border: .1em solid var(--gray-4);
26 | }
27 |
--------------------------------------------------------------------------------
/src/renderer/src/components/CloseButton.tsx:
--------------------------------------------------------------------------------
1 | import { FaTimes } from 'react-icons/fa';
2 | import { DetailedHTMLProps, ButtonHTMLAttributes } from 'react';
3 |
4 | import styles from './CloseButton.module.css';
5 | import i18n from '../i18n';
6 |
7 | export default function CloseButton({ type = 'button', ...props }: DetailedHTMLProps, HTMLButtonElement>) {
8 | return (
9 | // eslint-disable-next-line react/jsx-props-no-spreading, react/button-has-type
10 |
11 |
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/src/renderer/src/components/CopyClipboardButton.tsx:
--------------------------------------------------------------------------------
1 | import { memo, ReactNode, useCallback } from 'react';
2 | import { FaClipboard } from 'react-icons/fa';
3 | import { MotionStyle, motion, useAnimation } from 'framer-motion';
4 | import i18n from '../i18n';
5 |
6 | const electron = window.require('electron');
7 | const { clipboard } = electron;
8 |
9 | function CopyClipboardButton({ text, style, children = ({ onClick }) => }: {
10 | text: string,
11 | style?: MotionStyle,
12 | children?: (p: { onClick: () => void }) => ReactNode,
13 | }) {
14 | const animation = useAnimation();
15 |
16 | const onClick = useCallback(() => {
17 | clipboard.writeText(text);
18 | animation.start({
19 | scale: [1, 2, 1],
20 | transition: { duration: 0.3 },
21 | });
22 | }, [animation, text]);
23 |
24 | return (
25 |
26 | {children({ onClick })}
27 |
28 | );
29 | }
30 |
31 | export default memo(CopyClipboardButton);
32 |
--------------------------------------------------------------------------------
/src/renderer/src/components/Dialog.module.css:
--------------------------------------------------------------------------------
1 | .dialog {
2 | --duration: 0.2s;
3 |
4 | border: .1em solid var(--black-a5);
5 | background: var(--white-a8);
6 | color: var(--gray-12);
7 | backdrop-filter: blur(2em);
8 | border-radius: .5em;
9 | padding: 1.7em;
10 | box-shadow: 0 0 1em .3em var(--black-a1);
11 | transform-origin: center;
12 | transition:
13 | translate var(--duration) ease-out,
14 | scale var(--duration) ease-out,
15 | opacity var(--duration) ease-out,
16 | display var(--duration) ease-out allow-discrete;
17 |
18 | &[open] {
19 | /* Post-Entry (Normal) State */
20 | scale: 1;
21 | opacity: 1;
22 |
23 | /* Pre-Entry State */
24 | @starting-style {
25 | scale: 0.6;
26 | opacity: 0;
27 | }
28 | }
29 |
30 | &::backdrop {
31 | background-color: var(--black-a7);
32 | animation: overlayShow 600ms cubic-bezier(0.16, 1, 0.3, 1);
33 | }
34 |
35 | h1 {
36 | text-transform: uppercase;
37 | font-size: 1.3em;
38 | margin-top: 0;
39 | }
40 | }
41 |
42 | :global(.dark-theme) .dialog {
43 | border: .1em solid var(--white-a3);
44 | background: var(--black-a4);
45 | box-shadow: 0 0 1em .3em var(--black-a2);
46 | }
47 |
48 | @keyframes overlayShow {
49 | from {
50 | opacity: 0;
51 | }
52 | to {
53 | opacity: 1;
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/renderer/src/components/Dialog.tsx:
--------------------------------------------------------------------------------
1 | import { DetailedHTMLProps, DialogHTMLAttributes, useCallback, useEffect, forwardRef, useRef } from 'react';
2 | import invariant from 'tiny-invariant';
3 |
4 | import styles from './Dialog.module.css';
5 | import Button, { ButtonProps } from './Button';
6 | import CloseButton from './CloseButton';
7 |
8 |
9 | type Props = Omit, HTMLDialogElement>, 'open'> & {
10 | autoOpen?: boolean | undefined,
11 | };
12 |
13 | // eslint-disable-next-line react/display-name
14 | const Dialog = forwardRef(({ children, autoOpen, onClose, onClick, ...props }, refArg) => {
15 | const localRef = useRef(null);
16 | const ref = refArg ?? localRef;
17 |
18 | useEffect(() => {
19 | invariant('current' in ref);
20 | // eslint-disable-next-line react/destructuring-assignment
21 | if (autoOpen) {
22 | ref.current?.showModal();
23 | }
24 | return undefined;
25 | }, [autoOpen, ref]);
26 |
27 | const handleClose = useCallback((e: React.MouseEvent) => {
28 | invariant('current' in ref);
29 | onClose?.(e);
30 | }, [onClose, ref]);
31 |
32 | const handleClick = useCallback((e: React.MouseEvent) => {
33 | if (!(ref != null && 'current' in ref && ref.current != null)) return;
34 | const dialogDimensions = ref.current.getBoundingClientRect();
35 | if (e.clientX < dialogDimensions.left
36 | || e.clientX > dialogDimensions.right
37 | || e.clientY < dialogDimensions.top
38 | || e.clientY > dialogDimensions.bottom) {
39 | ref.current?.close();
40 | }
41 | onClick?.(e);
42 | }, [onClick, ref]);
43 |
44 | return (
45 | // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, react/jsx-props-no-spreading
46 |
47 | {children}
48 |
49 |
52 |
53 | );
54 | });
55 |
56 | export const ConfirmButton = ({ style, ...props }: ButtonProps) => (
57 | // eslint-disable-next-line react/jsx-props-no-spreading
58 |
59 | );
60 |
61 |
62 | export default Dialog;
63 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ExportButton.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { FiScissors } from 'react-icons/fi';
3 | import { FaFileExport } from 'react-icons/fa';
4 | import { useTranslation } from 'react-i18next';
5 |
6 | import { primaryColor } from '../colors';
7 | import useUserSettings from '../hooks/useUserSettings';
8 | import { SegmentToExport } from '../types';
9 |
10 |
11 | function ExportButton({ segmentsToExport, areWeCutting, onClick, size = 1 }: {
12 | segmentsToExport: SegmentToExport[],
13 | areWeCutting: boolean,
14 | onClick: () => void,
15 | size?: number | undefined,
16 | }) {
17 | const CutIcon = areWeCutting ? FiScissors : FaFileExport;
18 |
19 | const { t } = useTranslation();
20 |
21 | const { autoMerge } = useUserSettings();
22 |
23 | let title = t('Export');
24 | if (segmentsToExport.length === 1) {
25 | title = t('Export selection');
26 | } else if (segmentsToExport.length > 1) {
27 | title = t('Export {{ num }} segments', { num: segmentsToExport.length });
28 | }
29 |
30 | const text = autoMerge && segmentsToExport && segmentsToExport.length > 1 ? t('Export+merge') : t('Export');
31 |
32 | return (
33 |
40 |
44 | {text}
45 |
46 | );
47 | }
48 |
49 | export default memo(ExportButton);
50 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ExportConfirm.module.css:
--------------------------------------------------------------------------------
1 | table.options {
2 | width: 100%;
3 | }
4 |
5 | table.options td:nth-child(2) {
6 | text-align: right;
7 | }
8 |
9 | table.options td:last-child {
10 | text-align: center;
11 | width: 1.7em;
12 | }
13 |
14 | table.options td {
15 | vertical-align: top;
16 | }
17 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ExportDialog.module.css:
--------------------------------------------------------------------------------
1 | .sheet {
2 | position: fixed;
3 | left: 0;
4 | right: 0;
5 | top: 0;
6 | bottom: 0;
7 | background: var(--white-a11);
8 | color: var(--gray-12);
9 | backdrop-filter: blur(30px);
10 | overflow-y: scroll;
11 | display: flex;
12 | justify-content: center;
13 | align-items: flex-start;
14 | }
15 |
16 | :global(.dark-theme) .sheet {
17 | background: var(--black-a11);
18 | }
19 |
20 | .box {
21 | margin: 15px 15px 50px 15px;
22 | border-radius: 10px;
23 | padding: 10px 20px;
24 | min-height: 500px;
25 | position: relative;
26 | max-width: 100%;
27 | background: var(--white-a11);
28 | }
29 |
30 | :global(.dark-theme) .box {
31 | background: var(--black-a11);
32 | }
33 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ExportDialog.tsx:
--------------------------------------------------------------------------------
1 | import { CSSProperties, ReactNode } from 'react';
2 | import { motion, AnimatePresence } from 'framer-motion';
3 |
4 | import styles from './ExportDialog.module.css';
5 | import CloseButton from './CloseButton';
6 |
7 | function ExportDialog({
8 | visible,
9 | children,
10 | renderBottom,
11 | renderButton,
12 | onClosePress,
13 | title,
14 | width,
15 | } : {
16 | visible: boolean,
17 | renderBottom?: (() => ReactNode | null) | undefined,
18 | renderButton?: (() => ReactNode | null) | undefined,
19 | children: ReactNode,
20 | onClosePress: () => void,
21 | title: string,
22 | width: CSSProperties['width'],
23 | }) {
24 | // https://stackoverflow.com/questions/33454533/cant-scroll-to-top-of-flex-item-that-is-overflowing-container
25 | return (
26 |
27 | {visible && (
28 | <>
29 |
36 |
37 |
{title}
38 |
39 |
40 |
41 | {children}
42 |
43 |
44 |
45 |
46 |
53 | {renderBottom?.()}
54 |
55 |
56 |
63 | {renderButton?.()}
64 |
65 |
66 | >
67 | )}
68 |
69 | );
70 | }
71 |
72 | export default ExportDialog;
73 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ExportModeButton.tsx:
--------------------------------------------------------------------------------
1 | import { CSSProperties, memo, useMemo } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 |
4 | import { withBlur } from '../util';
5 | import useUserSettings from '../hooks/useUserSettings';
6 | import Select from './Select';
7 | import { ExportMode } from '../types';
8 |
9 |
10 | function ExportModeButton({ selectedSegments, style }: { selectedSegments: unknown[], style?: CSSProperties }) {
11 | const { t } = useTranslation();
12 |
13 | const { effectiveExportMode, setAutoMerge, setAutoDeleteMergedSegments, setSegmentsToChaptersOnly } = useUserSettings();
14 |
15 | function onChange(newMode: ExportMode) {
16 | switch (newMode) {
17 | case 'segments_to_chapters': {
18 | setAutoMerge(false);
19 | setAutoDeleteMergedSegments(false);
20 | setSegmentsToChaptersOnly(true);
21 | break;
22 | }
23 | case 'merge': {
24 | setAutoMerge(true);
25 | setAutoDeleteMergedSegments(true);
26 | setSegmentsToChaptersOnly(false);
27 | break;
28 | }
29 | case 'merge+separate': {
30 | setAutoMerge(true);
31 | setAutoDeleteMergedSegments(false);
32 | setSegmentsToChaptersOnly(false);
33 | break;
34 | }
35 | case 'separate': {
36 | setAutoMerge(false);
37 | setAutoDeleteMergedSegments(false);
38 | setSegmentsToChaptersOnly(false);
39 | break;
40 | }
41 | default:
42 | }
43 | }
44 |
45 | const selectableModes = useMemo(() => [
46 | 'separate' as const,
47 | ...(selectedSegments.length >= 2 || effectiveExportMode === 'merge' ? ['merge'] as const : []),
48 | ...(selectedSegments.length >= 2 || effectiveExportMode === 'merge+separate' ? ['merge+separate'] as const : []),
49 | 'segments_to_chapters' as const,
50 | ], [effectiveExportMode, selectedSegments.length]);
51 |
52 | return (
53 | // eslint-disable-next-line react/jsx-props-no-spreading
54 | onChange(e.target.value as ExportMode))}
58 | >
59 | {t('Export mode')}
60 |
61 | {selectableModes.map((mode) => {
62 | const titles = {
63 | segments_to_chapters: t('Segments to chapters'),
64 | merge: t('Merge cuts'),
65 | 'merge+separate': t('Merge & Separate'),
66 | separate: t('Separate files'),
67 | description: t('Export to separate files'),
68 | };
69 |
70 | const title = titles[mode];
71 |
72 | return (
73 | {title}
74 | );
75 | })}
76 |
77 | );
78 | }
79 |
80 | export default memo(ExportModeButton);
81 |
--------------------------------------------------------------------------------
/src/renderer/src/components/HighlightedText.tsx:
--------------------------------------------------------------------------------
1 | import { CSSProperties, HTMLAttributes, memo } from 'react';
2 |
3 | import { primaryTextColor } from '../colors';
4 |
5 | export const highlightedTextStyle: CSSProperties = { textDecoration: 'underline', textUnderlineOffset: '.2em', textDecorationColor: primaryTextColor, color: 'var(--gray-12)', borderRadius: '.4em' };
6 |
7 | function HighlightedText({ children, style, ...props }: HTMLAttributes) {
8 | // eslint-disable-next-line react/jsx-props-no-spreading
9 | return {children} ;
10 | }
11 |
12 | export default memo(HighlightedText);
13 |
--------------------------------------------------------------------------------
/src/renderer/src/components/OutputFormatSelect.tsx:
--------------------------------------------------------------------------------
1 | import { CSSProperties, memo, useMemo } from 'react';
2 | import i18n from 'i18next';
3 |
4 | import allOutFormats from '../outFormats';
5 | import { withBlur } from '../util';
6 | import Select from './Select';
7 |
8 | const commonVideoAudioFormats = ['matroska', 'mov', 'mp4', 'mpegts', 'ogv', 'webm'];
9 | const commonAudioFormats = ['flac', 'ipod', 'mp3', 'oga', 'ogg', 'opus', 'wav'];
10 | const commonSubtitleFormats = ['ass', 'srt', 'sup', 'webvtt'];
11 |
12 | function renderFormatOptions(formats: string[]) {
13 | return formats.map((format) => (
14 | {format} - {(allOutFormats as Record)[format]}
15 | ));
16 | }
17 |
18 | function OutputFormatSelect({ style, detectedFileFormat, fileFormat, onOutputFormatUserChange }: {
19 | style: CSSProperties, detectedFileFormat?: string | undefined, fileFormat?: string | undefined, onOutputFormatUserChange: (a: string) => void,
20 | }) {
21 | const commonVideoAudioFormatsExceptDetectedFormat = useMemo(() => commonVideoAudioFormats.filter((f) => f !== detectedFileFormat), [detectedFileFormat]);
22 | const commonAudioFormatsExceptDetectedFormat = useMemo(() => commonAudioFormats.filter((f) => f !== detectedFileFormat), [detectedFileFormat]);
23 | const commonSubtitleFormatsExceptDetectedFormat = useMemo(() => commonSubtitleFormats.filter((f) => f !== detectedFileFormat), [detectedFileFormat]);
24 | const commonFormatsAndDetectedFormat = useMemo(() => new Set([...commonVideoAudioFormats, ...commonAudioFormats, commonSubtitleFormats, detectedFileFormat]), [detectedFileFormat]);
25 |
26 | const otherFormats = useMemo(() => Object.keys(allOutFormats).filter((format) => !commonFormatsAndDetectedFormat.has(format)), [commonFormatsAndDetectedFormat]);
27 |
28 | return (
29 | // eslint-disable-next-line react/jsx-props-no-spreading
30 | onOutputFormatUserChange(e.target.value))}>
31 | {i18n.t('Output container format:')}
32 |
33 | {detectedFileFormat && (
34 |
35 | {detectedFileFormat} - {(allOutFormats as Record)[detectedFileFormat]} {i18n.t('(detected)')}
36 |
37 | )}
38 |
39 | --- {i18n.t('Common video/audio formats:')} ---
40 | {renderFormatOptions(commonVideoAudioFormatsExceptDetectedFormat)}
41 |
42 | --- {i18n.t('Common audio formats:')} ---
43 | {renderFormatOptions(commonAudioFormatsExceptDetectedFormat)}
44 |
45 | --- {i18n.t('Common subtitle formats:')} ---
46 | {renderFormatOptions(commonSubtitleFormatsExceptDetectedFormat)}
47 |
48 | --- {i18n.t('All other formats:')} ---
49 | {renderFormatOptions(otherFormats)}
50 |
51 | );
52 | }
53 |
54 | export default memo(OutputFormatSelect);
55 |
--------------------------------------------------------------------------------
/src/renderer/src/components/PlaybackStreamSelector.module.css:
--------------------------------------------------------------------------------
1 | .wrapper {
2 | display: flex;
3 | margin: 0 .3em;
4 | padding: .5em;
5 | border-radius: .3em;
6 | color: var(--gray-12);
7 | background: var(--white-a8);
8 | box-shadow: 0 0 .3em .3em var(--white-a5);
9 | backdrop-filter: blur(7px);
10 | }
11 |
12 | :global(.dark-theme) .wrapper {
13 | background: var(--black-a8);
14 | box-shadow: 0 0 .3em .3em var(--black-a5);
15 | }
16 |
--------------------------------------------------------------------------------
/src/renderer/src/components/SegmentCutpointButton.tsx:
--------------------------------------------------------------------------------
1 | import { CSSProperties, useMemo } from 'react';
2 | import { FaStepForward } from 'react-icons/fa';
3 |
4 | import { useSegColors } from '../contexts';
5 | import useUserSettings from '../hooks/useUserSettings';
6 | import { SegmentColorIndex } from '../types';
7 |
8 | const SegmentCutpointButton = ({ currentCutSeg, side, Icon, onClick, title, style }: {
9 | currentCutSeg: SegmentColorIndex | undefined,
10 | side: 'start' | 'end',
11 | Icon: typeof FaStepForward,
12 | onClick?: (() => void) | undefined,
13 | title?: string | undefined,
14 | style?: CSSProperties | undefined,
15 | }) => {
16 | const { darkMode } = useUserSettings();
17 | const { getSegColor } = useSegColors();
18 | const segColor = useMemo(() => getSegColor(currentCutSeg), [currentCutSeg, getSegColor]);
19 |
20 | const start = side === 'start';
21 | const border = `3px solid ${segColor.desaturate(0.6).lightness(darkMode ? 45 : 35).string()}`;
22 | const backgroundColor = segColor.desaturate(0.6).lightness(darkMode ? 35 : 55).string();
23 |
24 | return (
25 |
32 | );
33 | };
34 |
35 | export default SegmentCutpointButton;
36 |
--------------------------------------------------------------------------------
/src/renderer/src/components/Select.module.css:
--------------------------------------------------------------------------------
1 | .select {
2 | appearance: none;
3 | font: inherit;
4 | line-height: 120%;
5 | font-size: .8em;
6 | background-color: var(--gray-3);
7 | color: var(--gray-12);
8 | border-radius: .3em;
9 | padding: 0 1.2em 0 .3em;
10 | outline: .05em solid var(--gray-8);
11 | border: .05em solid var(--gray-7);
12 |
13 | background-image: url("data:image/svg+xml;utf8, ");
14 | background-repeat: no-repeat;
15 | background-position-x: 100%;
16 | background-position-y: 0;
17 | background-size: auto 100%;
18 | }
19 |
20 | :global(.dark-theme) .select {
21 | background-image: url("data:image/svg+xml;utf8, ");
22 | }
--------------------------------------------------------------------------------
/src/renderer/src/components/Select.tsx:
--------------------------------------------------------------------------------
1 | import { SelectHTMLAttributes, memo } from 'react';
2 |
3 | import styles from './Select.module.css';
4 |
5 |
6 | function Select(props: SelectHTMLAttributes) {
7 | return (
8 | // eslint-disable-next-line react/jsx-props-no-spreading
9 |
10 | );
11 | }
12 |
13 | export default memo(Select);
14 |
--------------------------------------------------------------------------------
/src/renderer/src/components/SetCutpointButton.tsx:
--------------------------------------------------------------------------------
1 | import { CSSProperties } from 'react';
2 | import { FaHandPointUp } from 'react-icons/fa';
3 |
4 | import SegmentCutpointButton from './SegmentCutpointButton';
5 | import { mirrorTransform } from '../util';
6 | import { SegmentColorIndex } from '../types';
7 |
8 | // constant side because we are mirroring
9 | const SetCutpointButton = ({ currentCutSeg, side, title, onClick, style }: {
10 | currentCutSeg: SegmentColorIndex | undefined,
11 | side: 'start' | 'end',
12 | title?: string,
13 | onClick?: () => void,
14 | style?: CSSProperties,
15 | }) => (
16 |
17 | );
18 |
19 | export default SetCutpointButton;
20 |
--------------------------------------------------------------------------------
/src/renderer/src/components/Settings.module.css:
--------------------------------------------------------------------------------
1 | .settings td {
2 | vertical-align: top;
3 | }
4 |
5 | .settings td:first-child, .settings th:first-child {
6 | padding: 1em 2em 1em 2em;
7 | }
8 | .settings td:nth-child(2), .settings th:nth-child(2) {
9 | padding: 1em 2em 1em 0em;
10 | }
11 |
12 | .settings th {
13 | text-align: left;
14 | }
15 |
16 | .settings tr.header {
17 | background-color: var(--black-a3);
18 | }
19 |
20 | :global(.dark-theme) .settings tr.header {
21 | background-color: var(--white-a3);
22 | }
23 |
24 | .settings {
25 | border-collapse: collapse;
26 | }
27 |
--------------------------------------------------------------------------------
/src/renderer/src/components/Sheet.module.css:
--------------------------------------------------------------------------------
1 | .sheet {
2 | position: fixed;
3 | left: 0;
4 | right: 0;
5 | top: 0;
6 | bottom: 0;
7 | background: var(--white-a11);
8 | color: var(--gray-12);
9 | backdrop-filter: blur(30px);
10 |
11 | h1 {
12 | text-transform: uppercase;
13 | font-size: 1.3em;
14 | }
15 | }
16 |
17 | :global(.dark-theme) .sheet {
18 | background: var(--black-a11);
19 | }
20 |
--------------------------------------------------------------------------------
/src/renderer/src/components/Sheet.tsx:
--------------------------------------------------------------------------------
1 | import { CSSProperties, ReactNode, memo } from 'react';
2 | import { motion, AnimatePresence } from 'framer-motion';
3 |
4 | import styles from './Sheet.module.css';
5 | import CloseButton from './CloseButton';
6 |
7 |
8 | function Sheet({ visible, onClosePress, children, maxWidth = 800, style }: {
9 | visible: boolean,
10 | onClosePress: () => void,
11 | children: ReactNode,
12 | maxWidth?: number | string,
13 | style?: CSSProperties,
14 | }) {
15 | return (
16 |
17 | {visible && (
18 |
24 |
25 |
26 | {children}
27 |
28 |
29 |
30 |
31 |
32 | )}
33 |
34 | );
35 | }
36 |
37 | export default memo(Sheet);
38 |
--------------------------------------------------------------------------------
/src/renderer/src/components/SimpleModeButton.tsx:
--------------------------------------------------------------------------------
1 | import { CSSProperties, memo } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import { FaBaby } from 'react-icons/fa';
4 |
5 | import { primaryTextColor } from '../colors';
6 | import useUserSettings from '../hooks/useUserSettings';
7 |
8 |
9 | function SimpleModeButton({ size = 20, style }: { size?: number, style: CSSProperties }) {
10 | const { t } = useTranslation();
11 | const { simpleMode, toggleSimpleMode } = useUserSettings();
12 |
13 | return (
14 |
20 | );
21 | }
22 |
23 | export default memo(SimpleModeButton);
24 |
--------------------------------------------------------------------------------
/src/renderer/src/components/Switch.module.css:
--------------------------------------------------------------------------------
1 | .SwitchRoot {
2 | all: unset;
3 | width: 42px;
4 | height: 25px;
5 | background-color: var(--gray-9);
6 | border-radius: 9999px;
7 | position: relative;
8 | box-shadow: 0 0 0 2px var(--black-a5);
9 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
10 | }
11 | .SwitchRoot:focus {
12 | box-shadow: 0 0 0 2px var(--gray-11);
13 | }
14 | .SwitchRoot[data-state='checked'] {
15 | background-color: var(--cyan-9);
16 | }
17 | .SwitchRoot[data-state='checked']:focus {
18 | box-shadow: 0 0 0 2px var(--cyan-11);
19 | }
20 |
21 | .SwitchThumb {
22 | display: block;
23 | width: 21px;
24 | height: 21px;
25 | background-color: white;
26 | border-radius: 9999px;
27 | box-shadow: 0 2px 2px rgba(0,0,0,0.2);
28 | transition: transform 100ms;
29 | transform: translateX(2px);
30 | will-change: transform;
31 | }
32 | .SwitchThumb[data-state='checked'] {
33 | transform: translateX(19px);
34 | }
35 | .SwitchRoot:disabled {
36 | opacity: .5;
37 | }
38 |
--------------------------------------------------------------------------------
/src/renderer/src/components/Switch.tsx:
--------------------------------------------------------------------------------
1 | import { RefAttributes } from 'react';
2 | import * as RadixSwitch from '@radix-ui/react-switch';
3 |
4 | import classes from './Switch.module.css';
5 |
6 | const Switch = (props: RadixSwitch.SwitchProps & RefAttributes) => (
7 | // eslint-disable-next-line react/jsx-props-no-spreading
8 |
9 |
10 |
11 | );
12 |
13 | export default Switch;
14 |
--------------------------------------------------------------------------------
/src/renderer/src/components/TextInput.tsx:
--------------------------------------------------------------------------------
1 | import { CSSProperties, forwardRef } from 'react';
2 |
3 |
4 | const inputStyle: CSSProperties = { borderRadius: '.4em', flexGrow: 1, fontFamily: 'inherit', fontSize: '.8em', backgroundColor: 'var(--gray-3)', color: 'var(--gray-12)', border: '1px solid var(--gray-7)', appearance: 'none' };
5 |
6 | // eslint-disable-next-line react/display-name
7 | const TextInput = forwardRef(({ style, ...props }, forwardedRef) => (
8 | // eslint-disable-next-line react/jsx-props-no-spreading
9 |
10 | ));
11 |
12 | export default TextInput;
13 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ToggleExportConfirm.tsx:
--------------------------------------------------------------------------------
1 | import { CSSProperties, memo } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 |
4 | import { MdEventNote } from 'react-icons/md';
5 |
6 | import { primaryTextColor } from '../colors';
7 | import useUserSettings from '../hooks/useUserSettings';
8 |
9 |
10 | function ToggleExportConfirm({ size = 23, style }: { size?: string | number | undefined, style?: CSSProperties }) {
11 | const { t } = useTranslation();
12 | const { exportConfirmEnabled, toggleExportConfirmEnabled } = useUserSettings();
13 |
14 | return (
15 |
16 | );
17 | }
18 |
19 | export default memo(ToggleExportConfirm);
20 |
--------------------------------------------------------------------------------
/src/renderer/src/components/Truncated.tsx:
--------------------------------------------------------------------------------
1 | import { CSSProperties, DetailedHTMLProps, HTMLAttributes } from 'react';
2 |
3 | export default function Truncated({ maxWidth, style, ...props }: DetailedHTMLProps, HTMLDivElement> & { maxWidth: CSSProperties['maxWidth'] }) {
4 | return (
5 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ValueTuner.module.css:
--------------------------------------------------------------------------------
1 | .value-tuner {
2 | min-width: 20em;
3 | position: absolute;
4 | bottom: 0;
5 | padding: 1.2em;
6 | margin: 1em;
7 | border-radius: 1em;
8 | color: var(--gray-12);
9 | background: var(--white-a7);
10 | box-shadow: 0 0 .3em .3em var(--white-a8);
11 | backdrop-filter: blur(10px);
12 | }
13 |
14 | :global(.dark-theme) .value-tuner {
15 | background: var(--black-a7);
16 | box-shadow: 0 0 .3em .3em var(--black-a8);
17 | }
18 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ValueTuner.tsx:
--------------------------------------------------------------------------------
1 | import { memo, useState, useCallback, ChangeEventHandler } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 |
4 | import styles from './ValueTuner.module.css';
5 |
6 | import Switch from './Switch';
7 | import Button from './Button';
8 |
9 |
10 | function ValueTuner({ title, value, setValue, onFinished, resolution, decimals, min: minIn = 0, max: maxIn = 1, resetToDefault }: {
11 | title: string,
12 | value: number,
13 | setValue: (v: number) => void,
14 | onFinished: () => void,
15 | resolution: number,
16 | decimals: number,
17 | min?: number,
18 | max?: number,
19 | resetToDefault: () => void,
20 | }) {
21 | const { t } = useTranslation();
22 |
23 | const [min, setMin] = useState(minIn);
24 | const [max, setMax] = useState(maxIn);
25 |
26 | const onChange = useCallback>((e) => {
27 | e.target.blur();
28 | setValue(Math.min(Math.max(min, ((Number(e.target.value) / resolution) * (max - min)) + min), max));
29 | }, [max, min, resolution, setValue]);
30 |
31 | const isZoomed = !(min === minIn && max === maxIn);
32 |
33 | const resetZoom = useCallback(() => {
34 | setMin(minIn);
35 | setMax(maxIn);
36 | }, [maxIn, minIn]);
37 |
38 | const toggleZoom = useCallback(() => {
39 | if (isZoomed) {
40 | resetZoom();
41 | } else {
42 | const zoomWindow = (maxIn - minIn) / 100;
43 | setMin(Math.max(minIn, value - zoomWindow));
44 | setMax(Math.min(maxIn, value + zoomWindow));
45 | }
46 | }, [isZoomed, maxIn, minIn, resetZoom, value]);
47 |
48 | const handleResetToDefaultClick = useCallback(() => {
49 | resetToDefault();
50 | resetZoom();
51 | }, [resetToDefault, resetZoom]);
52 |
53 | return (
54 |
55 |
56 |
{title}
57 |
{value.toFixed(decimals)}
58 |
{t('Precise')}
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | {t('Default')}
67 | {t('Done')}
68 |
69 |
70 | );
71 | }
72 |
73 | export default memo(ValueTuner);
74 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ValueTuners.tsx:
--------------------------------------------------------------------------------
1 | import { memo, useCallback } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 |
4 | import ValueTuner from './ValueTuner';
5 | import useUserSettings from '../hooks/useUserSettings';
6 | import { TunerType } from '../types';
7 |
8 |
9 | function ValueTuners({ type, onFinished }: { type: TunerType, onFinished: () => void }) {
10 | const { t } = useTranslation();
11 | const { wheelSensitivity, setWheelSensitivity, keyboardNormalSeekSpeed, keyboardSeekSpeed2, setKeyboardSeekSpeed2, keyboardSeekSpeed3, setKeyboardSeekSpeed3, setKeyboardNormalSeekSpeed, keyboardSeekAccFactor, setKeyboardSeekAccFactor, waveformHeight, setWaveformHeight } = useUserSettings();
12 |
13 | // NOTE default values are duplicated in src/main/configStore.js
14 | const types = {
15 | wheelSensitivity: {
16 | title: t('Timeline trackpad/wheel sensitivity'),
17 | value: wheelSensitivity,
18 | setValue: setWheelSensitivity,
19 | min: 0,
20 | max: 4,
21 | resolution: 1000,
22 | decimals: 4,
23 | default: 0.2,
24 | },
25 | waveformHeight: {
26 | title: t('Waveform height'),
27 | value: waveformHeight,
28 | setValue: setWaveformHeight,
29 | min: 20,
30 | max: 1000,
31 | resolution: 1000 - 20,
32 | decimals: 0,
33 | default: 40,
34 | },
35 | keyboardNormalSeekSpeed: {
36 | title: t('Timeline keyboard seek interval'),
37 | value: keyboardNormalSeekSpeed,
38 | setValue: setKeyboardNormalSeekSpeed,
39 | min: 0,
40 | max: 120,
41 | resolution: 1000,
42 | decimals: 4,
43 | default: 1,
44 | },
45 | keyboardSeekSpeed2: {
46 | title: t('Timeline keyboard seek interval (longer)'),
47 | value: keyboardSeekSpeed2,
48 | setValue: setKeyboardSeekSpeed2,
49 | min: 0,
50 | max: 600,
51 | resolution: 1000,
52 | decimals: 4,
53 | default: 10,
54 | },
55 | keyboardSeekSpeed3: {
56 | title: t('Timeline keyboard seek interval (longest)'),
57 | value: keyboardSeekSpeed3,
58 | setValue: setKeyboardSeekSpeed3,
59 | min: 0,
60 | max: 3600,
61 | resolution: 1000,
62 | decimals: 4,
63 | default: 60,
64 | },
65 | keyboardSeekAccFactor: {
66 | title: t('Timeline keyboard seek acceleration'),
67 | value: keyboardSeekAccFactor,
68 | setValue: setKeyboardSeekAccFactor,
69 | min: 1,
70 | max: 2,
71 | resolution: 1000,
72 | decimals: 4,
73 | default: 1.03,
74 | },
75 | };
76 | const { title, value, setValue, min, max, resolution, decimals, default: defaultValue } = types[type];
77 |
78 | const resetToDefault = useCallback(() => setValue(defaultValue), [defaultValue, setValue]);
79 |
80 | return ;
81 | }
82 |
83 |
84 | export default memo(ValueTuners);
85 |
--------------------------------------------------------------------------------
/src/renderer/src/components/VolumeControl.tsx:
--------------------------------------------------------------------------------
1 | import { memo, useState, useCallback, useRef, useEffect, ChangeEventHandler } from 'react';
2 | import { FaVolumeMute, FaVolumeUp } from 'react-icons/fa';
3 | import { useTranslation } from 'react-i18next';
4 |
5 |
6 | function VolumeControl({ playbackVolume, setPlaybackVolume, onToggleMutedClick }: {
7 | playbackVolume: number,
8 | setPlaybackVolume: (a: number) => void,
9 | onToggleMutedClick: () => void,
10 | }) {
11 | const [volumeControlVisible, setVolumeControlVisible] = useState(false);
12 | const timeoutRef = useRef();
13 | const { t } = useTranslation();
14 |
15 | useEffect(() => {
16 | const clear = () => clearTimeout(timeoutRef.current);
17 | clear();
18 | timeoutRef.current = window.setTimeout(() => setVolumeControlVisible(false), 4000);
19 | return () => clear();
20 | }, [playbackVolume, volumeControlVisible]);
21 |
22 | const onVolumeChange = useCallback>((e) => {
23 | e.target.blur();
24 | setPlaybackVolume(Number(e.target.value) / 100);
25 | }, [setPlaybackVolume]);
26 |
27 | const onVolumeIconClick = useCallback(() => {
28 | if (volumeControlVisible) {
29 | onToggleMutedClick();
30 | } else {
31 | setVolumeControlVisible(true);
32 | }
33 | }, [onToggleMutedClick, volumeControlVisible]);
34 |
35 | const VolumeIcon = playbackVolume === 0 ? FaVolumeMute : FaVolumeUp;
36 |
37 | return (
38 | <>
39 | {volumeControlVisible && (
40 |
48 | )}
49 |
50 |
57 | >
58 | );
59 | }
60 |
61 | export default memo(VolumeControl);
62 |
--------------------------------------------------------------------------------
/src/renderer/src/components/Warning.tsx:
--------------------------------------------------------------------------------
1 | import { DetailedHTMLProps, HTMLAttributes, useMemo } from 'react';
2 |
3 |
4 | export default function Warning({ style, ...props }: DetailedHTMLProps, HTMLDivElement>) {
5 | const mergedStyle = useMemo(() => ({ color: 'var(--orange-8)', ...style }), [style]);
6 | // eslint-disable-next-line react/jsx-props-no-spreading
7 | return
;
8 | }
9 |
--------------------------------------------------------------------------------
/src/renderer/src/components/Working.module.css:
--------------------------------------------------------------------------------
1 | .wrapper {
2 | background: var(--white-a4);
3 | }
4 |
5 | :global(.dark-theme) .wrapper {
6 | background: var(--black-a4);
7 | }
8 |
9 | .loader-box {
10 | max-width: 16em;
11 | min-width: 13em;
12 | border-radius: 1.5em;
13 | padding: 2.5% 1% 1.7% 1%;
14 | color: white;
15 | font-size: 14px;
16 | display: flex;
17 | flex-direction: column;
18 | align-items: center;
19 |
20 | background: var(--black-a6);
21 | box-shadow: var(--black-a6) 0 0 .7em 0;
22 | border: 1px solid var(--white-a5);
23 | backdrop-filter: blur(7px);
24 | }
25 |
--------------------------------------------------------------------------------
/src/renderer/src/components/Working.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { motion } from 'framer-motion';
3 | import Lottie from 'react-lottie-player/dist/LottiePlayerLight';
4 | import { Trans } from 'react-i18next';
5 |
6 | import loadingLottie from '../7077-magic-flow.json';
7 | import Button from './Button';
8 | import styles from './Working.module.css';
9 |
10 |
11 | function Working({ text, progress, onAbortClick }: {
12 | text: string,
13 | progress?: number | undefined,
14 | onAbortClick: () => void
15 | }) {
16 | return (
17 |
18 |
24 |
25 |
31 |
32 |
33 |
34 | {text}...
35 |
36 |
37 | {(progress != null) && (
38 |
39 | {`${(progress * 100).toFixed(1)} %`}
40 |
41 | )}
42 |
43 |
44 | Abort
45 |
46 |
47 |
48 | );
49 | }
50 |
51 | export default memo(Working);
52 |
--------------------------------------------------------------------------------
/src/renderer/src/contexts.ts:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react';
2 | import Color from 'color';
3 |
4 | import useUserSettingsRoot from './hooks/useUserSettingsRoot';
5 | import { ExportMode, SegmentColorIndex } from './types';
6 |
7 |
8 | export type UserSettingsContextType = ReturnType & {
9 | toggleCaptureFormat: () => void,
10 | changeOutDir: () => Promise,
11 | toggleKeyframeCut: (showMessage?: boolean) => void,
12 | toggleExportConfirmEnabled: () => void,
13 | toggleSimpleMode: () => void,
14 | toggleSafeOutputFileName: () => void,
15 | effectiveExportMode: ExportMode,
16 | }
17 |
18 | interface SegColorsContextType {
19 | getSegColor: (seg: SegmentColorIndex | undefined) => Color
20 | }
21 |
22 | export const UserSettingsContext = React.createContext(undefined);
23 | export const SegColorsContext = React.createContext(undefined);
24 |
25 | export const useSegColors = () => {
26 | const context = useContext(SegColorsContext);
27 | if (context == null) throw new Error('SegColorsContext nullish');
28 | return context;
29 | };
30 |
--------------------------------------------------------------------------------
/src/renderer/src/dialogs/html5ify.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useCallback, ChangeEventHandler } from 'react';
2 | import i18n from 'i18next';
3 |
4 | import { ReactSwal } from '../swal';
5 | import { Html5ifyMode } from '../../../../types';
6 | import Checkbox from '../components/Checkbox';
7 |
8 |
9 | // eslint-disable-next-line import/prefer-default-export
10 | export async function askForHtml5ifySpeed({ allowedOptions, showRemember, initialOption }: {
11 | allowedOptions: Html5ifyMode[],
12 | showRemember?: boolean | undefined,
13 | initialOption?: Html5ifyMode | undefined,
14 | }) {
15 | const availOptions: Record = {
16 | fastest: i18n.t('Fastest: FFmpeg-assisted playback'),
17 | fast: i18n.t('Fast: Full quality remux (no audio), likely to fail'),
18 | 'fast-audio-remux': i18n.t('Fast: Full quality remux, likely to fail'),
19 | 'fast-audio': i18n.t('Fast: Remux video, encode audio (fails if unsupported video codec)'),
20 | slow: i18n.t('Slow: Low quality encode (no audio)'),
21 | 'slow-audio': i18n.t('Slow: Low quality encode'),
22 | slowest: i18n.t('Slowest: High quality encode'),
23 | };
24 | const inputOptions: Partial> = {};
25 | allowedOptions.forEach((allowedOption) => {
26 | inputOptions[allowedOption] = availOptions[allowedOption];
27 | });
28 |
29 | let selectedOption: Html5ifyMode = initialOption != null && inputOptions[initialOption] ? initialOption : Object.keys(inputOptions)[0]! as Html5ifyMode;
30 | let rememberChoice = !!initialOption;
31 |
32 | function AskForHtml5ifySpeed() {
33 | const [option, setOption] = useState(selectedOption);
34 | const [remember, setRemember] = useState(rememberChoice);
35 |
36 | const onOptionChange = useCallback>((e) => {
37 | selectedOption = e.currentTarget.value as Html5ifyMode;
38 | setOption(selectedOption);
39 | }, []);
40 |
41 | const onRememberChange = useCallback((checked: boolean) => {
42 | rememberChoice = checked;
43 | setRemember(rememberChoice);
44 | }, []);
45 |
46 | return (
47 |
48 |
{i18n.t('These options will let you convert files to a format that is supported by the player. You can try different options and see which works with your file. Note that the conversion is for preview only. When you run an export, the output will still be lossless with full quality')}
49 |
50 | {Object.entries(inputOptions).map(([value, label]) => {
51 | const id = `html5ify-${value}`;
52 | return (
53 |
54 |
62 | {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
63 | {label}
64 |
65 | );
66 | })}
67 |
68 | {showRemember &&
}
69 |
70 | );
71 | }
72 |
73 | const { value: response } = await ReactSwal.fire({
74 | title: i18n.t('Convert to supported format'),
75 | html: ,
76 | showCancelButton: true,
77 | });
78 |
79 | return {
80 | selectedOption: response != null ? selectedOption : undefined,
81 | remember: rememberChoice,
82 | };
83 | }
84 |
--------------------------------------------------------------------------------
/src/renderer/src/edl.test.ts:
--------------------------------------------------------------------------------
1 | import { it, expect } from 'vitest';
2 |
3 | import { readFixture } from './test/util';
4 | import { parseEdlCmx3600 } from './edlFormats';
5 |
6 |
7 | it('parseEdlCmx3600', async () => {
8 | const fps = 30;
9 | expect(await parseEdlCmx3600(await readFixture('edl/12_16 TL01 MUSIC.edl'), fps)).toMatchSnapshot();
10 | expect(await parseEdlCmx3600(await readFixture('edl/070816_EG101_HEISTS_ROUGH_CUT_SOURCES_PART 1.edl'), fps)).toMatchSnapshot();
11 | expect(await parseEdlCmx3600(await readFixture('edl/cmx3600_5994.edl'), fps)).toMatchSnapshot();
12 | expect(await parseEdlCmx3600(await readFixture('edl/cmx3600.edl'), fps)).toMatchSnapshot();
13 | expect(await parseEdlCmx3600(await readFixture('edl/pull001_201109_exr.edl'), fps)).toMatchSnapshot();
14 | });
15 |
--------------------------------------------------------------------------------
/src/renderer/src/ffmpegParameters.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-line unicorn/filename-case
2 | import i18n from 'i18next';
3 |
4 | const parametersRaw = {
5 | blackdetect: {
6 | black_min_duration: {
7 | value: '2.0',
8 | hint: () => i18n.t('Set the minimum detected black duration expressed in seconds. It must be a non-negative floating point number.'),
9 | },
10 | picture_black_ratio_th: {
11 | value: '0.98',
12 | hint: () => i18n.t('Set the threshold for considering a picture "black".'),
13 | },
14 | pixel_black_th: {
15 | value: '0.10',
16 | hint: () => i18n.t('Set the threshold for considering a pixel "black".'),
17 | },
18 | mode: {
19 | value: '1',
20 | hint: () => i18n.t('Segment mode: "{{mode1}}" will create segments bounding the black sections. "{{mode2}}" will create segments that start/stop at the center of each black section.', { mode1: '1', mode2: '2' }),
21 | },
22 | },
23 | silencedetect: {
24 | noise: {
25 | value: '-60dB',
26 | hint: () => i18n.t('Set noise tolerance. Can be specified in dB (in case "dB" is appended to the specified value) or amplitude ratio. Default is -60dB, or 0.001.'),
27 | },
28 | duration: {
29 | value: '2.0',
30 | hint: () => i18n.t('Set minimum silence duration that will be converted into a segment.'),
31 | },
32 | mode: {
33 | value: '1',
34 | hint: () => i18n.t('Segment mode: "{{mode1}}" will create segments bounding the silent sections. "{{mode2}}" will create segments that start/stop at the center of each silent section.', { mode1: '1', mode2: '2' }),
35 | },
36 | },
37 | sceneChange: {
38 | minChange: {
39 | value: '0.3',
40 | hint: () => i18n.t('Minimum change between two frames to be considered a new scene. A value between 0.3 and 0.5 is generally a sane choice.'),
41 | },
42 | },
43 | };
44 |
45 | export type FfmpegDialog = keyof typeof parametersRaw;
46 |
47 | // widen types
48 | export const parameters: Record string, label?: string }>> = parametersRaw;
49 |
50 | export const getHint = (dialogType: FfmpegDialog, param: string) => parameters[dialogType][param]?.hint?.();
51 | export const getLabel = (dialogType: FfmpegDialog, param: string) => parameters[dialogType][param]?.label;
52 |
--------------------------------------------------------------------------------
/src/renderer/src/gps.tsx:
--------------------------------------------------------------------------------
1 | import { MapContainer, Popup, TileLayer } from 'react-leaflet';
2 | import { Marker } from '@adamscybot/react-leaflet-component-marker';
3 | import { Trans } from 'react-i18next';
4 | import 'leaflet/dist/leaflet.css';
5 | import { FaMapMarkerAlt } from 'react-icons/fa';
6 |
7 | import { extractSrtGpsTrack } from './ffmpeg';
8 | import { ReactSwal } from './swal';
9 | import { handleError } from './util';
10 | import { parseGpsLine } from './edlFormats';
11 |
12 |
13 | export default async function tryShowGpsMap(filePath: string, streamIndex: number) {
14 | try {
15 | const subtitles = await extractSrtGpsTrack(filePath, streamIndex);
16 | const gpsPoints = subtitles.flatMap((subtitle) => {
17 | const firstLine = subtitle.lines[0];
18 | const { index } = subtitle;
19 | if (firstLine == null || index == null) return [];
20 |
21 | const parsed = parseGpsLine(firstLine);
22 | if (parsed == null) return [];
23 |
24 | return [{
25 | ...parsed,
26 | index,
27 | raw: firstLine,
28 | }];
29 | });
30 | // console.log(gpsPoints)
31 |
32 | const firstPoint = gpsPoints[0];
33 |
34 | if (firstPoint == null) {
35 | throw new Error('No GPS points found');
36 | }
37 |
38 | // https://www.openstreetmap.org/copyright
39 | ReactSwal.fire({
40 | width: '100%',
41 | html: (
42 | <>
43 | GPS track
44 |
45 |
46 |
50 |
51 | {gpsPoints.map((point, i) => (
52 | }>
53 |
54 | Point {i + 1} / {gpsPoints.length}
55 | {point.raw}
56 |
57 |
58 | ))}
59 |
60 | >
61 | ),
62 | showCloseButton: true,
63 | showConfirmButton: false,
64 | });
65 | } catch (err) {
66 | handleError(err);
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/useContextMenu.ts:
--------------------------------------------------------------------------------
1 | import { RefObject, useEffect } from 'react';
2 |
3 | import useNativeMenu from './useNativeMenu';
4 | import { ContextMenuTemplate } from '../types';
5 |
6 |
7 | // https://github.com/transflow/use-electron-context-menu
8 | export default function useContextMenu(
9 | ref: RefObject,
10 | template: ContextMenuTemplate,
11 | ) {
12 | const { openMenu, closeMenu } = useNativeMenu(template);
13 |
14 | useEffect(() => {
15 | const el = ref.current;
16 | if (el) {
17 | el.addEventListener('contextmenu', openMenu);
18 | return () => el.removeEventListener('contextmenu', openMenu);
19 | }
20 | return undefined;
21 | }, [openMenu, ref]);
22 |
23 | return { closeMenu };
24 | }
25 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/useFileFormatState.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | export default () => {
4 | const [detectedFileFormat, setDetectedFileFormat] = useState();
5 | const [fileFormat, setFileFormat] = useState();
6 |
7 | const isCustomFormatSelected = fileFormat !== detectedFileFormat;
8 |
9 | return { fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, isCustomFormatSelected };
10 | };
11 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/useKeyboard.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 |
3 | // mousetrap seems to be the only lib properly handling layouts that require shift to be pressed to get a particular key #520
4 | // Also document.addEventListener needs custom handling of modifier keys or C will be triggered by CTRL+C, etc
5 | import Mousetrap from 'mousetrap';
6 |
7 | import { KeyBinding, KeyboardAction } from '../../../../types';
8 |
9 |
10 | // for all dialog actions (e.g. detectSceneChanges) we must use keyup, or we risk having the button press inserted into the dialog's input element right after the dialog opens
11 | // todo use keyup for most events?
12 | const keyupActions = new Set(['seekBackwards', 'seekForwards', 'seekBackwards2', 'seekForwards2', 'seekBackwards3', 'seekForwards3', 'detectBlackScenes', 'detectSilentScenes', 'detectSceneChanges']);
13 |
14 | interface StoredAction { action: KeyboardAction, keyup?: boolean }
15 |
16 | export default ({ keyBindings, onKeyPress: onKeyPressProp }: {
17 | keyBindings: KeyBinding[],
18 | onKeyPress: ((a: { action: KeyboardAction, keyup?: boolean | undefined }) => boolean) | ((a: { action: KeyboardAction, keyup?: boolean | undefined }) => void),
19 | }) => {
20 | const onKeyPressRef = useRef<(a: StoredAction) => void>();
21 |
22 | // optimization to prevent re-binding all the time:
23 | useEffect(() => {
24 | onKeyPressRef.current = onKeyPressProp;
25 | }, [onKeyPressProp]);
26 |
27 | useEffect(() => {
28 | const mousetrap = new Mousetrap();
29 |
30 | function onKeyPress(params: StoredAction) {
31 | if (onKeyPressRef.current) return onKeyPressRef.current(params);
32 | return true;
33 | }
34 |
35 | keyBindings.forEach(({ action, keys }) => {
36 | mousetrap.bind(keys, () => onKeyPress({ action }));
37 |
38 | if (keyupActions.has(action)) {
39 | mousetrap.bind(keys, () => onKeyPress({ action, keyup: true }), 'keyup');
40 | }
41 | });
42 |
43 | return () => {
44 | mousetrap.reset();
45 | };
46 | }, [keyBindings]);
47 | };
48 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/useKeyframes.ts:
--------------------------------------------------------------------------------
1 | import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
2 | import sortBy from 'lodash/sortBy';
3 | import useDebounceOld from 'react-use/lib/useDebounce'; // Want to phase out this
4 |
5 | import { readFramesAroundTime, findNearestKeyFrameTime as ffmpegFindNearestKeyFrameTime, Frame } from '../ffmpeg';
6 | import { FFprobeStream } from '../../../../ffprobe';
7 |
8 | const maxKeyframes = 1000;
9 | // const maxKeyframes = 100;
10 |
11 | function useKeyframes({ keyframesEnabled, filePath, commandedTime, videoStream, detectedFps, ffmpegExtractWindow }: {
12 | keyframesEnabled: boolean,
13 | filePath: string | undefined,
14 | commandedTime: number,
15 | videoStream: FFprobeStream | undefined,
16 | detectedFps: number | undefined,
17 | ffmpegExtractWindow: number,
18 | }) {
19 | const readingKeyframesPromise = useRef>();
20 | const [neighbouringKeyFramesMap, setNeighbouringKeyFrames] = useState>({});
21 | const neighbouringKeyFrames = useMemo(() => Object.values(neighbouringKeyFramesMap), [neighbouringKeyFramesMap]);
22 |
23 | const findNearestKeyFrameTime = useCallback(({ time, direction }: { time: number, direction: number }) => ffmpegFindNearestKeyFrameTime({ frames: neighbouringKeyFrames, time, direction, fps: detectedFps }), [neighbouringKeyFrames, detectedFps]);
24 |
25 | useEffect(() => setNeighbouringKeyFrames({}), [filePath, videoStream]);
26 |
27 | useDebounceOld(() => {
28 | let aborted = false;
29 |
30 | (async () => {
31 | // See getIntervalAroundTime
32 | // We still want to calculate keyframes even if not shouldShowKeyframes because maybe we want to be able to step to the closest keyframe
33 | const shouldRun = keyframesEnabled && filePath != null && videoStream && commandedTime != null && !readingKeyframesPromise.current;
34 | if (!shouldRun) return;
35 |
36 | try {
37 | const promise = readFramesAroundTime({ filePath, aroundTime: commandedTime, streamIndex: videoStream.index, window: ffmpegExtractWindow });
38 | readingKeyframesPromise.current = promise;
39 | const newFrames = await promise;
40 | if (aborted) return;
41 | const newKeyFrames = newFrames.filter((frame) => frame.keyframe);
42 | // console.log(newFrames);
43 | setNeighbouringKeyFrames((existingKeyFramesMap) => {
44 | let existingFrames = Object.values(existingKeyFramesMap);
45 | if (existingFrames.length >= maxKeyframes) {
46 | existingFrames = sortBy(existingFrames, 'createdAt').slice(newKeyFrames.length);
47 | }
48 | const toObj = (map: Frame[]) => Object.fromEntries(map.map((frame) => [frame.time, frame]));
49 | return {
50 | ...toObj(existingFrames),
51 | ...toObj(newKeyFrames),
52 | };
53 | });
54 | } catch (err) {
55 | console.error('Failed to read keyframes', err);
56 | } finally {
57 | readingKeyframesPromise.current = undefined;
58 | }
59 | })();
60 |
61 | return () => {
62 | aborted = true;
63 | };
64 | }, 500, [keyframesEnabled, filePath, commandedTime, videoStream, ffmpegExtractWindow]);
65 |
66 | return {
67 | neighbouringKeyFrames, findNearestKeyFrameTime,
68 | };
69 | }
70 |
71 | export default useKeyframes;
72 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/useLoading.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useRef, useState } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import { abortFfmpegs } from '../ffmpeg';
4 |
5 |
6 | export default () => {
7 | const { t } = useTranslation();
8 |
9 | const [working, setWorkingState] = useState<{ text: string, abortController?: AbortController | undefined } | undefined>();
10 |
11 | // Store "working" in a ref so we can avoid race conditions
12 | const workingRef = useRef(!!working);
13 |
14 | const setWorking = useCallback((valOrBool?: { text: string, abortController?: AbortController } | true | undefined) => {
15 | workingRef.current = !!valOrBool;
16 | const val = valOrBool === true ? { text: t('Loading') } : valOrBool;
17 | setWorkingState(val);
18 | }, [t]);
19 |
20 | const abortWorking = useCallback(() => {
21 | console.log('User clicked abort');
22 | abortFfmpegs(); // todo use abortcontroller for this also
23 | working?.abortController?.abort();
24 | }, [working?.abortController]);
25 |
26 | return {
27 | working,
28 | workingRef,
29 | setWorking,
30 | abortWorking,
31 | };
32 | };
33 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/useNativeMenu.ts:
--------------------------------------------------------------------------------
1 | import type { Menu as MenuType, MenuItemConstructorOptions, MenuItem } from 'electron';
2 | import { useCallback, useMemo } from 'react';
3 |
4 | // TODO pull out?
5 | const remote = window.require('@electron/remote');
6 | // eslint-disable-next-line prefer-destructuring
7 | const Menu: typeof MenuType = remote.Menu;
8 |
9 | // https://github.com/transflow/use-electron-context-menu
10 | // https://www.electronjs.org/docs/latest/api/menu-item
11 | export default function useNativeMenu(
12 | template: (MenuItemConstructorOptions | MenuItem)[],
13 | options: { x?: number, y?: number, onContext?: (e: MouseEvent) => void, onClose?: () => void } = {},
14 | ) {
15 | const menu = useMemo(() => Menu.buildFromTemplate(template), [template]);
16 |
17 | const { x, y, onContext, onClose } = options;
18 |
19 | const openMenu = useCallback((e: MouseEvent) => {
20 | // @ts-expect-error todo
21 | menu.popup({
22 | window: remote.getCurrentWindow(),
23 | x,
24 | y,
25 | callback: onClose,
26 | });
27 |
28 | if (onContext) onContext(e);
29 | }, [menu, onClose, onContext, x, y]);
30 |
31 | const closeMenu = useCallback(() => menu.closePopup(), [menu]);
32 |
33 | return { openMenu, closeMenu };
34 | }
35 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/useSegmentsAutoSave.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useMemo, useRef } from 'react';
2 | import { useDebounce } from 'use-debounce';
3 | import isEqual from 'lodash/isEqual';
4 |
5 | import isDev from '../isDev';
6 | import { saveLlcProject } from '../edlStore';
7 | import { mapSaveableSegments } from '../segments';
8 | import { getSuffixedOutPath } from '../util';
9 | import { StateSegment } from '../types';
10 | import { errorToast } from '../swal';
11 | import i18n from '../i18n';
12 |
13 |
14 | export default ({ autoSaveProjectFile, storeProjectInWorkingDir, filePath, customOutDir, cutSegments }: {
15 | autoSaveProjectFile: boolean,
16 | storeProjectInWorkingDir: boolean,
17 | filePath: string | undefined,
18 | customOutDir: string | undefined,
19 | cutSegments: StateSegment[],
20 | }) => {
21 | const projectSuffix = 'proj.llc';
22 | // New LLC format can be stored along with input file or in working dir (customOutDir)
23 | const getEdlFilePath = useCallback((fp?: string, cod?: string) => getSuffixedOutPath({ customOutDir: cod, filePath: fp, nameSuffix: projectSuffix }), []);
24 | const getProjectFileSavePath = useCallback((storeProjectInWorkingDirIn: boolean) => getEdlFilePath(filePath, storeProjectInWorkingDirIn ? customOutDir : undefined), [getEdlFilePath, filePath, customOutDir]);
25 | const projectFileSavePath = useMemo(() => getProjectFileSavePath(storeProjectInWorkingDir), [getProjectFileSavePath, storeProjectInWorkingDir]);
26 |
27 | const currentSaveOperation = useMemo(() => {
28 | if (!projectFileSavePath) return undefined;
29 | return { cutSegments, projectFileSavePath, filePath };
30 | }, [cutSegments, filePath, projectFileSavePath]);
31 |
32 | const [debouncedSaveOperation] = useDebounce(currentSaveOperation, isDev ? 2000 : 500);
33 |
34 | const lastSaveOperation = useRef();
35 |
36 | useEffect(() => {
37 | async function save() {
38 | try {
39 | // NOTE: Could lose a save if user closes too fast, but not a big issue I think
40 | if (!autoSaveProjectFile
41 | || !debouncedSaveOperation
42 | || debouncedSaveOperation.filePath == null
43 | // Don't create llc file if no segments yet, or if initial segment:
44 | || debouncedSaveOperation.cutSegments.length === 0
45 | || debouncedSaveOperation.cutSegments[0]?.initial) return;
46 |
47 | if (lastSaveOperation.current && lastSaveOperation.current.projectFileSavePath === debouncedSaveOperation.projectFileSavePath && isEqual(mapSaveableSegments(lastSaveOperation.current.cutSegments), mapSaveableSegments(debouncedSaveOperation.cutSegments))) {
48 | console.log('Segments unchanged, skipping save');
49 | return;
50 | }
51 |
52 | await saveLlcProject({ savePath: debouncedSaveOperation.projectFileSavePath, filePath: debouncedSaveOperation.filePath, cutSegments: debouncedSaveOperation.cutSegments });
53 | lastSaveOperation.current = debouncedSaveOperation;
54 | } catch (err) {
55 | errorToast(i18n.t('Unable to save project file'));
56 | console.error('Failed to save project file', err);
57 | }
58 | }
59 | save();
60 | }, [debouncedSaveOperation, autoSaveProjectFile]);
61 |
62 | return {
63 | getEdlFilePath,
64 | projectFileSavePath,
65 | getProjectFileSavePath,
66 | };
67 | };
68 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/useSubtitles.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef, useState } from 'react';
2 | import { extractSubtitleTrackVtt } from '../ffmpeg';
3 | import { FFprobeStream } from '../../../../ffprobe';
4 |
5 |
6 | export default () => {
7 | const [subtitlesByStreamId, setSubtitlesByStreamId] = useState>({});
8 |
9 | const loadSubtitle = useCallback(async ({ filePath, index, subtitleStream }: { filePath: string, index: number, subtitleStream: FFprobeStream }) => {
10 | const url = await extractSubtitleTrackVtt(filePath, index);
11 | setSubtitlesByStreamId((old) => ({ ...old, [index]: { url, lang: subtitleStream.tags && subtitleStream.tags.language } }));
12 | }, []);
13 |
14 | // Cleanup removed subtitles
15 | const subtitlesByStreamIdRef = useRef({});
16 | useEffect(() => {
17 | Object.values(subtitlesByStreamIdRef.current).forEach(({ url, lang }) => {
18 | if (!Object.values(subtitlesByStreamId).some((existingSubtitle) => existingSubtitle.url === url)) {
19 | console.log('Cleanup subtitle', lang);
20 | URL.revokeObjectURL(url);
21 | }
22 | });
23 | subtitlesByStreamIdRef.current = subtitlesByStreamId;
24 | }, [subtitlesByStreamId]);
25 |
26 | return {
27 | loadSubtitle,
28 | subtitlesByStreamId,
29 | setSubtitlesByStreamId,
30 | };
31 | };
32 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/useThumbnails.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useMemo, useState } from 'react';
2 | import { useDebounce } from 'use-debounce';
3 | import invariant from 'tiny-invariant';
4 | import sortBy from 'lodash/sortBy';
5 |
6 | import { renderThumbnails as ffmpegRenderThumbnails } from '../ffmpeg';
7 | import { Thumbnail } from '../types';
8 | import { isDurationValid } from '../segments';
9 | import { isExecaError } from '../util';
10 |
11 |
12 | export default ({ filePath, zoomedDuration, zoomWindowStartTime, showThumbnails }: {
13 | filePath: string | undefined,
14 | zoomedDuration: number | undefined,
15 | zoomWindowStartTime: number,
16 | showThumbnails: boolean,
17 | }) => {
18 | const [thumbnails, setThumbnails] = useState([]);
19 |
20 | const [debounced] = useDebounce({ zoomedDuration, filePath, zoomWindowStartTime, showThumbnails }, 300, {
21 | equalityFn: (a, b) => JSON.stringify(a) === JSON.stringify(b),
22 | });
23 |
24 | useEffect(() => {
25 | const abortController = new AbortController();
26 | const thumbnails2: Thumbnail[] = [];
27 |
28 | (async () => {
29 | if (!isDurationValid(debounced.zoomedDuration) || !debounced.showThumbnails) return;
30 |
31 | try {
32 | invariant(debounced.filePath != null);
33 | invariant(debounced.zoomedDuration != null);
34 |
35 | const addThumbnail = (t: Thumbnail) => {
36 | if (abortController.signal.aborted) return; // because the bridge is async
37 | thumbnails2.push(t);
38 | setThumbnails((v) => [...v, t]);
39 | };
40 |
41 | await ffmpegRenderThumbnails({ signal: abortController.signal, filePath: debounced.filePath, from: debounced.zoomWindowStartTime, duration: debounced.zoomedDuration, onThumbnail: addThumbnail });
42 | } catch (err) {
43 | if ((err as Error).name !== 'AbortError' && !(isExecaError(err) && err.isCanceled)) {
44 | console.error('Failed to render thumbnails', err);
45 | }
46 | }
47 | })();
48 |
49 | return () => {
50 | abortController.abort();
51 | console.log('Cleanup thumbnails', thumbnails2.map((t) => t.time));
52 | thumbnails2.forEach((thumbnail) => URL.revokeObjectURL(thumbnail.url));
53 | setThumbnails([]);
54 | };
55 | }, [debounced]);
56 |
57 | const thumbnailsSorted = useMemo(() => sortBy(thumbnails, (thumbnail) => thumbnail.time), [thumbnails]);
58 |
59 | return {
60 | thumbnailsSorted,
61 | setThumbnails,
62 | };
63 | };
64 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/useTimecode.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useMemo } from 'react';
2 | import { FormatTimecode, ParseTimecode } from '../types';
3 | import { getFrameCountRaw } from '../edlFormats';
4 | import { getFrameDuration } from '../util';
5 | import { TimecodeFormat } from '../../../../types';
6 | import { formatDuration, parseDuration } from '../util/duration';
7 |
8 |
9 | export default ({ detectedFps, timecodeFormat }: {
10 | detectedFps: number | undefined,
11 | timecodeFormat: TimecodeFormat,
12 | }) => {
13 | const getFrameCount = useCallback((sec: number) => getFrameCountRaw(detectedFps, sec), [detectedFps]);
14 | const frameCountToDuration = useCallback((frames: number) => getFrameDuration(detectedFps) * frames, [detectedFps]);
15 |
16 | const formatTimecode = useCallback(({ seconds, shorten, fileNameFriendly }) => {
17 | if (timecodeFormat === 'frameCount') {
18 | const frameCount = getFrameCount(seconds);
19 | return frameCount != null ? String(frameCount) : '';
20 | }
21 | if (timecodeFormat === 'seconds') {
22 | return seconds.toFixed(3);
23 | }
24 | if (timecodeFormat === 'timecodeWithFramesFraction') {
25 | return formatDuration({ seconds, shorten, fileNameFriendly, fps: detectedFps });
26 | }
27 | return formatDuration({ seconds, shorten, fileNameFriendly });
28 | }, [detectedFps, timecodeFormat, getFrameCount]);
29 |
30 | const timecodePlaceholder = useMemo(() => formatTimecode({ seconds: 0, shorten: false }), [formatTimecode]);
31 |
32 | const parseTimecode = useCallback((val: string) => {
33 | if (timecodeFormat === 'frameCount') {
34 | const parsed = parseInt(val, 10);
35 | return frameCountToDuration(parsed);
36 | }
37 | if (timecodeFormat === 'seconds') {
38 | return parseFloat(val);
39 | }
40 | if (timecodeFormat === 'timecodeWithFramesFraction') {
41 | return parseDuration(val, detectedFps);
42 | }
43 | return parseDuration(val);
44 | }, [detectedFps, frameCountToDuration, timecodeFormat]);
45 |
46 | const formatTimeAndFrames = useCallback((seconds: number) => {
47 | const frameCount = getFrameCount(seconds);
48 |
49 | const timeStr = timecodeFormat === 'timecodeWithFramesFraction'
50 | ? formatDuration({ seconds, fps: detectedFps })
51 | : formatDuration({ seconds });
52 |
53 | return `${timeStr} (${frameCount ?? '0'})`;
54 | }, [detectedFps, timecodeFormat, getFrameCount]);
55 |
56 | return {
57 | parseTimecode,
58 | formatTimecode,
59 | formatTimeAndFrames,
60 | timecodePlaceholder,
61 | getFrameCount,
62 | };
63 | };
64 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/useTimelineScroll.ts:
--------------------------------------------------------------------------------
1 | import { WheelEventHandler, useCallback } from 'react';
2 | import { t } from 'i18next';
3 |
4 | import normalizeWheel from './normalizeWheel';
5 | import { ModifierKey } from '../../../../types';
6 |
7 | export const keyMap = {
8 | ctrl: 'ctrlKey',
9 | shift: 'shiftKey',
10 | alt: 'altKey',
11 | meta: 'metaKey',
12 | } as const;
13 |
14 | export const getModifierKeyNames = () => ({
15 | ctrl: [t('Ctrl')],
16 | shift: [t('Shift')],
17 | alt: [t('Alt')],
18 | meta: [t('⌘ Cmd'), t('⊞ Win')],
19 | });
20 |
21 | export const getModifier = (key: ModifierKey) => getModifierKeyNames()[key];
22 |
23 | function useTimelineScroll({ wheelSensitivity, mouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, invertTimelineScroll, zoomRel, seekRel, shortStep, seekClosestKeyframe }: {
24 | wheelSensitivity: number,
25 | mouseWheelZoomModifierKey: ModifierKey,
26 | mouseWheelFrameSeekModifierKey: ModifierKey,
27 | mouseWheelKeyframeSeekModifierKey: ModifierKey,
28 | invertTimelineScroll?: boolean | undefined,
29 | zoomRel: (a: number) => void,
30 | seekRel: (a: number) => void,
31 | shortStep: (a: number) => void,
32 | seekClosestKeyframe: (a: number) => void,
33 | }) {
34 | const onWheel = useCallback>((wheelEvent) => {
35 | const { pixelX, pixelY } = normalizeWheel(wheelEvent);
36 | // console.log({ spinX, spinY, pixelX, pixelY });
37 |
38 | const direction = invertTimelineScroll ? 1 : -1;
39 |
40 | const makeUnit = (v: number) => ((direction * v) > 0 ? 1 : -1);
41 |
42 | if (wheelEvent[keyMap[mouseWheelZoomModifierKey]]) {
43 | zoomRel(direction * pixelY * wheelSensitivity * 0.4);
44 | } else if (wheelEvent[keyMap[mouseWheelFrameSeekModifierKey]]) {
45 | shortStep(makeUnit(pixelX + pixelY));
46 | } else if (wheelEvent[keyMap[mouseWheelKeyframeSeekModifierKey]]) {
47 | seekClosestKeyframe(makeUnit(pixelX + pixelY));
48 | } else {
49 | seekRel(direction * (pixelX + pixelY) * wheelSensitivity * 0.2);
50 | }
51 | }, [invertTimelineScroll, mouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, zoomRel, wheelSensitivity, shortStep, seekClosestKeyframe, seekRel]);
52 |
53 | return onWheel;
54 | }
55 |
56 | export default useTimelineScroll;
57 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/useUserSettings.ts:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react';
2 |
3 | import { UserSettingsContext } from '../contexts';
4 |
5 |
6 | export default () => {
7 | const context = useContext(UserSettingsContext);
8 | if (context == null) throw new Error('UserSettingsContext nullish');
9 | return context;
10 | };
11 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/useWhatChanged.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | // https://stackoverflow.com/questions/64997362/how-do-i-see-what-props-have-changed-in-react
4 | export default function useWhatChanged(props: Record) {
5 | // cache the last set of props
6 | const prev = React.useRef(props);
7 |
8 | React.useEffect(() => {
9 | // check each prop to see if it has changed
10 | const changed = Object.entries(props).reduce((a, [key, prop]) => {
11 | if (prev.current[key] === prop) return a;
12 | return {
13 | ...a,
14 | [key]: {
15 | prev: prev.current[key],
16 | next: prop,
17 | },
18 | };
19 | }, {});
20 |
21 | if (Object.keys(changed).length > 0) {
22 | console.group('Props That Changed');
23 | console.log(changed);
24 | console.groupEnd();
25 | }
26 |
27 | prev.current = props;
28 | }, [props]);
29 | }
30 |
--------------------------------------------------------------------------------
/src/renderer/src/i18n.ts:
--------------------------------------------------------------------------------
1 | import i18n from 'i18next';
2 | import { initReactI18next } from 'react-i18next';
3 |
4 | const Backend = window.require('i18next-fs-backend');
5 |
6 | const { i18n: { commonI18nOptions, fallbackLng, loadPath, addPath } } = window.require('@electron/remote').require('./index.js');
7 |
8 | // https://www.i18next.com/overview/typescript#argument-of-type-defaulttfuncreturn-is-not-assignable-to-parameter-of-type-xyz
9 | // todo This should not be necessary anymore since v23.0.0
10 | declare module 'i18next' {
11 | interface CustomTypeOptions {
12 | returnNull: false;
13 | }
14 | }
15 | export { fallbackLng };
16 |
17 | // https://github.com/i18next/react-i18next/blob/master/example/react/src/i18n.js
18 | // https://github.com/i18next/i18next/issues/869
19 | i18n
20 | .use(Backend)
21 | // detect user language
22 | // learn more: https://github.com/i18next/i18next-browser-languageDetector
23 | // LanguageDetector is disabled because many users are used to english, and I cannot guarantee the status of all the translations so it's best to default to engligh https://github.com/mifi/lossless-cut/issues/346
24 | // .use(LanguageDetector)
25 | // pass the i18n instance to react-i18next.
26 | .use(initReactI18next)
27 | // init i18next
28 | // for all options read: https://www.i18next.com/overview/configuration-options
29 | // See also i18next-parser.config.mjs
30 | .init({
31 | ...commonI18nOptions,
32 |
33 | backend: {
34 | loadPath,
35 | addPath,
36 | },
37 |
38 | interpolation: {
39 | escapeValue: false, // not needed for react as it escapes by default
40 | },
41 | });
42 |
43 | // eslint-disable-next-line unicorn/prefer-export-from
44 | export default i18n;
45 |
--------------------------------------------------------------------------------
/src/renderer/src/icon-mac.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/src/renderer/src/icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
7 |
10 |
12 |
13 |
17 |
18 |
19 |
24 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/src/renderer/src/index.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense, StrictMode } from 'react';
2 | import { createRoot } from 'react-dom/client';
3 | import { MotionConfig } from 'framer-motion';
4 | import { enableMapSet } from 'immer';
5 | import * as Electron from 'electron';
6 | import Remote from '@electron/remote';
7 | import type path from 'node:path';
8 | import type fsPromises from 'node:fs/promises';
9 | import type fsExtraRaw from 'fs-extra';
10 | import type mimeTypes from 'mime-types';
11 | import type i18nextFsBackend from 'i18next-fs-backend';
12 | import type cueParser from 'cue-parser';
13 |
14 | import '@fontsource/open-sans/300.css';
15 | import '@fontsource/open-sans/300-italic.css';
16 | import '@fontsource/open-sans/400.css';
17 | import '@fontsource/open-sans/400-italic.css';
18 | import '@fontsource/open-sans/500.css';
19 | import '@fontsource/open-sans/500-italic.css';
20 | import '@fontsource/open-sans/600.css';
21 | import '@fontsource/open-sans/600-italic.css';
22 | import '@fontsource/open-sans/700.css';
23 | import '@fontsource/open-sans/700-italic.css';
24 | import '@fontsource/open-sans/800.css';
25 | import '@fontsource/open-sans/800-italic.css';
26 |
27 | import type * as main from '../../main/index';
28 |
29 | import App from './App';
30 | import ErrorBoundary from './ErrorBoundary';
31 | import './i18n';
32 |
33 | import './main.css';
34 | import './swal2.scss';
35 |
36 |
37 | // something wrong with the tyep
38 | type FsExtra = typeof fsExtraRaw & { exists: (p: string) => Promise };
39 |
40 | type TypedRemote = Omit & {
41 | require: (module: T) => (
42 | T extends './index.js' ? typeof main :
43 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
44 | any
45 | );
46 | }
47 |
48 | declare global {
49 | interface Window {
50 | require: (module: T) => (
51 | T extends '@electron/remote' ? TypedRemote :
52 | T extends 'electron' ? typeof Electron :
53 | T extends 'path' ? typeof path :
54 | T extends 'node:path' ? typeof path :
55 | T extends 'fs/promises' ? typeof fsPromises :
56 | T extends 'node:fs/promises' ? typeof fsPromises :
57 | T extends 'fs-extra' ? FsExtra :
58 | T extends 'mime-types' ? typeof mimeTypes :
59 | T extends 'i18next-fs-backend' ? typeof i18nextFsBackend :
60 | T extends 'cue-parser' ? typeof cueParser :
61 | never
62 | );
63 | }
64 | }
65 |
66 |
67 | enableMapSet();
68 |
69 | const { app } = window.require('@electron/remote');
70 |
71 | console.log('Version', app.getVersion());
72 |
73 |
74 | const container = document.getElementById('root')!;
75 | const root = createRoot(container);
76 | root.render(
77 |
78 |
79 | }>
80 |
81 |
82 |
83 |
84 |
85 | ,
86 | );
87 |
--------------------------------------------------------------------------------
/src/renderer/src/isDev.ts:
--------------------------------------------------------------------------------
1 | const { isDev } = window.require('@electron/remote').require('./index.js');
2 |
3 | export default isDev;
4 |
--------------------------------------------------------------------------------
/src/renderer/src/main.css:
--------------------------------------------------------------------------------
1 | /*
2 | https://www.radix-ui.com/docs/colors/palette-composition/the-scales
3 | https://www.radix-ui.com/docs/colors/palette-composition/understanding-the-scale
4 | */
5 | @import '@radix-ui/colors/red.css';
6 | @import '@radix-ui/colors/orange.css';
7 | @import '@radix-ui/colors/red-dark.css';
8 | @import '@radix-ui/colors/amber.css';
9 | @import '@radix-ui/colors/amber-dark.css';
10 | @import '@radix-ui/colors/green.css';
11 | @import '@radix-ui/colors/green-dark.css';
12 | @import '@radix-ui/colors/cyan.css';
13 | @import '@radix-ui/colors/cyan-dark.css';
14 | @import '@radix-ui/colors/blue.css';
15 | @import '@radix-ui/colors/blue-dark.css';
16 | @import '@radix-ui/colors/gray.css';
17 | @import '@radix-ui/colors/gray-dark.css';
18 | @import '@radix-ui/colors/black-alpha.css';
19 | @import '@radix-ui/colors/white-alpha.css';
20 |
21 |
22 | html {
23 | font-family: 'Open Sans', 'Noto Sans SemiCondensed', 'Noto Sans', sans-serif;
24 | font-size: 16px;
25 | }
26 |
27 | body {
28 | margin: 0;
29 | overflow: hidden;
30 | color: white;
31 | background: black;
32 | }
33 |
34 | /* https://github.com/electron/electron/issues/2538 */
35 | .no-user-select :not(input):not(textarea),
36 | :not(input):not(textarea)::after,
37 | :not(input):not(textarea)::before {
38 | -webkit-user-select: none;
39 | user-select: none;
40 | cursor: default;
41 | }
42 |
43 | kbd {
44 | display: inline-block;
45 | padding: 3px 5px;
46 | font-size: 11px;
47 | line-height: 10px;
48 | color: var(--gray-11);
49 | vertical-align: middle;
50 | background-color: var(--gray-4);
51 | border: solid 1px var(--gray-8);
52 | border-bottom-color: var(--gray-8);
53 | border-radius: 3px;
54 | box-shadow: inset 0 -1px 0 var(--gray-8);
55 | }
56 |
57 | code.highlighted {
58 | background-color: var(--gray-4);
59 | border-radius: .2em;
60 | }
61 |
62 | .hide-scrollbar::-webkit-scrollbar {
63 | display: none;
64 | }
65 |
66 | .consistent-scrollbar::-webkit-scrollbar {
67 | width: .5em;
68 | }
69 | .consistent-scrollbar::-webkit-scrollbar-track-piece {
70 | background: transparent;
71 | }
72 | .consistent-scrollbar::-webkit-scrollbar-thumb {
73 | background: var(--gray-11);
74 | border-radius: .1em;
75 | }
76 |
77 | .swal2-losslesscut-radio {
78 | font-size: 0.8em;
79 | flex-direction: column;
80 | /* !important is important: https://github.com/mifi/lossless-cut/issues/541#issuecomment-750003650 */
81 | align-items: flex-start !important;
82 | text-align: left;
83 | }
84 |
85 | .segment-list-entry .enabled {
86 | display: none;
87 | }
88 |
89 | .segment-list-entry:hover .enabled {
90 | display: inherit;
91 | }
92 |
93 | .button-unstyled {
94 | all: unset;
95 | cursor: pointer;
96 | }
97 | .button-unstyled:focus {
98 | outline: revert;
99 | }
100 |
101 | .link-button {
102 | all: unset;
103 | cursor: pointer;
104 | text-decoration: underline;
105 | text-underline-offset: .15em;
106 | text-decoration-thickness: .05em;
107 | }
108 | .link-button:focus {
109 | outline: revert;
110 | }
111 |
112 | input:focus-visible, select:focus-visible {
113 | outline: 0.1em solid var(--gray-11);
114 | outline-offset: 0;
115 | }
116 |
117 | @keyframes pulse {
118 | 0% { transform: scale(1) }
119 | 45% { transform: scale(1.03) }
120 | 50% { transform: scale(1.15) }
121 | 55% { transform: scale(1.03) }
122 | 100% { transform: scale(1) }
123 | }
124 |
125 | /* because some people have a hard time finding the button */
126 | .export-animation {
127 | animation: pulse 2s linear infinite
128 | }
129 |
--------------------------------------------------------------------------------
/src/renderer/src/mifi.ts:
--------------------------------------------------------------------------------
1 | import ky from 'ky';
2 |
3 | import { runFfmpegStartupCheck, getFfmpegPath } from './ffmpeg';
4 | import Swal from './swal';
5 | import { handleError } from './util';
6 | import isDev from './isDev';
7 |
8 |
9 | export async function loadMifiLink() {
10 | try {
11 | // In old versions: https://mifi.no/losslesscut/config.json
12 | return await ky('https://losslesscut.mifi.no/config.json').json();
13 | // return await ky('http://localhost:8080/losslesscut/config-dev.json').json();
14 | } catch (err) {
15 | if (isDev) console.error(err);
16 | return undefined;
17 | }
18 | }
19 |
20 | export async function runStartupCheck({ customFfPath }: { customFfPath: string | undefined }) {
21 | try {
22 | await runFfmpegStartupCheck();
23 | } catch (err) {
24 | if (err instanceof Error) {
25 | if (!customFfPath && 'code' in err && typeof err.code === 'string' && ['EPERM', 'EACCES'].includes(err.code)) {
26 | Swal.fire({
27 | icon: 'error',
28 | title: 'Fatal: ffmpeg not accessible',
29 | text: `Got ${err.code}. This probably means that anti-virus is blocking execution of ffmpeg. Please make sure the following file exists and is executable:\n\n${getFfmpegPath()}\n\nSee this issue: https://github.com/mifi/lossless-cut/issues/1114`,
30 | });
31 | return;
32 | }
33 |
34 | if (customFfPath && 'code' in err && err.code === 'ENOENT') {
35 | Swal.fire({
36 | icon: 'error',
37 | title: 'Fatal: ffmpeg not found',
38 | text: `Make sure that ffmpeg executable exists: ${getFfmpegPath()}`,
39 | });
40 | return;
41 | }
42 | }
43 |
44 | handleError('Fatal: ffmpeg non-functional', err);
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/renderer/src/reporting.tsx:
--------------------------------------------------------------------------------
1 | import i18n from 'i18next';
2 | import { Trans } from 'react-i18next';
3 |
4 | import CopyClipboardButton from './components/CopyClipboardButton';
5 | import { isStoreBuild, isMasBuild, isWindowsStoreBuild, isExecaError } from './util';
6 | import { ReactSwal } from './swal';
7 |
8 | const electron = window.require('electron');
9 |
10 | const remote = window.require('@electron/remote');
11 |
12 | const { app } = remote;
13 |
14 | const { platform, arch } = remote.require('./index.js');
15 |
16 |
17 | // eslint-disable-next-line import/prefer-default-export
18 | export function openSendReportDialog(err: unknown | undefined, state?: unknown) {
19 | const reportInstructions = isStoreBuild
20 | ? (
21 | Please send an email to electron.shell.openExternal('mailto:losslesscut@mifi.no')}>losslesscut@mifi.no where you describe what you were doing.
22 | ) : (
23 |
24 |
25 | If you're having a problem or question about LosslessCut, please first check the links in the Help menu. If you cannot find any resolution, you may ask a question in electron.shell.openExternal('https://github.com/mifi/lossless-cut/discussions')}>GitHub discussions or on electron.shell.openExternal('https://github.com/mifi/lossless-cut')}>Discord.
26 |
27 |
28 | If you believe that you found a bug in LosslessCut, you may electron.shell.openExternal('https://github.com/mifi/lossless-cut/issues')}>report a bug .
29 |
30 |
31 | );
32 |
33 | const version = app.getVersion();
34 |
35 | const text = `${err instanceof Error ? err.stack : 'No error occurred.'}\n\n${JSON.stringify({
36 | err: isExecaError(err) && {
37 | code: err.code,
38 | isTerminated: err.isTerminated,
39 | failed: err.failed,
40 | timedOut: err.timedOut,
41 | isCanceled: err.isCanceled,
42 | exitCode: err.exitCode,
43 | signal: err.signal,
44 | signalDescription: err.signalDescription,
45 | },
46 |
47 | state,
48 |
49 | platform,
50 | arch,
51 | version,
52 | isWindowsStoreBuild,
53 | isMasBuild,
54 | }, null, 2)}`;
55 |
56 | ReactSwal.fire({
57 | showCloseButton: true,
58 | title: i18n.t('Send problem report'),
59 | showConfirmButton: false,
60 | html: (
61 |
62 | {reportInstructions}
63 |
64 |
Include the following text:
65 |
66 | {!isStoreBuild &&
You might want to redact any sensitive information like paths.
}
67 |
68 |
69 | {text}
70 |
71 |
72 | ),
73 | });
74 | }
75 |
--------------------------------------------------------------------------------
/src/renderer/src/smartcut.ts:
--------------------------------------------------------------------------------
1 | import { getRealVideoStreams, getVideoTimebase } from './util/streams';
2 |
3 | import { readKeyframesAroundTime, findNextKeyframe, findKeyframeAtExactTime } from './ffmpeg';
4 | import { FFprobeStream } from '../../../ffprobe';
5 |
6 | const { stat } = window.require('fs-extra');
7 |
8 |
9 | const mapVideoCodec = (codec: string) => ({ av1: 'libsvtav1' }[codec] ?? codec);
10 |
11 | // eslint-disable-next-line import/prefer-default-export
12 | export async function getSmartCutParams({ path, fileDuration, desiredCutFrom, streams }: {
13 | path: string,
14 | fileDuration: number | undefined,
15 | desiredCutFrom: number,
16 | streams: Pick[],
17 | }) {
18 | const videoStreams = getRealVideoStreams(streams);
19 | if (videoStreams.length > 1) throw new Error('Can only smart cut video with exactly one video stream');
20 |
21 | const [videoStream] = videoStreams;
22 |
23 | if (videoStream == null) throw new Error('Smart cut only works on videos');
24 |
25 | const readKeyframes = async (window: number) => readKeyframesAroundTime({ filePath: path, streamIndex: videoStream.index, aroundTime: desiredCutFrom, window });
26 |
27 | let keyframes = await readKeyframes(10);
28 |
29 | const keyframeAtExactTime = findKeyframeAtExactTime(keyframes, desiredCutFrom);
30 | if (keyframeAtExactTime) {
31 | console.log('Start cut is already on exact keyframe', keyframeAtExactTime.time);
32 |
33 | return {
34 | losslessCutFrom: keyframeAtExactTime.time,
35 | videoStreamIndex: videoStream.index,
36 | segmentNeedsSmartCut: false,
37 | };
38 | }
39 |
40 | let nextKeyframe = findNextKeyframe(keyframes, desiredCutFrom);
41 |
42 | if (nextKeyframe == null) {
43 | // try again with a larger window
44 | keyframes = await readKeyframes(60);
45 | nextKeyframe = findNextKeyframe(keyframes, desiredCutFrom);
46 | }
47 | if (nextKeyframe == null) throw new Error('Cannot find any keyframe after the desired start cut point');
48 |
49 | console.log('Smart cut from keyframe', { keyframe: nextKeyframe.time, desiredCutFrom });
50 |
51 | let videoBitrate = parseInt(videoStream.bit_rate!, 10);
52 | if (Number.isNaN(videoBitrate)) {
53 | console.warn('Unable to detect input bitrate');
54 | const stats = await stat(path);
55 | if (fileDuration == null) throw new Error('Video duration is unknown, cannot estimate bitrate');
56 | videoBitrate = (stats.size * 8) / fileDuration;
57 | }
58 |
59 | // to account for inaccuracies and quality loss
60 | // see discussion https://github.com/mifi/lossless-cut/issues/126#issuecomment-1602266688
61 | videoBitrate = Math.floor(videoBitrate * 1.2);
62 |
63 | const { codec_name: detectedVideoCodec } = videoStream;
64 | if (detectedVideoCodec == null) throw new Error('Unable to determine codec for smart cut');
65 |
66 | const videoCodec = mapVideoCodec(detectedVideoCodec);
67 | console.log({ detectedVideoCodec, videoCodec });
68 |
69 | const timebase = getVideoTimebase(videoStream);
70 | if (timebase == null) console.warn('Unable to determine timebase', videoStream.time_base);
71 |
72 | // seems like ffmpeg handles this itself well when encoding same source file
73 | // const videoLevel = parseLevel(videoStream);
74 | // const videoProfile = parseProfile(videoStream);
75 |
76 | return {
77 | losslessCutFrom: nextKeyframe.time,
78 | videoStreamIndex: videoStream.index,
79 | segmentNeedsSmartCut: true,
80 | videoCodec,
81 | videoBitrate: Math.floor(videoBitrate),
82 | videoTimebase: timebase,
83 | };
84 | }
85 |
--------------------------------------------------------------------------------
/src/renderer/src/styles.ts:
--------------------------------------------------------------------------------
1 | import { CSSProperties } from 'react';
2 | import { controlsBackground, darkModeTransition } from './colors';
3 |
4 |
5 | export const videoStyle: CSSProperties = { width: '100%', height: '100%', objectFit: 'contain' };
6 | export const bottomStyle: CSSProperties = { background: controlsBackground, transition: darkModeTransition, flexShrink: 0 };
7 |
--------------------------------------------------------------------------------
/src/renderer/src/swal.ts:
--------------------------------------------------------------------------------
1 | import SwalRaw from 'sweetalert2/dist/sweetalert2.js';
2 | import type { SweetAlertOptions } from 'sweetalert2';
3 | import withReactContent from 'sweetalert2-react-content';
4 | import i18n from './i18n';
5 |
6 |
7 | const { systemPreferences } = window.require('@electron/remote');
8 |
9 | const animationSettings = systemPreferences.getAnimationSettings();
10 |
11 | let commonSwalOptions: SweetAlertOptions = {
12 | target: '#swal2-container-wrapper',
13 | };
14 |
15 | if (animationSettings.prefersReducedMotion) {
16 | commonSwalOptions = {
17 | ...commonSwalOptions,
18 | showClass: {
19 | popup: '',
20 | backdrop: '',
21 | icon: '',
22 | },
23 | hideClass: {
24 | popup: '',
25 | backdrop: '',
26 | icon: '',
27 | },
28 | };
29 | }
30 |
31 | const Swal = SwalRaw.mixin({
32 | ...commonSwalOptions,
33 | });
34 |
35 | export default Swal;
36 |
37 | export const swalToastOptions: SweetAlertOptions = {
38 | ...commonSwalOptions,
39 | toast: true,
40 | width: '50vw',
41 | position: 'top',
42 | showConfirmButton: false,
43 | showCloseButton: true,
44 | timer: 5000,
45 | timerProgressBar: true,
46 | didOpen: (self) => {
47 | self.addEventListener('mouseenter', Swal.stopTimer);
48 | self.addEventListener('mouseleave', Swal.resumeTimer);
49 | },
50 | };
51 |
52 | export const toast = Swal.mixin(swalToastOptions);
53 |
54 | export const errorToast = (text: string) => toast.fire({
55 | icon: 'error',
56 | text,
57 | });
58 |
59 | export const showPlaybackFailedMessage = () => errorToast(i18n.t('Unable to playback this file. Try to convert to supported format from the menu'));
60 |
61 | export const ReactSwal = withReactContent(Swal);
62 |
--------------------------------------------------------------------------------
/src/renderer/src/swal2.scss:
--------------------------------------------------------------------------------
1 | $swal2-outline-color: var(--gray-5) !default;
2 |
3 | // see colors.ts primaryColor
4 | $swal2-confirm-button-background-color: var(--cyan-9);
5 |
6 | $myswal-background: var(--gray-1);
7 | $myswal-foreground: var(--gray-12);
8 | $swal2-outline-color: $swal2-outline-color;
9 |
10 | $swal2-border: .1em solid $swal2-outline-color;
11 | $swal2-background: $myswal-background;
12 | $swal2-html-container-color: $myswal-foreground;
13 | $swal2-title-color: $myswal-foreground;
14 | $swal2-backdrop: rgba(0, 0, 0, .75);
15 |
16 | // TOAST
17 | $swal2-toast-background: $myswal-background;
18 | $swal2-toast-button-focus-box-shadow: 0 0 0 1px $swal2-background, 0 0 0 3px $swal2-outline-color;
19 |
20 | $swal2-close-button-color: var(--gray-11);
21 |
22 | // FOOTER
23 | $swal2-footer-border-color: var(--gray-2);
24 | $swal2-footer-color: $myswal-background;
25 |
26 | // TIMER POGRESS BAR
27 | $swal2-timer-progress-bar-background: var(--gray-8);
28 |
29 | // INPUT
30 | $swal2-input-color: $myswal-foreground;
31 | $swal2-input-background: var(--gray-3);
32 |
33 | // VALIDATION MESSAGE
34 | $swal2-validation-message-background: var(--gray-3);
35 | $swal2-validation-message-color: $myswal-foreground;
36 |
37 | // QUEUE
38 | $swal2-progress-step-background: var(--gray-5);
39 |
40 | // COMMON VARIABLES FOR CONFIRM AND CANCEL BUTTONS
41 | $swal2-button-focus-box-shadow: 0 0 0 1px $swal2-background, 0 0 0 3px $swal2-outline-color;
42 |
43 | .swal2-textarea::placeholder {
44 | color: var(--gray-8);
45 | }
46 |
47 | @import 'sweetalert2/src/sweetalert2.scss';
48 |
--------------------------------------------------------------------------------
/src/renderer/src/test/fixtures/DV Analyzer Summary.txt:
--------------------------------------------------------------------------------
1 | DV Analyzer v.1.4.2 by AudioVisual Preservation Solutions, Inc. http://www.avpreserve.com
2 |
3 | L:\To-Re-Encode\31.12.2001 Cats Test Tape (TDK Tape).avi
4 |
5 | Frame Count: 116883
6 |
7 | Frame count with video error concealment: 2776 frames
8 | Total video error concealment: 319120 errors ( 317500 "A" errors, 1620 "F" errors)
9 | Frame count with CH1 audio error code: 783 frames
10 | Total audio error code for CH1: 16263 errors ( 4005 Dseq=0, 1575 Dseq=1, 3780 Dseq=2, 1611 Dseq=3, 3672 Dseq=4, 1620 Dseq=5)
11 | Frame count with DV timecode incoherency: 2 frames
12 | Frame count with Arbitrary bit inconsistency: 6 frames
13 |
14 | Absolute time DV timecode range Recorded date/time range Frame range
15 | 00:00:00.000 00:00:00:15 - 00:01:00:16 XXXX-XX-XX 00:00:00.000 - XXXX-XX-XX XX:XX:XX:XX 0 - 1509
16 | 00:01:00.400 00:00:00:00 - 00:07:04:08 XXXX-XX-XX XX:XX:XX:XX - 2001-12-31 23:22:09 1510 - 12126
17 | 00:08:05.080 00:00:00:00 - 00:08:45:14 2001-12-31 23:28:13 - 2002-01-01 19:34:38 12127 - 25266
18 | 00:16:50.680 00:08:45:15 - 00:12:29:22 2002-01-01 13:31:24 - 2002-01-01 22:03:01 25267 - 30882
19 | 00:20:35.320 00:00:00:00 - 00:05:39:09 2002-01-02 14:27:10 - 2002-01-02 15:48:55 30883 - 39375
20 | 00:26:15.040 00:00:00:00 - 00:00:00:00 2002-01-02 22:30:22 - 2002-01-02 22:30:22 39376 - 39376
21 | 00:26:15.080 00:00:00:02 - 00:00:00:03 2002-01-02 22:30:22 - 2002-01-02 22:30:22 39377 - 39378
22 | 00:26:15.160 00:00:00:05 - 00:00:00:06 2002-01-02 22:30:22 - 2002-01-05 10:57:51 39379 - 39380
23 | 00:26:15.240 00:00:00:08 - 00:05:44:04 2002-01-05 10:57:51 - 2002-01-05 11:36:20 39381 - 47985
24 | 00:31:59.440 00:00:00:00 - 00:02:36:02 2002-01-05 13:18:43 - 2002-01-05 14:04:19 47986 - 51896
25 | 00:34:35.880 00:00:00:00 - 00:01:02:07 2002-01-05 16:39:22 - 2002-01-05 22:51:40 51897 - 53454
26 | 00:35:38.200 00:01:02:08 - 00:01:02:14 2002-01-05 16:40:24 - 2002-01-05 16:40:25 53455 - 53468
27 | 00:35:38.760 00:00:00:00 - 00:01:18:05 2002-01-05 22:53:17 - 2002-01-05 22:55:08 53469 - 55432
28 | 00:36:57.320 00:00:00:00 - 00:00:52:07 2002-01-16 21:17:04 - 2002-01-16 21:18:01 55433 - 56748
29 | 00:37:49.960 00:00:00:00 - 00:01:02:08 2002-01-20 20:06:37 - 2002-01-20 20:07:48 56749 - 58315
30 | 00:38:52.640 00:00:00:00 - 00:11:16:02 2002-01-30 18:34:52 - 2002-03-12 00:46:51 58316 - 75227
31 | 00:50:09.120 00:00:00:00 - 00:09:47:08 2002-03-14 20:27:57 - 2002-04-12 21:06:54 75228 - 89911
32 | 00:59:56.480 00:09:49:07 - 00:10:14:14 2002-04-12 21:06:56 - 2002-04-12 21:07:22 89912 - 90551
33 | 01:00:22.080 00:00:00:00 - 00:11:22:21 2002-04-12 21:11:47 - 2002-04-27 00:05:36 90552 - 107624
34 | 01:11:45.000 00:11:22:22 - 00:11:25:00 2002-04-25 22:59:57 - 2002-04-25 22:59:59 107625 - 107677
35 | 01:11:47.120 00:11:25:01 - 00:12:15:14 2002-04-25 23:00:00 - 2002-05-02 13:33:33 107678 - 108941
36 | 01:12:37.680 00:12:15:15 - 00:12:17:07 2002-04-25 23:00:50 - 2002-04-25 23:00:52 108942 - 108992
37 | 01:12:39.720 00:00:00:00 - 00:05:00:09 2002-05-02 13:40:27 - 2002-05-02 13:53:34 108993 - 116510
38 | 01:17:40.440 00:00:00:00 - 00:00:14:21 2002-05-02 13:54:14 - 2002-05-02 13:59:29 116511 - 116882
39 |
40 | Percent of frames with Error: 2.69%
41 | Percent of frames with Error (including Arbitrary bit inconsistency): 2.69%
42 | Percent of frames with Video Error Concealment: 2.38%
43 | Percent of frames with Audio Errors: 0.67%
44 | Percent of frames with Timecode Incoherency: 0.00%
45 | Percent of frames with Arbitrary bit inconsistency: 0.01%
46 |
47 | Warning, frame count is maybe incoherant (reported by MediaInfo: 116882)
48 |
--------------------------------------------------------------------------------
/src/renderer/src/test/fixtures/edl/README.md:
--------------------------------------------------------------------------------
1 | Taken from https://github.com/bradcordeiro/edl-genius/tree/master/test/edl_files
2 |
--------------------------------------------------------------------------------
/src/renderer/src/test/fixtures/edl/cmx3600.edl:
--------------------------------------------------------------------------------
1 | TITLE: EDL Test SEQ
2 | FCM: DROP FRAME
3 | 001 ACC112 V C 01:49:40:01 01:49:46:18 01:00:00:00 01:00:06:17
4 | * FROM CLIP NAME: ACC112 WARBIRDS.NEW.01
5 | * SOURCE FILE: ACC112 WARBIRDS
6 | 002 IMG_6348 V C 00:00:15:26 00:00:17:05 01:00:06:17 01:00:07:26
7 | * FROM CLIP NAME: IMG_6348.NEW.01
8 | * SOURCE FILE: IMG_6348
9 | 003 BOONE_SM V C 01:01:43:05 01:01:57:00 01:00:07:26 01:00:21:21
10 | * FROM CLIP NAME: BOONE SMITH ON CAMERA HOST_-720P.NEW.01
11 | * SOURCE FILE: BOONE SMITH ON CAMERA HOST_-720P
12 | 004 BOONE_SM V C 01:02:21:28 01:02:22:28 01:00:21:21 01:00:22:21
13 | * FROM CLIP NAME: BOONE SMITH ON CAMERA HOST_-720P.NEW.01
14 | * TO CLIP NAME: BOONE SMITH ON CAMERA HOST_-720P.NEW.01
15 | * SOURCE FILE: BOONE SMITH ON CAMERA HOST_-720P
16 | 005 BOONE_SM V C 01:02:22:28 01:02:26:17 01:00:22:21 01:00:26:10
17 | * FROM CLIP NAME: BOONE SMITH ON CAMERA HOST_-720P.NEW.01
18 | * SOURCE FILE: BOONE SMITH ON CAMERA HOST_-720P
19 | 006 AQ100 V C 00:02:18:05 00:02:28:10 01:00:26:10 01:00:31:13
20 | M2 AQ100 059.6 00:02:18:05
21 | * TIMEWARP EFFECT AT SEQUENCE TC 01;00;26;10.
22 | * FROM CLIP NAME: DTB RE EDIT - HD 720P VIDEO SHARING.NEW.01
23 | * SOURCE FILE: DTB RE EDIT - HD 720P VIDEO SHARING
24 | 007 BR240 V C 09:18:30:13 09:18:38:13 01:00:31:13 01:00:39:12
25 | M2 BR240 024.0 09:18:30:13
26 | * FROM CLIP NAME: 00004.NEW.01
27 | * SOURCE FILE: 00004
28 | 008 ACC112 V C 01:50:58:03 01:51:00:27 01:00:38:14 01:00:41:06
29 | * FROM CLIP NAME: ACC112 WARBIRDS.NEW.01
30 | * SOURCE FILE: ACC112 WARBIRDS
31 | 009 KIRA_PAS V C 01:01:25:14 01:01:32:07 01:00:40:25 01:00:47:15
32 | M2 KIRA_PAS 024.0 01:01:25:14
33 | * FROM CLIP NAME: KIRA PASTA.NEW.01
34 | * SOURCE FILE: KIRA PASTA
--------------------------------------------------------------------------------
/src/renderer/src/test/fixtures/edl/cmx3600_5994.edl:
--------------------------------------------------------------------------------
1 | TITLE: EDL Test SEQ
2 | FCM: DROP FRAME
3 | 001 ACC112 V C 01:49:40:02 01:49:46:36 01:00:00:00 01:00:06:34
4 | * FROM CLIP NAME: ACC112 WARBIRDS.NEW.01
5 | * SOURCE FILE: ACC112 WARBIRDS
6 | 002 IMG_6348 V C 00:00:15:52 00:00:17:10 01:00:06:34 01:00:07:52
7 | * FROM CLIP NAME: IMG_6348.NEW.01
8 | * SOURCE FILE: IMG_6348
9 | 003 BOONE_SM V C 01:01:43:10 01:01:57:00 01:00:07:52 01:00:21:42
10 | * FROM CLIP NAME: BOONE SMITH ON CAMERA HOST_-720P.NEW.01
11 | * SOURCE FILE: BOONE SMITH ON CAMERA HOST_-720P
12 | 004 BOONE_SM V C 01:02:21:56 01:02:22:56 01:00:21:42 01:00:22:42
13 | * FROM CLIP NAME: BOONE SMITH ON CAMERA HOST_-720P.NEW.01
14 | * TO CLIP NAME: BOONE SMITH ON CAMERA HOST_-720P.NEW.01
15 | * SOURCE FILE: BOONE SMITH ON CAMERA HOST_-720P
16 | 005 BOONE_SM V C 01:02:22:56 01:02:26:34 01:00:22:42 01:00:26:20
17 | * FROM CLIP NAME: BOONE SMITH ON CAMERA HOST_-720P.NEW.01
18 | * SOURCE FILE: BOONE SMITH ON CAMERA HOST_-720P
19 | 006 AQ100 V C 00:02:18:10 00:02:28:20 01:00:26:20 01:00:31:26
20 | M2 AQ100 059.6 00:02:18:05
21 | * TIMEWARP EFFECT AT SEQUENCE TC 01;00;26;10.
22 | * FROM CLIP NAME: DTB RE EDIT - HD 720P VIDEO SHARING.NEW.01
23 | * SOURCE FILE: DTB RE EDIT - HD 720P VIDEO SHARING
24 | 007 BR240 V C 09:18:30:26 09:18:38:26 01:00:31:26 01:00:39:24
25 | M2 BR240 024.0 09:18:30:13
26 | * FROM CLIP NAME: 00004.NEW.01
27 | * SOURCE FILE: 00004
28 | 008 ACC112 V C 01:50:58:06 01:51:00:54 01:00:38:28 01:00:41:12
29 | * FROM CLIP NAME: ACC112 WARBIRDS.NEW.01
30 | * SOURCE FILE: ACC112 WARBIRDS
31 | 009 KIRA_PAS V C 01:01:25:28 01:01:32:14 01:00:40:50 01:00:47:30
32 | M2 KIRA_PAS 024.0 01:01:25:28
33 | * FROM CLIP NAME: KIRA PASTA.NEW.01
34 | * SOURCE FILE: KIRA PASTA
--------------------------------------------------------------------------------
/src/renderer/src/test/fixtures/edl/file32.edl:
--------------------------------------------------------------------------------
1 | TITLE: EDL Test SEQ
2 | FCM: DROP FRAME
3 | 000001 ACC112 V C 01:49:40:01 01:49:46:18 01:00:00:00 01:00:06:17
4 | *FROM CLIP NAME: ACC112 WARBIRDS.NEW.01
5 | *SOURCE FILE: ACC112 WARBIRDS
6 | 000002 IMG_6348 V C 00:00:15:26 00:00:17:05 01:00:06:17 01:00:07:26
7 | *FROM CLIP NAME: IMG_6348.NEW.01
8 | *SOURCE FILE: IMG_6348
9 | 000003 BOONE_SMITH_ON_CAMERA_HOST_-720P V C 01:01:43:05 01:01:57:00 01:00:07:26 01:00:21:21
10 | *FROM CLIP NAME: BOONE SMITH ON CAMERA HOST_-720P.NEW.01
11 | *SOURCE FILE: BOONE SMITH ON CAMERA HOST_-720P
12 | 000004 BOONE_SMITH_ON_CAMERA_HOST_-720P V C 01:02:21:28 01:02:22:28 01:00:21:21 01:00:22:21
13 | *FROM CLIP NAME: BOONE SMITH ON CAMERA HOST_-720P.NEW.01
14 | *TO CLIP NAME: BOONE SMITH ON CAMERA HOST_-720P.NEW.01
15 | *SOURCE FILE: BOONE SMITH ON CAMERA HOST_-720P
16 | 000005 BOONE_SMITH_ON_CAMERA_HOST_-720P V C 01:02:22:28 01:02:26:17 01:00:22:21 01:00:26:10
17 | *FROM CLIP NAME: BOONE SMITH ON CAMERA HOST_-720P.NEW.01
18 | *SOURCE FILE: BOONE SMITH ON CAMERA HOST_-720P
19 | 000006 AQ100 V C 00:02:18:05 00:02:28:10 01:00:26:10 01:00:31:13
20 | M2 AQ100 059.6 00:02:18:05
21 | *TIMEWARP EFFECT AT SEQUENCE TC 01;00;26;10.
22 | *FROM CLIP NAME: DTB RE EDIT - HD 720P VIDEO SHARING.NEW.01
23 | *SOURCE FILE: DTB RE EDIT - HD 720P VIDEO SHARING
24 | 000007 BR240 V C 09:18:30:13 09:18:38:13 01:00:31:13 01:00:39:12
25 | M2 BR240 024.0 09:18:30:13
26 | *FROM CLIP NAME: 00004.NEW.01
27 | *SOURCE FILE: 00004
28 | 000008 ACC112 V C 01:50:58:03 01:51:00:27 01:00:38:14 01:00:41:06
29 | *FROM CLIP NAME: ACC112 WARBIRDS.NEW.01
30 | *SOURCE FILE: ACC112 WARBIRDS
31 | 000009 KIRA_PASTA V C 01:01:25:14 01:01:32:07 01:00:40:25 01:00:47:15
32 | M2 KIRA_PASTA 024.0 01:01:25:14
33 | *FROM CLIP NAME: KIRA PASTA.NEW.01
34 | *SOURCE FILE: KIRA PASTA
--------------------------------------------------------------------------------
/src/renderer/src/test/fixtures/edl/pull001_201109_exr.edl:
--------------------------------------------------------------------------------
1 | TITLE: pull001_201109_exr
2 | FCM: NON-DROP FRAME
3 | 000001 A036C010_191203 V C 08:12:57:14 08:13:04:07 01:00:00:00 01:00:06:17
4 | *009 0100
5 | 000002 A066C008_191219 V C 10:01:05:16 10:01:09:20 01:00:06:17 01:00:10:21
6 | *076 0300
7 | 000003 A152C002_200208 V C 10:37:07:02 10:37:16:12 01:00:10:21 01:00:20:07
8 | *102 0100
--------------------------------------------------------------------------------
/src/renderer/src/test/fixtures/mplayer.edl:
--------------------------------------------------------------------------------
1 | 5.3 7.1 0
2 | 15 16.7 1
3 | 420 822 3
4 | 1 255.3 2
5 | 720.1 2
6 |
--------------------------------------------------------------------------------
/src/renderer/src/test/fixtures/potplayer bookmark format utf16le issue 867.pbf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mifi/lossless-cut/250523c3c3a1d37d140d19e0e0b7e3dbe00728bd/src/renderer/src/test/fixtures/potplayer bookmark format utf16le issue 867.pbf
--------------------------------------------------------------------------------
/src/renderer/src/test/fixtures/sample.srt:
--------------------------------------------------------------------------------
1 | 1
2 | 00:00:02,000 --> 00:00:06,000
3 | First subtitle
4 |
5 | 2
6 | 00:00:28,967 --> 01:30:30.95
7 | Subtitle 2 line 1
8 | Subtitle 2 line 2
9 |
--------------------------------------------------------------------------------
/src/renderer/src/test/fixtures/sample.vtt:
--------------------------------------------------------------------------------
1 | WEBVTT
2 |
3 | 00:01.000 --> 00:04.000
4 | - Never drink liquid nitrogen.
5 |
6 | 00:05.000 --> 00:09.000
7 | - It will perforate your stomach.
8 | - You could die.
--------------------------------------------------------------------------------
/src/renderer/src/test/fixtures/test1.csv:
--------------------------------------------------------------------------------
1 | 104.612,189.053,label 1
2 | 300.448,476.194,label 2
3 | 567.075,704.264,label 3
4 | 704.264,855.455,label 4
5 |
--------------------------------------------------------------------------------
/src/renderer/src/test/fixtures/test1.pbf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mifi/lossless-cut/250523c3c3a1d37d140d19e0e0b7e3dbe00728bd/src/renderer/src/test/fixtures/test1.pbf
--------------------------------------------------------------------------------
/src/renderer/src/test/fixtures/test2.csv:
--------------------------------------------------------------------------------
1 | 104.612,189.053,label 1
2 | 189.053,300.448,label 2
3 | 300.448,476.194,label 3
4 | 476.194,567.075,label 4
5 | 567.075,704.264,label 5
6 | 704.264,855.455,label 6
7 |
--------------------------------------------------------------------------------
/src/renderer/src/test/fixtures/test2.pbf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mifi/lossless-cut/250523c3c3a1d37d140d19e0e0b7e3dbe00728bd/src/renderer/src/test/fixtures/test2.pbf
--------------------------------------------------------------------------------
/src/renderer/src/test/fixtures/test3.csv:
--------------------------------------------------------------------------------
1 | 104.612,189.053,A
2 | 300.448,476.194,B
3 | 567.075,704.264,C
4 |
--------------------------------------------------------------------------------
/src/renderer/src/test/fixtures/test3.pbf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mifi/lossless-cut/250523c3c3a1d37d140d19e0e0b7e3dbe00728bd/src/renderer/src/test/fixtures/test3.pbf
--------------------------------------------------------------------------------
/src/renderer/src/test/util.ts:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs/promises';
2 | import { join, dirname } from 'node:path';
3 | import { fileURLToPath } from 'node:url';
4 |
5 | // eslint-disable-next-line no-underscore-dangle
6 | const __dirname = dirname(fileURLToPath(import.meta.url));
7 |
8 | export const readFixture = async (name: string, encoding: BufferEncoding = 'utf8') => fs.readFile(join(__dirname, 'fixtures', name), encoding);
9 | export const readFixtureBinary = async (name: string) => fs.readFile(join(__dirname, 'fixtures', name), null);
10 |
--------------------------------------------------------------------------------
/src/renderer/src/theme.ts:
--------------------------------------------------------------------------------
1 | import { DefaultTheme, IntentTypes, defaultTheme } from 'evergreen-ui';
2 | import { ProviderProps } from 'react';
3 |
4 |
5 | function colorKeyForIntent(intent: IntentTypes) {
6 | if (intent === 'danger') return 'var(--red-12)';
7 | if (intent === 'success') return 'var(--green-12)';
8 | return 'var(--gray-12)';
9 | }
10 |
11 | function borderColorForIntent(intent: IntentTypes, isHover?: boolean) {
12 | if (intent === 'danger') return isHover ? 'var(--red-8)' : 'var(--red-7)';
13 | if (intent === 'success') return isHover ? 'var(--green-8)' : 'var(--green-7)';
14 | return 'var(--gray-8)';
15 | }
16 |
17 | const customTheme: ProviderProps['value'] = {
18 | ...defaultTheme,
19 | colors: {
20 | ...defaultTheme.colors,
21 | icon: {
22 | default: 'var(--gray-12)',
23 | muted: 'var(--gray-11)',
24 | disabled: 'var(--gray-8)',
25 | selected: 'var(--gray-12)',
26 | },
27 | },
28 | components: {
29 | ...defaultTheme.components,
30 | Button: {
31 | ...defaultTheme.components.Button,
32 | appearances: {
33 | ...defaultTheme.components.Button.appearances,
34 | default: {
35 | ...defaultTheme.components.Button.appearances.default,
36 | backgroundColor: 'var(--gray-3)',
37 |
38 | // https://github.com/segmentio/evergreen/blob/master/src/themes/default/components/button.js
39 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
40 | border: ((_theme: unknown, props: { intent: IntentTypes }) => `1px solid ${borderColorForIntent(props.intent)}`) as any as string, // todo types
41 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
42 | color: ((_theme: unknown, props: { color: string, intent: IntentTypes }) => props.color || colorKeyForIntent(props.intent)) as any as string, // todo types
43 |
44 | _hover: {
45 | backgroundColor: 'var(--gray-4)',
46 | },
47 | _active: {
48 | backgroundColor: 'var(--gray-5)',
49 | },
50 | _focus: {
51 | backgroundColor: 'var(--gray-5)',
52 | boxShadow: '0 0 0 1px var(--gray-8)',
53 | },
54 | disabled: {
55 | opacity: 0.5,
56 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
57 | } as any as boolean, // todo types
58 | },
59 | minimal: {
60 | ...defaultTheme.components.Button.appearances.minimal,
61 |
62 | // https://github.com/segmentio/evergreen/blob/master/src/themes/default/components/button.js
63 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
64 | color: ((_theme: unknown, props: { color: string, intent: IntentTypes }) => props.color || colorKeyForIntent(props.intent)) as any as string, // todo types
65 |
66 | _hover: {
67 | backgroundColor: 'var(--gray-4)',
68 | },
69 | _active: {
70 | backgroundColor: 'var(--gray-5)',
71 | },
72 | disabled: {
73 | opacity: 0.5,
74 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
75 | } as any as boolean, // todo types,
76 | },
77 | },
78 | },
79 | },
80 | };
81 |
82 | export default customTheme;
83 |
--------------------------------------------------------------------------------
/src/renderer/src/util/colors.ts:
--------------------------------------------------------------------------------
1 | import color from 'color';
2 | import invariant from 'tiny-invariant';
3 |
4 | import { SegmentColorIndex } from '../types';
5 |
6 | // http://phrogz.net/css/distinct-colors.html
7 | const colorStrings = '#ff5100, #ffc569, #ddffd1, #00ccff, #e9d1ff, #ff0084, #ff6975, #ffe6d1, #ffff69, #69ff96, #008cff, #ae00ff, #ff002b, #ff8c00, #8cff00, #69ffff, #0044ff, #ff00d4, #ffd1d9'.split(',').map((str) => str.trim());
8 | const colors = colorStrings.map((str) => color(str));
9 |
10 | function getColor(n: number) {
11 | const ret = colors[n % colors.length];
12 | invariant(ret != null);
13 | return ret;
14 | }
15 |
16 | // eslint-disable-next-line import/prefer-default-export
17 | export function getSegColor(seg: SegmentColorIndex | undefined) {
18 | if (!seg) {
19 | return color({
20 | h: 0,
21 | s: 0,
22 | v: 100,
23 | });
24 | }
25 | const { segColorIndex } = seg;
26 |
27 | return getColor(segColorIndex);
28 | }
29 |
--------------------------------------------------------------------------------
/src/renderer/src/util/constants.ts:
--------------------------------------------------------------------------------
1 | // anything more than this will probably cause the UI to become unusably slow
2 | export const maxSegmentsAllowed = 2000;
3 |
4 | export const ffmpegExtractWindow = 60;
5 |
6 | export const zoomMax = 2 ** 14;
7 |
8 | export const rightBarWidth = 200;
9 | export const leftBarWidth = 240;
10 |
--------------------------------------------------------------------------------
/src/renderer/src/util/duration.ts:
--------------------------------------------------------------------------------
1 | import padStart from 'lodash/padStart';
2 |
3 | export function formatDuration({ seconds: totalSecondsIn, fileNameFriendly, showFraction = true, shorten = false, fps }: {
4 | seconds?: number | undefined,
5 | fileNameFriendly?: boolean | undefined,
6 | showFraction?: boolean | undefined,
7 | shorten?: boolean | undefined,
8 | fps?: number | undefined,
9 | }) {
10 | const totalSeconds = totalSecondsIn || 0;
11 | const totalSecondsAbs = Math.abs(totalSeconds);
12 | const sign = totalSeconds < 0 ? '-' : '';
13 |
14 | const unitsPerSec = fps != null ? fps : 1000;
15 |
16 | // round to integer for our current unit
17 | const totalUnits = Math.round(totalSecondsAbs * unitsPerSec);
18 |
19 | const seconds = Math.floor(totalUnits / unitsPerSec);
20 | const secondsPadded = padStart(String(seconds % 60), 2, '0');
21 | const minutes = Math.floor(totalUnits / unitsPerSec / 60) % 60;
22 | const hours = Math.floor(totalUnits / unitsPerSec / 60 / 60);
23 |
24 | const minutesPadded = shorten && hours === 0 ? `${minutes}` : padStart(String(minutes), 2, '0');
25 |
26 | const remainder = totalUnits % unitsPerSec;
27 |
28 | // Be nice to filenames and use .
29 | const delim = fileNameFriendly ? '.' : ':';
30 |
31 | let hoursPart = '';
32 | if (!shorten || hours !== 0) {
33 | const hoursPadded = shorten ? `${hours}` : padStart(String(hours), 2, '0');
34 | hoursPart = `${hoursPadded}${delim}`;
35 | }
36 |
37 | let fraction = '';
38 | if (showFraction && !(shorten && remainder === 0)) {
39 | const numDigits = fps != null ? 2 : 3;
40 | fraction = `.${padStart(String(Math.floor(remainder)), numDigits, '0')}`;
41 | }
42 |
43 | return `${sign}${hoursPart}${minutesPadded}${delim}${secondsPadded}${fraction}`;
44 | }
45 |
46 | const exactDurationRegex = /^-?\d{2}:\d{2}:\d{2}.\d{3}$/;
47 | const durationRegex = /^(-?)(?:(?:(\d+):)?(\d{1,2}):)?(\d+(?:[,.]\d+)?)$/;
48 |
49 | // todo adapt also to frame counts and frame fractions?
50 | export const isExactDurationMatch = (str: string) => exactDurationRegex.test(str);
51 |
52 | // See also parseYoutube
53 | export function parseDuration(str: string, fps?: number) {
54 | // eslint-disable-next-line unicorn/better-regex
55 | const match = str.replaceAll(/\s/g, '').match(durationRegex);
56 |
57 | if (!match) return undefined;
58 |
59 | const [, sign, hourStr, minStr, secStrRaw] = match;
60 | const hour = hourStr != null ? parseInt(hourStr, 10) : 0;
61 | const min = minStr != null ? parseInt(minStr, 10) : 0;
62 |
63 | const secWithFraction = secStrRaw!.replace(',', '.');
64 |
65 | let sec: number;
66 | if (fps == null) {
67 | sec = parseFloat(secWithFraction);
68 | } else {
69 | const [secStr, framesStr] = secWithFraction.split('.');
70 | sec = parseInt(secStr!, 10) + (framesStr != null ? parseInt(framesStr, 10) : 0) / fps;
71 | }
72 |
73 | if (min > 59) return undefined;
74 |
75 | let time = (((hour * 60) + min) * 60 + sec);
76 |
77 | if (sign === '-') time *= -1;
78 |
79 | return time;
80 | }
81 |
--------------------------------------------------------------------------------
/src/renderer/src/util/rate-calculator.test.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-line unicorn/filename-case
2 | import { describe, it, expect } from 'vitest';
3 |
4 | import { adjustRate, DEFAULT_PLAYBACK_RATE } from './rate-calculator';
5 |
6 | it('inverts for reverse direction', () => {
7 | const r = adjustRate(1, -1, 2);
8 | expect(r).toBeLessThan(1);
9 | });
10 |
11 | it('uses default rate', () => {
12 | const r = adjustRate(1, 1);
13 | expect(r).toBe(1 * DEFAULT_PLAYBACK_RATE);
14 | });
15 |
16 | it('allows multiplier override', () => {
17 | const r = adjustRate(1, 1, Math.PI);
18 | expect(r).toBe(1 * Math.PI);
19 | });
20 |
21 | describe('speeding up', () => {
22 | it('sets rate to 1 if close to 1', () => {
23 | expect(adjustRate(1 / DEFAULT_PLAYBACK_RATE + 0.01, 1)).toBe(1);
24 | });
25 |
26 | it('sets rate to 1 if passing 1 ', () => {
27 | expect(adjustRate(0.5, 1, 2)).toBe(1);
28 | });
29 |
30 | it('will not play faster than 16', () => {
31 | expect(adjustRate(15.999999, 1, 2)).toBe(16);
32 | });
33 | });
34 |
35 | describe('slowing down', () => {
36 | it('sets rate to 1 if close to 1', () => {
37 | expect(adjustRate(DEFAULT_PLAYBACK_RATE + 0.01, -1)).toBe(1);
38 | });
39 |
40 | it('sets rate to 1 if passing 1', () => {
41 | expect(adjustRate(1.1, -1, 2)).toBe(1);
42 | });
43 |
44 | it('will not play slower than 0.1', () => {
45 | expect(adjustRate(0.1111, -1, 2)).toBe(0.1);
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/src/renderer/src/util/rate-calculator.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-line unicorn/filename-case
2 | import clamp from 'lodash/clamp';
3 |
4 | /**
5 | * @constant {number}
6 | * @default
7 | * The default playback rate multiplier is used to adjust the current playback
8 | * rate when no additional modifiers are applied. This is set to ∛2 so that striking
9 | * the fast forward key (`l`) three times speeds playback up to twice the speed.
10 | */
11 | export const DEFAULT_PLAYBACK_RATE = (2 ** (1 / 3));
12 |
13 | /**
14 | * Adjusts the current playback rate up or down
15 | * @param {number} playbackRate current playback rate
16 | * @param {number} direction positive for forward, negative for reverse
17 | * @param {number} [multiplier] rate multiplier, defaults to {@link DEFAULT_PLAYBACK_RATE}
18 | * @returns a new playback rate
19 | */
20 | export function adjustRate(playbackRate: number, direction: number, multiplier?: number) {
21 | const m = multiplier || DEFAULT_PLAYBACK_RATE;
22 | const factor = direction > 0 ? m : (1 / m);
23 | let newRate = playbackRate * factor;
24 | // If the multiplier causes us to go faster than real time or slower than real time,
25 | // stop along the way at 1.0. This could happen if the current playbackRate was reached
26 | // using a different multiplier (e.g., holding the shift key).
27 | // https://github.com/mifi/lossless-cut/issues/447#issuecomment-766339083
28 | if ((newRate > 1 && playbackRate < 1) || (newRate < 1 && playbackRate > 1)) {
29 | newRate = 1;
30 | }
31 | // And, clean up any rounding errors that get us to almost 1.0 (e.g., treat 1.00001 as 1)
32 | if ((newRate > (m ** (-1 / 2))) && (newRate < (m ** (1 / 2)))) {
33 | newRate = 1;
34 | }
35 | return clamp(newRate, 0.1, 16);
36 | }
37 |
38 | export default adjustRate;
39 |
--------------------------------------------------------------------------------
/src/renderer/src/worker/eval.ts:
--------------------------------------------------------------------------------
1 | import invariant from 'tiny-invariant';
2 |
3 | // https://github.com/vitejs/vite/issues/11823#issuecomment-1407277242
4 | // https://github.com/mifi/lossless-cut/issues/2059
5 | import Worker from './evalWorker?worker';
6 |
7 |
8 | export interface RequestMessageData {
9 | code: string,
10 | id: number,
11 | context: string // json
12 | }
13 |
14 | export type ResponseMessageData = { id: number } & ({
15 | error: string,
16 | } | {
17 | data: unknown,
18 | })
19 |
20 | // https://v3.vitejs.dev/guide/features.html#web-workers
21 | // todo terminate() and recreate in case of error?
22 | const worker = new Worker();
23 | worker.addEventListener('error', (err) => {
24 | console.error('evalWorker error', err);
25 | });
26 |
27 | let lastRequestId = 0;
28 |
29 | export default async function safeishEval(code: string, context: Record) {
30 | return new Promise((resolve, reject) => {
31 | lastRequestId += 1;
32 | const id = lastRequestId;
33 |
34 | // console.log({ lastRequestId, code, context })
35 |
36 | function cleanup() {
37 | // eslint-disable-next-line no-use-before-define
38 | worker.removeEventListener('message', onMessage);
39 | // eslint-disable-next-line no-use-before-define
40 | worker.removeEventListener('messageerror', onMessageerror);
41 | // eslint-disable-next-line no-use-before-define
42 | worker.removeEventListener('error', onError);
43 | }
44 |
45 | function onMessage(response: { data: ResponseMessageData }) {
46 | // console.log('message', { responseId, error, data })
47 |
48 | if (response.data.id === id) {
49 | cleanup();
50 | if ('error' in response.data) {
51 | reject(new Error(response.data.error));
52 | } else {
53 | invariant('data' in response.data);
54 | resolve(response.data.data);
55 | }
56 | }
57 | }
58 |
59 | function onMessageerror() {
60 | cleanup();
61 | reject(new Error('safeishEval messageerror'));
62 | }
63 |
64 | function onError(err: ErrorEvent) {
65 | cleanup();
66 | reject(new Error(`safeishEval error: ${err.message}`));
67 | }
68 |
69 | worker.addEventListener('message', onMessage);
70 | worker.addEventListener('messageerror', onMessageerror);
71 | worker.addEventListener('error', onError);
72 |
73 | worker.postMessage({ id, code, context: JSON.stringify(context) } satisfies RequestMessageData);
74 | });
75 | }
76 |
--------------------------------------------------------------------------------
/test-manual/README.md:
--------------------------------------------------------------------------------
1 | Fixtures are stored separately:
2 | https://github.com/mifi/lossless-cut-fixtures
3 |
--------------------------------------------------------------------------------
/test-manual/formats.sh:
--------------------------------------------------------------------------------
1 | for f in *; do echo -n "$f: "; ffprobe -show_format -of json -i "$f" | json format.format_name; done 2> /dev/null
2 |
--------------------------------------------------------------------------------
/tracks_screenshot.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mifi/lossless-cut/250523c3c3a1d37d140d19e0e0b7e3dbe00728bd/tracks_screenshot.jpg
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "references": [
3 | { "path": "./tsconfig.web.json" },
4 | { "path": "./tsconfig.main.json" },
5 | { "path": "./tsconfig.node.json" },
6 | ],
7 | "files": [],
8 | }
--------------------------------------------------------------------------------
/tsconfig.main.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@tsconfig/strictest"],
3 | "compilerOptions": {
4 | "composite": true,
5 |
6 | "emitDeclarationOnly": true,
7 | "outDir": "ts-dist",
8 | "tsBuildInfoFile": "ts-dist/tsconfig.tsbuildinfo",
9 | "lib": ["es2023"],
10 | "target": "ESNext",
11 | "module": "ESNext",
12 | "moduleResolution": "Bundler",
13 | "types": ["vite/client"],
14 | },
15 | "include": [
16 | "src/main/**/*",
17 | "types.ts",
18 | ],
19 | }
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@tsconfig/strictest", "@tsconfig/node20/tsconfig.json"],
3 | "compilerOptions": {
4 | "noEmit": true,
5 | },
6 | "include": [
7 | "script/**/*",
8 | ],
9 | }
--------------------------------------------------------------------------------
/tsconfig.web.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@tsconfig/strictest", "@tsconfig/vite-react/tsconfig.json"],
3 | "compilerOptions": {
4 | "lib": ["ES2023", "DOM", "DOM.Iterable"],
5 | "noEmit": true,
6 |
7 | "types": ["vite/client"],
8 | },
9 | "references": [
10 | { "path": "./tsconfig.main.json" },
11 | ],
12 | "include": [
13 | "src/renderer/**/*",
14 | ],
15 | }
--------------------------------------------------------------------------------