render waveform when zoomed out

also type
pull/1939/head
Mikael Finstad 2024-03-20 16:02:34 +08:00
rodzic b791c9f748
commit 2e7d746007
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 25AB36E3E81CBC26
26 zmienionych plików z 1086 dodań i 171 usunięć

781
ffprobe.ts 100644
Wyświetl plik

@ -0,0 +1,781 @@
// FFProbe response types. Copied from https://gist.github.com/termermc/2a62735201cede462763456542d8a266
// See also https://github.com/DefinitelyTyped/DefinitelyTyped/blob/a21bbc63c5a31afbad57c3830582c389d32a931b/types/ffprobe/index.d.ts#L4
// These definitions are my best attempt at documenting the output of FFprobe using the "-print_format json" option
// The descriptions come mostly out of experience, deduction, and the very sparse documentation of the outputs
// Not all fields will be present (it depends on the file), but fields that are definite are not marked as optional
// Sample probe:
// ffprobe -v quiet -print_format json -show_format -show_streams -show_chapters -show_error my_file.mp4
/**
* @typedef FFprobeDisposition
* @property {number} default 1 if the default track
* @property {number} dub 1 if a dub track
* @property {number} original 1 if the original track
* @property {number} comment 1 if a comment track
* @property {number} lyrics 1 if a lyrics track
* @property {number} karaoke 1 if a karaoke track
* @property {number} forced 1 if a forced track
* @property {number} hearing_impaired 1 if a track for the hearing impaired
* @property {number} visual_impaired 1 if a track for the visually impaired
* @property {number} clean_effects 1 if a clean effects track (meaning not entirely understood)
* @property {number} attached_pic 1 if an attached picture track
* @property {number} timed_thumbnails 1 if a timed thumbnails track (perhaps like the preview thumbnails you get when scrolling over a YouTube video's seek bar)
* @property {number} captions 1 if a captions track
* @property {number} descriptions 1 if a descriptions track
* @property {number} metadata 1 if a metadata track
* @property {number} dependent 1 if a dependent track (unclear meaning)
* @property {number} still_image 1 if a still image track
*/
/**
* @typedef FFprobeStreamTags
* @property {string} [language] The track's language code (usually represented using a 3 letter language code, e.g.: "eng")
* @property {string} [handler_name] The name of the handler which produced the track
* @property {string} [vendor_id] The ID of the vendor which produced the track
* @property {string} [encoder] The name of the encoder responsible for creating the stream
* @property {string} [creation_time] The date (often ISO-formatted, but it may use other formats) when the media was created
* @property {string} [comment] The comment attached to the stream
*/
/**
* @typedef FFprobeStream
* @property {number} index The stream index
* @property {string} codec_name The codec's name
* @property {string} codec_long_name The codec's long (detailed) name
* @property {string} profile The codec profile
* @property {'video'|'audio'|'subtitle'} codec_type The type of codec (video, audio, subtitle, etc)
* @property {string} codec_tag_string The codec tag (technical name)
* @property {string} codec_tag The codec tag ID
* @property {string} [sample_fmt] The audio sample format (not present if codec_type is not "audio")
* @property {string} [sample_rate] A string representation of an integer showing the audio sample rate (not present if codec_type is not "audio")
* @property {number} [channels] The audio track's channel count (not present if codec_type is not "audio")
* @property {'stereo'|'mono'} [channel_layout] The audio track's channel layout (e.g. "stereo") (not present if codec_type is not "audio")
* @property {number} [bits_per_sample] Bits per audio sample (might not be accurate, may just be 0) (not present if codec_type is not "audio")
* @property {number} [width] The video stream width (also available for images) (not present if codec_type is not "video")
* @property {number} [height] The stream height (also available for images) (not present if codec_type is not "video")
* @property {number} [coded_width] The stream's coded width (shouldn't vary from "width") (not present if codec_type is not "video")
* @property {number} [coded_height] The stream's coded height (shouldn't vary from "height") (not present if codec_type is not "video")
* @property {number} [closed_captions] Set to 1 if closed captions are present in stream... I think (not present if codec_type is not "video")
* @property {number} [has_b_frames] Set to 1 if the stream has b-frames... I think (not present if codec_type is not "video")
* @property {string} [sample_aspect_ratio] The sample aspect ratio (you probably want "display_aspect_ratio") (not present if codec_type is not "video")
* @property {string} [display_aspect_ratio] The display (real) aspect ratio (e.g. "16:9") (not present if codec_type is not "video")
* @property {string} [pix_fmt] The pixel format used (not present if codec_type is not "video")
* @property {number} [level] Unknown (not present if codec_type is not "video")
* @property {string} [color_range] The color range used (e.g. "tv") (not present if codec_type is not "video")
* @property {string} [color_space] The color space used (not present if codec_type is not "video")
* @property {string} [color_transfer] The color transfer used (not present if codec_type is not "video")
* @property {string} [color_primaries] The color primaries used (not present if codec_type is not "video")
* @property {string} [chroma_location] The chroma location (not present if codec_type is not "video")
* @property {number} [refs] Unknown (not present if codec_type is not "video")
* @property {'true'|'false'} [is_avc] Whether the stream is AVC (not present if codec_type is not "video")
* @property {string} [nal_length_size] Unknown string representing a number (not present if codec_type is not "video")
* @property {string} r_frame_rate Odd formatting of the frame rate, possibly "real frame rate"? (e.g. "30/1")
* @property {string} avg_frame_rate Odd formatting of the average frame rate (e.g. "30/1")
* @property {string} time_base The division equation to use for converting integer representations of timestamps into seconds (e.g. "1/30000" turns 80632552 into 2687.751733 seconds)
* @property {number} start_pts Unknown
* @property {string} start_time A string representation of a floating point integer showing the start time in seconds
* @property {number} duration_ts The stream's duration in integer timestamp format (defined by time_base)
* @property {string} duration A string representation of a floating point integer showing the stream duration in seconds
* @property {string} [bit_rate] The string representation of an integer showing the stream bit rate (not present on lossless formats such as FLAC)
* @property {string} [bits_per_raw_sample] A string representation of an integer showing the bits per raw sample (not present if codec_type is not "video")
* @property {string} nb_frames A string representation of an integer showing the total number of frames in the stream
* @property {FFprobeDisposition} disposition The stream's disposition
* @property {FFprobeStreamTags} [tags] The stream's tags
*/
/**
* @typedef FFprobeChapterTags
* @property {string} title The chapter title
*/
/**
* @typedef FFprobeChapter
* @property {number} id The chapter ID
* @property {string} time_base The division equation to use for converting integer representations of timestamps into seconds (e.g. "1/30000" turns 80632552 into 2687.751733 seconds)
* @property {number} start When the chapter starts in integer timestamp format (defined by time_base)
* @property {string} start_time The string representation of a floating point integer showing when the chapter starts in seconds
* @property {number} end When the chapter end in integer timestamp format (defined by time_base)
* @property {string} end_time The string representation of a floating point integer showing when the chapter ends in seconds
* @property {FFprobeChapterTags} tags The chapter's tags
*/
/**
* @typedef FFprobeFormatTags
* @property {string} major_brand Not clear, probably the media type brand, but not sure
* @property {string} minor_version The brand version perhaps, but not sure
* @property {string} compatible_brands The brands that are compatible with the referenced brands perhaps, but not sure
* @property {string} [title] The media's title (song metadata uses an all uppercase version)
* @property {string} [artist] The media artist (song metadata uses an all uppercase version)
* @property {string} [date] The media's creation date, seems to be in YYYYMMDD format (song metadata uses an all uppercase version)
* @property {string} [encoder] The name of the encoder responsible for encoding the media
* @property {string} [comment] The comment attached to the file
* @property {string} [description] The description attached to the file
* @property {string} [creation_time] The ISO-formatted date (although it may use other formats) when the media was created
* @property {string} [ALBUM] The album (only present in audio files)
* @property {string} [album_artist] The album arist (only present in audio files)
* @property {string} [ALBUMARTISTSORT] The album artist name used for sorting probably (only present in audio files)
* @property {string} [ARTIST] The song artist (only present in audio files)
* @property {string} [DATE] The date when the song was created (no particular format, often the year) (only present in audio files)
* @property {string} [disc] The string representation of an integer showing the song's disc number (only present in audio files)
* @property {string} [DISCTOTAL] The string representation of an integer showing the total number of discs comprising the album the song is in (only present in audio files)
* @property {string} [ISRC] The song's International Standard Recording Code
* @property {string} [GENRE] The song's genre (only present in audio files)
* @property {string} [TITLE] The song's title (only present in audio files)
* @property {string} [track] The string representation of an integer showing the song's track number (only present in audio files)
* @property {string} [TRACKTOTAL] The string representation of an integer showing the total number of tracks in the album the song is in (only present in audio files)
* @property {string} [YEAR] The string representation of an integer showing the year the song was created (only present in audio files)
* @property {string} [BPM] The string representation of an integer showing the song's BPM (only present in audio files)
* @property {string} [PUBLISHER] The song's publisher (only present in audio files)
*/
/**
* @typedef FFprobeFormat
* @property {string} filename The path of the probed file (as specified in the input file argument)
* @property {number} nb_streams The total number of streams present
* @property {number} nb_programs The total number of programs present
* @property {string} format_name The name of the format (a comma separated list of applicable file extensions for the format)
* @property {string} format_long_name The long (detailed) name of the format
* @property {string} start_time The string representation of a floating point integer showing the file's starting time
* @property {string} duration The string representation of a floating point integer showing the file's duration in seconds (seems to be a non-accurate, rounded version of the real duration)
* @property {string} size The string representation of a long integer showing the file's size in bytes
* @property {string} bit_rate The string representation of a long integer showing the file's stated bitrate (may vary between streams, probably applies to just video if a video file)
* @property {number} probe_score A score of how confident FFprobe is of the format, 0 to 100. https://stackoverflow.com/questions/25257986/what-does-probe-score-mean-in-ffprobe-output
* @property {FFprobeFormatTags} [tags] The format's tags
*/
/**
* @typedef FFprobeProbeError
* @property {number} code The error code
* @property {string} string The error message
*/
/**
* @typedef FFprobeProbeResult
* @property {Array<FFprobeStream>} [streams] The probed file's streams (-show_streams flag required)
* @property {Array<FFprobeChapter>} [chapters] The probed file's chapters (-show_chapters flag required)
* @property {FFprobeFormat} [format] The probed file's format data (-show_format flag required)
* @property {FFprobeProbeError} [error] The error that occurred when trying to probe the file (-show_error flag required)
*/
/**
* The "disposition" field on an FFprobe response stream object
*/
export interface FFprobeStreamDisposition {
/**
* 1 if the default track
*/
default: 1 | 0,
/**
* 1 if a dub track
*/
dub: 1 | 0,
/**
* 1 if the original track
*/
original: 1 | 0,
/**
* 1 if a comment track
*/
comment: 1 | 0,
/**
* 1 if a lyrics track
*/
lyrics: 1 | 0,
/**
* 1 if a karaoke track
*/
karaoke: 1 | 0,
/**
* 1 if a forced track
*/
forced: 1 | 0,
/**
* 1 if a track for the hearing impaired
*/
hearing_impaired: 1 | 0,
/**
* 1 if a track for the visually impaired
*/
visual_impaired: 1 | 0,
/**
* 1 if a clean effects track
*/
clean_effects: 1 | 0,
/**
* 1 if an attached picture track
*/
attached_pic: 1 | 0,
/**
* 1 if a timed thumbnails track (perhaps like the preview thumbnails you get when scrolling over a YouTube video's seek bar)
*/
timed_thumbnails: 1 | 0,
/**
* 1 if a captions track
*/
captions: 1 | 0,
/**
* 1 if a descriptions track
*/
descriptions: 1 | 0,
/**
* 1 if a metadata track
*/
metadata: 1 | 0,
/**
* 1 if a dependent track (unclear meaning)
*/
dependent: 1 | 0,
/**
* 1 if a still image track
*/
still_image: 1 | 0
}
/**
* The "tags" field on an FFprobe response stream object
*/
export interface FFprobeStreamTags {
/**
* The track's language (usually represented using a 3 letter language code, e.g.: "eng")
*/
language?: string,
/**
* The name of the handler which produced the track
*/
handler_name?: string,
/**
* The ID of the vendor which produced the track
*/
vendor_id?: string,
/**
* The name of the encoder responsible for creating the stream
*/
encoder?: string,
/**
* The date (often ISO-formatted, but it may use other formats) when the media was created
*/
creation_time?: string,
/**
* The comment attached to the stream
*/
comment?: string
rotate?: string,
}
/**
* An FFprobe response stream object
*/
export interface FFprobeStream {
/**
* The stream index
*/
index: number,
/**
* The codec's name
*/
codec_name: string,
/**
* The codec's long (detailed) name
*/
codec_long_name: string,
/**
* The codec profile
*/
profile: string,
/**
* The type of codec (video, audio, subtitle, etc.)
*/
codec_type: 'video' | 'audio' | 'subtitle' | 'attachment' | 'data',
/**
* The codec tag (technical name)
*/
codec_tag_string: string,
/**
* The codec tag ID
*/
codec_tag: string,
/**
* The audio sample format (not present if codec_type is not "audio")
*/
sample_fmt?: string,
/**
* A string representation of an integer showing the audio sample rate (not present if codec_type is not "audio")
*/
sample_rate?: string,
/**
* The audio track's channel count (not present if codec_type is not "audio")
*/
channels?: number,
/**
* The audio track's channel layout (e.g. "stereo") (not present if codec_type is not "audio")
*/
channel_layout?: 'stereo' | 'mono',
/**
* Bits per audio sample (might not be accurate, may just be 0) (not present if codec_type is not "audio")
*/
bits_per_sample?: number,
/**
* The video stream width (also available for images) (not present if codec_type is not "video")
*/
width?: number,
/**
* The stream height (also available for images) (not present if codec_type is not "video")
*/
height?: number,
/**
* The stream's coded width (shouldn't vary from "width") (not present if codec_type is not "video")
*/
coded_width?: number,
/**
* The stream's coded height (shouldn't vary from "height") (not present if codec_type is not "video")
*/
coded_height?: number,
/**
* Set to 1 if closed captions are present in stream... I think (not present if codec_type is not "video")
*/
closed_captions?: 1 | 0 | number,
/**
* Set to 1 if the stream has b-frames... I think (not present if codec_type is not "video")
*/
has_b_frames?: 1 | 0 | number,
/**
* The sample aspect ratio (you probably want "display_aspect_ratio") (not present if codec_type is not "video")
*/
sample_aspect_ratio?: string,
/**
* The display (real) aspect ratio (e.g. "16:9") (not present if codec_type is not "video")
*/
display_aspect_ratio?: string,
/**
* The pixel format used (not present if codec_type is not "video")
*/
pix_fmt?: string,
/**
* Unknown (not present if codec_type is not "video")
*/
level?: number,
/**
* The color range used (e.g. "tv") (not present if codec_type is not "video")
*/
color_range?: string,
/**
* The color space used (not present if codec_type is not "video")
*/
color_space?: string,
/**
* The color transfer used (not present if codec_type is not "video")
*/
color_transfer?: string,
/**
* The color primaries used (not present if codec_type is not "video")
*/
color_primaries?: string,
/**
* The chroma location (not present if codec_type is not "video")
*/
chroma_location?: string,
/**
* Unknown (not present if codec_type is not "video")
*/
refs?: number,
/**
* Whether the stream is AVC (not present if codec_type is not "video")
*/
is_avc?: 'true' | 'false',
/**
* Unknown string representing a number (not present if codec_type is not "video")
*/
nal_length_size?: string,
/**
* Odd formatting of the frame rate, possibly "real frame rate"? (e.g. "30/1")
*/
r_frame_rate: string,
/**
* Odd formatting of the average frame rate (e.g. "30/1")
*/
avg_frame_rate: string,
/**
* The division equation to use for converting integer representations of timestamps into seconds (e.g. "1/30000" turns 80632552 into 2687.751733 seconds)
*/
time_base: string,
/**
* Unknown
*/
start_pts: number,
/**
* A string representation of a floating point integer showing the start time in seconds
*/
start_time: string,
/**
* The stream's duration in integer timestamp format (defined by time_base)
*/
duration_ts: number,
/**
* A string representation of a floating point integer showing the stream duration in seconds
*/
duration: string,
/**
* The string representation of an integer showing the stream bit rate (not present on lossless formats such as FLAC)
*/
bit_rate?: string,
/**
* A string representation of an integer showing the bits per raw sample (not present if codec_type is not "video")
*/
bits_per_raw_sample?: string,
/**
* A string representation of an integer showing the total number of frames in the stream
*/
nb_frames: string,
/**
* The stream's disposition
*/
disposition: FFprobeStreamDisposition,
/**
* The stream's tags
*/
tags?: FFprobeStreamTags
}
/**
* The "tags" field on an FFprobe response chapter object
*/
export interface FFprobeChapterTags {
/**
* The chapter title
*/
title: string
}
/**
* An FFprobe response chapter object
*/
export interface FFprobeChapter {
/**
* The chapter ID
*/
id: number,
/**
* The division equation to use for converting integer representations of timestamps into seconds (e.g. "1/30000" turns 80632552 into 2687.751733 seconds)
*/
time_base: string,
/**
* When the chapter starts in integer timestamp format (defined by time_base)
*/
start: number,
/**
* The string representation of a floating point integer showing when the chapter starts in seconds
*/
start_time: string,
/**
* When the chapter end in integer timestamp format (defined by time_base)
*/
end: number,
/**
* The string representation of a floating point integer showing when the chapter ends in seconds
*/
end_time: string,
/**
* The chapter's tags
*/
tags: FFprobeChapterTags
}
/**
* The "tags" field on an FFprobe response format object
*/
export interface FFprobeFormatTags {
/**
* Not clear, probably the media type brand, but not sure
*/
major_brand: string,
/**
* The brand version perhaps, but not sure
*/
minor_version: string,
/**
* The brands that are compatible with the referenced brands perhaps, but not sure
*/
compatible_brands: string,
/**
* The media's title (song metadata uses an all uppercase version)
*/
title?: string,
/**
* The media artist (song metadata uses an all uppercase version)
*/
artist?: string,
/**
* The media's creation date, seems to be in YYYYMMDD format (song metadata uses an all uppercase version)
*/
date?: string,
/**
* The name of the encoder responsible for encoding the media
*/
encoder?: string,
/**
* The comment attached to the file
*/
comment?: string,
/**
* The description attached to the file
*/
description?: string,
/**
* The ISO-formatted date (although it may use other formats) when the media was created
*/
creation_time?: string,
/**
* The album (only present in audio files)
*/
ALBUM?: string,
/**
* The album arist (only present in audio files)
*/
album_artist?: string,
/**
* The album artist name used for sorting probably (only present in audio files)
*/
ALBUMARTISTSORT?: string,
/**
* The song artist (only present in audio files)
*/
ARTIST?: string,
/**
* The date when the song was created (no particular format, often the year) (only present in audio files)
*/
DATE?: string,
/**
* The string representation of an integer showing the song's disc number (only present in audio files)
*/
disc?: string,
/**
* The string representation of an integer showing the total number of discs comprising the album the song is in (only present in audio files)
*/
DISCTOTAL?: string,
/**
* The song's International Standard Recording Code
*/
ISRC?: string,
/**
* The song's genre (only present in audio files)
*/
GENRE?: string,
/**
* The song's title (only present in audio files)
*/
TITLE?: string,
/**
* The string representation of an integer showing the song's track number (only present in audio files)
*/
track?: string,
/**
* The string representation of an integer showing the total number of tracks in the album the song is in (only present in audio files)
*/
TRACKTOTAL?: string,
/**
* The string representation of an integer showing the year the song was created (only present in audio files)
*/
YEAR?: string,
/**
* The string representation of an integer showing the song's BPM (only present in audio files)
*/
BPM?: string,
/**
* The song's publisher (only present in audio files)
*/
PUBLISHER?: string
}
/**
* An FFprobe response format object
*/
export interface FFprobeFormat {
/**
* The path of the probed file (as specified in the input file argument)
*/
filename: string,
/**
* The total number of streams present
*/
nb_streams: number,
/**
* The total number of programs present
*/
nb_programs: number,
/**
* The name of the format (a comma separated list of applicable file extensions for the format)
*/
format_name: string,
/**
* The long (detailed) name of the format
*/
format_long_name: string,
/**
* The string representation of a floating point integer showing the file's starting time
*/
start_time: string,
/**
* The string representation of a floating point integer showing the file's duration in seconds (seems to be a non-accurate, rounded version of the real duration)
*/
duration: string,
/**
* The string representation of a long integer showing the file's size in bytes
*/
size: string,
/**
* The string representation of a long integer showing the file's stated bitrate (may vary between streams, probably applies to just video if a video file)
*/
bit_rate: string,
/**
* A score of how confident FFprobe is of the format, 0 to 100. https://stackoverflow.com/questions/25257986/what-does-probe-score-mean-in-ffprobe-output
*/
probe_score: number,
/**
* The format's tags
*/
tags?: FFprobeFormatTags
}
/**
* An FFprobe error object
*/
export interface FFprobeProbeError {
/**
* The error code
*/
code: number,
/**
* The error message
*/
string: string
}
/**
* An FFprobe probe result object
*/
export interface FFprobeProbeResult {
/**
* The probed file's streams (-show_streams flag required)
*/
streams?: FFprobeStream[],
/**
* The probed file's chapters (-show_chapters flag required)
*/
chapters?: FFprobeChapter[],
/**
* The probed file's format data (-show_format flag required)
*/
format?: FFprobeFormat,
/**
* The error that occurred when trying to probe the file (-show_error flag required)
*/
error?: FFprobeProbeError
}

Wyświetl plik

@ -152,6 +152,7 @@ async function runFfprobe(args, { timeout = isDev ? 10000 : 30000 } = {}) {
}
}
/** @type {(a: any) => Promise<import('../types').Waveform>} */
async function renderWaveformPng({ filePath, start, duration, color, streamIndex }) {
const args1 = [
'-hide_banner',

Wyświetl plik

@ -29,7 +29,7 @@ import useFrameCapture from './hooks/useFrameCapture';
import useSegments from './hooks/useSegments';
import useDirectoryAccess, { DirectoryAccessDeclinedError } from './hooks/useDirectoryAccess';
import { UserSettingsContext, SegColorsContext } from './contexts';
import { UserSettingsContext, SegColorsContext, UserSettingsContextType } from './contexts';
import NoFileLoaded from './NoFileLoaded';
import MediaSourcePlayer from './MediaSourcePlayer';
@ -86,8 +86,9 @@ import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './uti
import BigWaveform from './components/BigWaveform';
import isDev from './isDev';
import { EdlFileType, FfmpegCommandLog, FfprobeChapter, FfprobeFormat, FfprobeStream, FormatTimecode, Html5ifyMode, PlaybackMode, SegmentColorIndex, SegmentTags, SegmentToExport, StateSegment, Thumbnail, TunerType } from './types';
import { ChromiumHTMLVideoElement, EdlFileType, FfmpegCommandLog, FormatTimecode, Html5ifyMode, PlaybackMode, SegmentColorIndex, SegmentTags, SegmentToExport, StateSegment, Thumbnail, TunerType } from './types';
import { CaptureFormat, KeyboardAction } from '../types';
import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../ffprobe';
const electron = window.require('electron');
const { exists } = window.require('fs-extra');
@ -124,11 +125,11 @@ function App() {
const [cutProgress, setCutProgress] = useState<number>();
const [startTimeOffset, setStartTimeOffset] = useState(0);
const [filePath, setFilePath] = useState<string>();
const [externalFilesMeta, setExternalFilesMeta] = useState({});
const [externalFilesMeta, setExternalFilesMeta] = useState<Record<string, { streams: FFprobeStream[], formatData: FFprobeFormat, chapters: FFprobeChapter[] }>>({});
const [customTagsByFile, setCustomTagsByFile] = useState({});
const [paramsByStreamId, setParamsByStreamId] = useState(new Map());
const [detectedFps, setDetectedFps] = useState<number>();
const [mainFileMeta, setMainFileMeta] = useState<{ streams: FfprobeStream[], formatData: FfprobeFormat, chapters?: FfprobeChapter[] }>({ streams: [], formatData: {} });
const [mainFileMeta, setMainFileMeta] = useState<{ streams: FFprobeStream[], formatData: FFprobeFormat, chapters: FFprobeChapter[] }>();
const [copyStreamIdsByFile, setCopyStreamIdsByFile] = useState<Record<string, Record<string, boolean>>>({});
const [streamsSelectorShown, setStreamsSelectorShown] = useState(false);
const [concatDialogVisible, setConcatDialogVisible] = useState(false);
@ -149,7 +150,7 @@ function App() {
const { fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, isCustomFormatSelected } = useFileFormatState();
// State per application launch
const lastOpenedPathRef = useRef();
const lastOpenedPathRef = useRef<string>();
const [waveformMode, setWaveformMode] = useState<'big-waveform' | 'waveform'>();
const [thumbnailsEnabled, setThumbnailsEnabled] = useState(false);
const [keyframesEnabled, setKeyframesEnabled] = useState(true);
@ -209,7 +210,7 @@ function App() {
electron.ipcRenderer.send('setLanguage', l);
}, [language]);
const videoRef = useRef<HTMLVideoElement>(null);
const videoRef = useRef<ChromiumHTMLVideoElement>(null);
const videoContainerRef = useRef<HTMLDivElement>(null);
const setOutputPlaybackRate = useCallback((v) => {
@ -304,15 +305,15 @@ function App() {
commandedTimeRef.current = commandedTime;
}, [commandedTime]);
const mainStreams = useMemo(() => mainFileMeta.streams, [mainFileMeta.streams]);
const mainFileFormatData = useMemo(() => mainFileMeta.formatData, [mainFileMeta.formatData]);
const mainFileChapters = useMemo(() => mainFileMeta.chapters, [mainFileMeta.chapters]);
const mainStreams = useMemo(() => mainFileMeta?.streams ?? [], [mainFileMeta?.streams]);
const mainFileFormatData = useMemo(() => mainFileMeta?.formatData, [mainFileMeta?.formatData]);
const mainFileChapters = useMemo(() => mainFileMeta?.chapters, [mainFileMeta?.chapters]);
const isCopyingStreamId = useCallback((path, streamId) => (
!!(copyStreamIdsByFile[path] || {})[streamId]
), [copyStreamIdsByFile]);
const checkCopyingAnyTrackOfType = useCallback((filter) => mainStreams.some((stream) => isCopyingStreamId(filePath, stream.index) && filter(stream)), [filePath, isCopyingStreamId, mainStreams]);
const checkCopyingAnyTrackOfType = useCallback((filter: (s: FFprobeStream) => boolean) => mainStreams.some((stream) => isCopyingStreamId(filePath, stream.index) && filter(stream)), [filePath, isCopyingStreamId, mainStreams]);
const copyAnyAudioTrack = useMemo(() => checkCopyingAnyTrackOfType((stream) => stream.codec_type === 'audio'), [checkCopyingAnyTrackOfType]);
const subtitleStreams = useMemo(() => getSubtitleStreams(mainStreams), [mainStreams]);
@ -328,7 +329,7 @@ function App() {
// 360 means we don't modify rotation gtrgt
const isRotationSet = rotation !== 360;
const effectiveRotation = useMemo(() => (isRotationSet ? rotation : (activeVideoStream?.tags?.rotate && parseInt(activeVideoStream.tags.rotate, 10))), [isRotationSet, activeVideoStream, rotation]);
const effectiveRotation = useMemo(() => (isRotationSet ? rotation : (activeVideoStream?.tags?.rotate ? parseInt(activeVideoStream.tags.rotate, 10) : undefined)), [isRotationSet, activeVideoStream, rotation]);
const zoomRel = useCallback((rel) => setZoom((z) => Math.min(Math.max(z + (rel * (1 + (z / 10))), 1), zoomMax)), []);
const compatPlayerRequired = usingDummyVideo;
@ -568,7 +569,7 @@ function App() {
}), [hideAllNotifications, setInvertCutSegments, setSimpleMode]);
const effectiveExportMode = useMemo(() => {
if (segmentsToChaptersOnly) return 'sesgments_to_chapters';
if (segmentsToChaptersOnly) return 'segments_to_chapters';
if (autoMerge && autoDeleteMergedSegments) return 'merge';
if (autoMerge) return 'merge+separate';
return 'separate';
@ -603,7 +604,7 @@ function App() {
setStoreProjectInWorkingDir(newValue);
}, [ensureAccessToSourceDir, getProjectFileSavePath, setStoreProjectInWorkingDir, storeProjectInWorkingDir]);
const userSettingsContext = useMemo(() => ({
const userSettingsContext = useMemo<UserSettingsContextType>(() => ({
...allUserSettings, toggleCaptureFormat, changeOutDir, toggleKeyframeCut, togglePreserveMovData, toggleMovFastStart, toggleExportConfirmEnabled, toggleSegmentsToChapters, togglePreserveMetadataOnMerge, toggleSimpleMode, toggleSafeOutputFileName, effectiveExportMode,
}), [allUserSettings, changeOutDir, effectiveExportMode, toggleCaptureFormat, toggleExportConfirmEnabled, toggleKeyframeCut, toggleMovFastStart, togglePreserveMetadataOnMerge, togglePreserveMovData, toggleSafeOutputFileName, toggleSegmentsToChapters, toggleSimpleMode]);
@ -671,7 +672,7 @@ function App() {
const allFilesMeta = useMemo(() => ({
...externalFilesMeta,
...(filePath ? { [filePath]: mainFileMeta } : {}),
...(filePath && mainFileMeta != null ? { [filePath]: mainFileMeta } : {}),
}), [externalFilesMeta, filePath, mainFileMeta]);
// total number of streams for ALL files
@ -746,7 +747,7 @@ function App() {
const shouldShowWaveform = calcShouldShowWaveform(zoomedDuration);
const { neighbouringKeyFrames, findNearestKeyFrameTime } = useKeyframes({ keyframesEnabled, filePath, commandedTime, videoStream: activeVideoStream, detectedFps, ffmpegExtractWindow });
const { waveforms } = useWaveform({ darkMode, filePath, relevantTime, waveformEnabled, audioStream: activeAudioStream, shouldShowWaveform, ffmpegExtractWindow, durationSafe });
const { waveforms } = useWaveform({ darkMode, filePath, relevantTime, waveformEnabled, audioStream: activeAudioStream, ffmpegExtractWindow, durationSafe });
const resetMergedOutFileName = useCallback(() => {
if (fileFormat == null || filePath == null) return;
@ -784,7 +785,7 @@ function App() {
setCustomTagsByFile({});
setParamsByStreamId(new Map());
setDetectedFps(undefined);
setMainFileMeta({ streams: [], formatData: [] });
setMainFileMeta(undefined);
setCopyStreamIdsByFile({});
setStreamsSelectorShown(false);
setZoom(1);
@ -1298,6 +1299,7 @@ function App() {
if (!exportConfirmEnabled) notices.push(i18n.t('Export options are not shown. You can enable export options by clicking the icon right next to the export button.'));
invariant(mainFileFormatData != null);
// https://github.com/mifi/lossless-cut/issues/329
if (isIphoneHevc(mainFileFormatData, mainStreams)) warnings.push(i18n.t('There is a known issue with cutting iPhone HEVC videos. The output file may not work in all players.'));
@ -1648,7 +1650,7 @@ function App() {
}, [ensureAccessToSourceDir, loadMedia]);
// todo merge with userOpenFiles?
const batchOpenSingleFile = useCallback(async (path) => {
const batchOpenSingleFile = useCallback(async (path: string) => {
if (workingRef.current) return;
if (filePath === path) return;
try {
@ -1664,7 +1666,7 @@ function App() {
const batchFileJump = useCallback((direction: number, alsoOpen: boolean) => {
if (batchFiles.length === 0) return;
let newSelectedBatchFiles: string[];
let newSelectedBatchFiles: [string];
if (selectedBatchFiles.length === 0) {
newSelectedBatchFiles = [batchFiles[0]!.path];
} else {
@ -1681,8 +1683,9 @@ function App() {
}, [batchFiles, batchOpenSingleFile, selectedBatchFiles]);
const batchOpenSelectedFile = useCallback(() => {
if (selectedBatchFiles.length === 0) return;
batchOpenSingleFile(selectedBatchFiles[0]);
const [firstSelectedBatchFile] = selectedBatchFiles;
if (firstSelectedBatchFile == null) return;
batchOpenSingleFile(firstSelectedBatchFile);
}, [batchOpenSingleFile, selectedBatchFiles]);
const onBatchFileSelect = useCallback((path: string) => {
@ -1814,10 +1817,10 @@ function App() {
setter(fileMap.get(streamId));
})), [setParamsByStreamId]);
const addFileAsCoverArt = useCallback(async (path) => {
const addFileAsCoverArt = useCallback(async (path: string) => {
const fileMeta = await addStreamSourceFile(path);
if (!fileMeta) return false;
const firstIndex = fileMeta.streams[0].index;
const firstIndex = fileMeta.streams[0]!.index;
updateStreamParams(path, firstIndex, (params) => params.set('disposition', 'attached_pic'));
return true;
}, [addStreamSourceFile, updateStreamParams]);
@ -1852,14 +1855,14 @@ function App() {
});
}, []);
const userOpenFiles = useCallback(async (filePathsIn) => {
const userOpenFiles = useCallback(async (filePathsIn?: string[]) => {
let filePaths = filePathsIn;
if (!filePaths || filePaths.length === 0) return;
console.log('userOpenFiles');
console.log(filePaths.join('\n'));
[lastOpenedPathRef.current] = filePaths;
lastOpenedPathRef.current = filePaths[0]!;
// first check if it is a single directory, and if so, read it recursively
if (filePaths.length === 1) {
@ -1895,7 +1898,8 @@ function App() {
}
// filePaths.length is now 1
const firstFilePath = filePaths[0];
const [firstFilePath] = filePaths;
invariant(firstFilePath != null);
// https://en.wikibooks.org/wiki/Inside_DVD-Video/Directory_Structure
if (/^video_ts$/i.test(basename(firstFilePath))) {
@ -2461,7 +2465,6 @@ function App() {
<ThemeProvider value={theme}>
<div className={darkMode ? 'dark-theme' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100vh', color: 'var(--gray12)', background: 'var(--gray1)', transition: darkModeTransition }}>
<TopMenu
// @ts-expect-error todo
filePath={filePath}
fileFormat={fileFormat}
copyAnyAudioTrack={copyAnyAudioTrack}
@ -2521,7 +2524,7 @@ function App() {
{renderSubtitles()}
</video>
{compatPlayerEnabled && <MediaSourcePlayer rotate={effectiveRotation} filePath={filePath} videoStream={activeVideoStream} audioStream={activeAudioStream} playerTime={playerTime} commandedTime={commandedTime} playing={playing} eventId={compatPlayerEventId} masterVideoRef={videoRef} mediaSourceQuality={mediaSourceQuality} playbackVolume={playbackVolume} />}
{filePath != null && compatPlayerEnabled && <MediaSourcePlayer rotate={effectiveRotation} filePath={filePath} videoStream={activeVideoStream} audioStream={activeAudioStream} playerTime={playerTime ?? 0} commandedTime={commandedTime} playing={playing} eventId={compatPlayerEventId} masterVideoRef={videoRef} mediaSourceQuality={mediaSourceQuality} playbackVolume={playbackVolume} />}
</div>
{bigWaveformEnabled && <BigWaveform waveforms={waveforms} relevantTime={relevantTime} playing={playing} durationSafe={durationSafe} zoom={zoomUnrounded} seekRel={seekRel} />}
@ -2698,8 +2701,7 @@ function App() {
/>
</div>
{/* @ts-expect-error todo */}
<ExportConfirm filePath={filePath} areWeCutting={areWeCutting} nonFilteredSegmentsOrInverse={nonFilteredSegmentsOrInverse} selectedSegments={selectedSegmentsOrInverse} segmentsToExport={segmentsToExport} willMerge={willMerge} visible={exportConfirmVisible} onClosePress={closeExportConfirm} onExportConfirm={onExportConfirm} renderOutFmt={renderOutFmt} outputDir={outputDir} numStreamsTotal={numStreamsTotal} numStreamsToCopy={numStreamsToCopy} onShowStreamsSelectorClick={handleShowStreamsSelectorClick} outFormat={fileFormat} setOutSegTemplate={setOutSegTemplate} outSegTemplate={outSegTemplateOrDefault} generateOutSegFileNames={generateOutSegFileNames} currentSegIndexSafe={currentSegIndexSafe} mainCopiedThumbnailStreams={mainCopiedThumbnailStreams} needSmartCut={needSmartCut} mergedOutFileName={mergedOutFileName} setMergedOutFileName={setMergedOutFileName} />
<ExportConfirm areWeCutting={areWeCutting} nonFilteredSegmentsOrInverse={nonFilteredSegmentsOrInverse} selectedSegments={selectedSegmentsOrInverse} segmentsToExport={segmentsToExport} willMerge={willMerge} visible={exportConfirmVisible} onClosePress={closeExportConfirm} onExportConfirm={onExportConfirm} renderOutFmt={renderOutFmt} outputDir={outputDir} numStreamsTotal={numStreamsTotal} numStreamsToCopy={numStreamsToCopy} onShowStreamsSelectorClick={handleShowStreamsSelectorClick} outFormat={fileFormat} setOutSegTemplate={setOutSegTemplate} outSegTemplate={outSegTemplateOrDefault} generateOutSegFileNames={generateOutSegFileNames} currentSegIndexSafe={currentSegIndexSafe} mainCopiedThumbnailStreams={mainCopiedThumbnailStreams} needSmartCut={needSmartCut} mergedOutFileName={mergedOutFileName} setMergedOutFileName={setMergedOutFileName} />
<Sheet visible={streamsSelectorShown} onClosePress={() => setStreamsSelectorShown(false)} maxWidth={1000}>
{mainStreams && (

Wyświetl plik

@ -1,8 +1,10 @@
import { useEffect, useRef, useState, useCallback, useMemo, memo, CSSProperties } from 'react';
import { useEffect, useRef, useState, useCallback, useMemo, memo, CSSProperties, RefObject } from 'react';
import { Spinner } from 'evergreen-ui';
import { useDebounce } from 'use-debounce';
import isDev from './isDev';
import { ChromiumHTMLVideoElement } from './types';
import { FFprobeStream } from '../ffprobe';
const remote = window.require('@electron/remote');
const { createMediaSourceStream, readOneJpegFrame } = remote.require('./compatPlayer');
@ -10,7 +12,7 @@ const { createMediaSourceStream, readOneJpegFrame } = remote.require('./compatPl
async function startPlayback({ path, video, videoStreamIndex, audioStreamIndex, seekTo, signal, playSafe, onCanPlay, getTargetTime, size, fps }: {
path: string,
video: HTMLVideoElement,
video: ChromiumHTMLVideoElement,
videoStreamIndex?: number | undefined,
audioStreamIndex?: number | undefined,
seekTo: number,
@ -253,7 +255,9 @@ async function createPauseImage({ path, seekTo, videoStreamIndex, canvas, signal
drawJpegFrame(canvas, jpegImage);
}
function MediaSourcePlayer({ rotate, filePath, playerTime, videoStream, audioStream, commandedTime, playing, eventId, masterVideoRef, mediaSourceQuality, playbackVolume }) {
function MediaSourcePlayer({ rotate, filePath, playerTime, videoStream, audioStream, commandedTime, playing, eventId, masterVideoRef, mediaSourceQuality, playbackVolume }: {
rotate: number | undefined, filePath: string, playerTime: number, videoStream: FFprobeStream | undefined, audioStream: FFprobeStream | undefined, commandedTime: number, playing: boolean, eventId: number, masterVideoRef: RefObject<HTMLVideoElement>, mediaSourceQuality: number, playbackVolume: number,
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [loading, setLoading] = useState(true);
@ -292,7 +296,7 @@ function MediaSourcePlayer({ rotate, filePath, playerTime, videoStream, audioStr
}
const onCanPlay = () => setLoading(false);
const getTargetTime = () => masterVideoRef.current.currentTime - debouncedState.startTime;
const getTargetTime = () => masterVideoRef.current!.currentTime - debouncedState.startTime;
const abortController = new AbortController();

Wyświetl plik

@ -1,4 +1,4 @@
import { memo, useCallback } from 'react';
import { CSSProperties, ReactNode, memo, useCallback } from 'react';
import { IoIosSettings } from 'react-icons/io';
import { FaLock, FaUnlock } from 'react-icons/fa';
import { CrossIcon, ListIcon, VolumeUpIcon, VolumeOffIcon } from 'evergreen-ui';
@ -16,9 +16,31 @@ const outFmtStyle = { height: 20, maxWidth: 100 };
const exportModeStyle = { flexGrow: 0, flexBasis: 140 };
const TopMenu = memo(({
filePath, fileFormat, copyAnyAudioTrack, toggleStripAudio,
renderOutFmt, numStreamsToCopy, numStreamsTotal, setStreamsSelectorShown, toggleSettings,
selectedSegments, isCustomFormatSelected, clearOutDir,
filePath,
fileFormat,
copyAnyAudioTrack,
toggleStripAudio,
renderOutFmt,
numStreamsToCopy,
numStreamsTotal,
setStreamsSelectorShown,
toggleSettings,
selectedSegments,
isCustomFormatSelected,
clearOutDir,
}: {
filePath: string | undefined,
fileFormat: string | undefined,
copyAnyAudioTrack: boolean,
toggleStripAudio: () => void,
renderOutFmt: (style: CSSProperties) => ReactNode,
numStreamsToCopy: number,
numStreamsTotal: number,
setStreamsSelectorShown: (v: boolean) => void,
toggleSettings: () => void,
selectedSegments,
isCustomFormatSelected,
clearOutDir,
}) => {
const { t } = useTranslation();
const { customOutDir, changeOutDir, simpleMode, outFormatLocked, setOutFormatLocked } = useUserSettings();
@ -40,7 +62,7 @@ const TopMenu = memo(({
{filePath && (
<>
<Button onClick={withBlur(() => setStreamsSelectorShown(true))}>
<ListIcon size="1em" verticalAlign="middle" marginRight=".3em" />
<ListIcon size={'1em' as unknown as number} verticalAlign="middle" marginRight=".3em" />
{t('Tracks')} ({numStreamsToCopy}/{numStreamsTotal})
</Button>
@ -49,9 +71,9 @@ const TopMenu = memo(({
onClick={withBlur(toggleStripAudio)}
>
{copyAnyAudioTrack ? (
<><VolumeUpIcon size="1em" verticalAlign="middle" marginRight=".3em" />{t('Keep audio')}</>
<><VolumeUpIcon size={'1em' as unknown as number} verticalAlign="middle" marginRight=".3em" />{t('Keep audio')}</>
) : (
<><VolumeOffIcon size="1em" verticalAlign="middle" marginRight=".3em" />{t('Discard audio')}</>
<><VolumeOffIcon size={'1em' as unknown as number} verticalAlign="middle" marginRight=".3em" />{t('Discard audio')}</>
)}
</Button>
</>

Wyświetl plik

@ -1,10 +1,10 @@
import { memo, useEffect, useState, useCallback, useRef } from 'react';
import { ffmpegExtractWindow } from '../util/constants';
import { Waveform } from '../types';
import { RenderableWaveform } from '../types';
const BigWaveform = memo(({ waveforms, relevantTime, playing, durationSafe, zoom, seekRel }: {
waveforms: Waveform[], relevantTime: number, playing: boolean, durationSafe: number, zoom: number, seekRel: (a: number) => void,
waveforms: RenderableWaveform[], relevantTime: number, playing: boolean, durationSafe: number, zoom: number, seekRel: (a: number) => void,
}) => {
const windowSize = ffmpegExtractWindow * 2;
const windowStart = Math.max(0, relevantTime - windowSize);

Wyświetl plik

@ -1,10 +0,0 @@
import { memo } from 'react';
import styles from './Button.module.css';
const Button = memo(({ type = 'button', ...props }) => (
// eslint-disable-next-line react/jsx-props-no-spreading, react/button-has-type
<button className={styles.button} type={type} {...props} />
));
export default Button;

Wyświetl plik

@ -0,0 +1,10 @@
import { ButtonHTMLAttributes, memo } from 'react';
import styles from './Button.module.css';
const Button = memo(({ type = 'button', ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => (
// eslint-disable-next-line react/jsx-props-no-spreading, react/button-has-type
<button className={styles['button']} type={type} {...props} />
));
export default Button;

Wyświetl plik

@ -13,6 +13,7 @@ import OutputFormatSelect from './OutputFormatSelect';
import useUserSettings from '../hooks/useUserSettings';
import { isMov } from '../util/streams';
import { getOutFileExtension, getSuffixedFileName } from '../util';
import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../ffprobe';
const { basename } = window.require('path');
@ -25,18 +26,14 @@ const rowStyle: CSSProperties = {
};
const ConcatDialog = memo(({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFiles, setAlwaysConcatMultipleFiles }: {
// todo
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isShown: boolean, onHide: () => void, paths: string[], onConcat: (a: { paths: string[], includeAllStreams: boolean, streams: any, outFileName: string, fileFormat: string, clearBatchFilesAfterConcat: boolean }) => Promise<void>, alwaysConcatMultipleFiles: boolean, setAlwaysConcatMultipleFiles: (a: boolean) => void,
isShown: boolean, onHide: () => void, paths: string[], onConcat: (a: { paths: string[], includeAllStreams: boolean, streams: FFprobeStream[], outFileName: string, fileFormat: string, clearBatchFilesAfterConcat: boolean }) => Promise<void>, alwaysConcatMultipleFiles: boolean, setAlwaysConcatMultipleFiles: (a: boolean) => void,
}) => {
const { t } = useTranslation();
const { preserveMovData, setPreserveMovData, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge } = useUserSettings();
const [includeAllStreams, setIncludeAllStreams] = useState(false);
// todo
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [fileMeta, setFileMeta] = useState<{ format: any, streams: any, chapters: any }>();
const [allFilesMetaCache, setAllFilesMetaCache] = useState({});
const [fileMeta, setFileMeta] = useState<{ format: FFprobeFormat, streams: FFprobeStream[], chapters: FFprobeChapter[] }>();
const [allFilesMetaCache, setAllFilesMetaCache] = useState<Record<string, {format: FFprobeFormat, streams: FFprobeStream[], chapters: FFprobeChapter[] }>>({});
const [clearBatchFilesAfterConcat, setClearBatchFilesAfterConcat] = useState(false);
const [settingsVisible, setSettingsVisible] = useState(false);
const [enableReadFileMeta, setEnableReadFileMeta] = useState(false);
@ -88,21 +85,23 @@ const ConcatDialog = memo(({ isShown, onHide, paths, onConcat, alwaysConcatMulti
const allFilesMeta = useMemo(() => {
if (paths.length === 0) return undefined;
const filtered = paths.map((path) => [path, allFilesMetaCache[path]]).filter(([, it]) => it);
const filtered = paths.flatMap((path) => (allFilesMetaCache[path] ? [[path, allFilesMetaCache[path]!] as const] : []));
return filtered.length === paths.length ? filtered : undefined;
}, [allFilesMetaCache, paths]);
const isOutFileNameValid = outFileName != null && outFileName.length > 0;
const problemsByFile = useMemo(() => {
if (!allFilesMeta) return [];
if (!allFilesMeta) return {};
const allFilesMetaExceptFirstFile = allFilesMeta.slice(1);
const [, firstFileMeta] = allFilesMeta[0]!;
const errors = {};
function addError(path, error) {
const errors: Record<string, string[]> = {};
function addError(path: string, error: string) {
if (!errors[path]) errors[path] = [];
errors[path].push(error);
errors[path]!.push(error);
}
allFilesMetaExceptFirstFile.forEach(([path, { streams }]) => {
streams.forEach((stream, i) => {
const referenceStream = firstFileMeta.streams[i];
@ -123,7 +122,7 @@ const ConcatDialog = memo(({ isShown, onHide, paths, onConcat, alwaysConcatMulti
return errors;
}, [allFilesMeta]);
const onProblemsByFileClick = useCallback((path) => {
const onProblemsByFileClick = useCallback((path: string) => {
ReactSwal.fire({
title: i18n.t('Mismatches detected'),
html: (

Wyświetl plik

@ -5,9 +5,12 @@ import { useTranslation } from 'react-i18next';
import { primaryColor } from '../colors';
import useUserSettings from '../hooks/useUserSettings';
import { SegmentToExport } from '../types';
const ExportButton = memo(({ segmentsToExport, areWeCutting, onClick, size = 1 }) => {
const ExportButton = memo(({ segmentsToExport, areWeCutting, onClick, size = 1 }: {
segmentsToExport: SegmentToExport[], areWeCutting: boolean, onClick: () => void, size?: number | undefined,
}) => {
const CutIcon = areWeCutting ? FiScissors : FaFileExport;
const { t } = useTranslation();

Wyświetl plik

@ -1,10 +1,11 @@
import { memo, useCallback, useMemo } from 'react';
import { CSSProperties, memo, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { WarningSignIcon, CrossIcon } from 'evergreen-ui';
import { FaRegCheckCircle } from 'react-icons/fa';
import i18n from 'i18next';
import { useTranslation, Trans } from 'react-i18next';
import { IoIosHelpCircle } from 'react-icons/io';
import { SweetAlertIcon } from 'sweetalert2';
import ExportButton from './ExportButton';
import ExportModeButton from './ExportModeButton';
@ -23,21 +24,66 @@ import { toast } from '../swal';
import { isMov as ffmpegIsMov } from '../util/streams';
import useUserSettings from '../hooks/useUserSettings';
import styles from './ExportConfirm.module.css';
import { InverseCutSegment, SegmentToExport } from '../types';
import { GenerateOutSegFileNames } from '../util/outputNameTemplate';
import { FFprobeStream } from '../../ffprobe';
import { AvoidNegativeTs } from '../../types';
const boxStyle = { margin: '15px 15px 50px 15px', borderRadius: 10, padding: '10px 20px', minHeight: 500, position: 'relative' };
const boxStyle: CSSProperties = { margin: '15px 15px 50px 15px', borderRadius: 10, padding: '10px 20px', minHeight: 500, position: 'relative' };
const outDirStyle = { ...highlightedTextStyle, wordBreak: 'break-all', cursor: 'pointer' };
const outDirStyle: CSSProperties = { ...highlightedTextStyle, wordBreak: 'break-all', cursor: 'pointer' };
const warningStyle = { color: 'var(--red11)', fontSize: '80%', marginBottom: '.5em' };
const warningStyle: CSSProperties = { color: 'var(--red11)', fontSize: '80%', marginBottom: '.5em' };
const HelpIcon = ({ onClick, style }) => <IoIosHelpCircle size={20} role="button" onClick={withBlur(onClick)} style={{ cursor: 'pointer', color: primaryTextColor, verticalAlign: 'middle', ...style }} />;
const HelpIcon = ({ onClick, style }: { onClick: () => void, style?: CSSProperties }) => <IoIosHelpCircle size={20} role="button" onClick={withBlur(onClick)} style={{ cursor: 'pointer', color: primaryTextColor, verticalAlign: 'middle', ...style }} />;
const ExportConfirm = memo(({
areWeCutting, selectedSegments, segmentsToExport, willMerge, visible, onClosePress, onExportConfirm,
outFormat, renderOutFmt, outputDir, numStreamsTotal, numStreamsToCopy, onShowStreamsSelectorClick, outSegTemplate,
setOutSegTemplate, generateOutSegFileNames, filePath, currentSegIndexSafe, nonFilteredSegmentsOrInverse,
mainCopiedThumbnailStreams, needSmartCut, mergedOutFileName, setMergedOutFileName,
areWeCutting,
selectedSegments,
segmentsToExport,
willMerge,
visible,
onClosePress,
onExportConfirm,
outFormat,
renderOutFmt,
outputDir,
numStreamsTotal,
numStreamsToCopy,
onShowStreamsSelectorClick,
outSegTemplate,
setOutSegTemplate,
generateOutSegFileNames,
currentSegIndexSafe,
nonFilteredSegmentsOrInverse,
mainCopiedThumbnailStreams,
needSmartCut,
mergedOutFileName,
setMergedOutFileName,
} : {
areWeCutting: boolean,
selectedSegments: InverseCutSegment[],
segmentsToExport: SegmentToExport[],
willMerge: boolean,
visible: boolean,
onClosePress: () => void,
onExportConfirm: () => void,
outFormat: string | undefined,
renderOutFmt: (style: CSSProperties) => JSX.Element,
outputDir: string,
numStreamsTotal: number,
numStreamsToCopy: number,
onShowStreamsSelectorClick: () => void,
outSegTemplate: string,
setOutSegTemplate: (a: string) => void,
generateOutSegFileNames: GenerateOutSegFileNames,
currentSegIndexSafe: number,
nonFilteredSegmentsOrInverse: InverseCutSegment[],
mainCopiedThumbnailStreams: FFprobeStream[],
needSmartCut: boolean,
mergedOutFileName: string | undefined,
setMergedOutFileName: (a: string) => void,
}) => {
const { t } = useTranslation();
@ -50,13 +96,13 @@ const ExportConfirm = memo(({
const areWeCuttingProblematicStreams = areWeCutting && mainCopiedThumbnailStreams.length > 0;
const exportModeDescription = useMemo(() => ({
sesgments_to_chapters: t('Don\'t cut the file, but instead export an unmodified original which has chapters generated from segments'),
segments_to_chapters: t('Don\'t cut the file, but instead export an unmodified original which has chapters generated from segments'),
merge: t('Auto merge segments to one file after export'),
'merge+separate': t('Auto merge segments to one file after export, but keep segments too'),
separate: t('Export to separate files'),
})[effectiveExportMode], [effectiveExportMode, t]);
const showHelpText = useCallback(({ icon = 'info', timer = 10000, text }) => toast.fire({ icon, timer, text }), []);
const showHelpText = useCallback(({ icon = 'info', timer = 10000, text }: { icon?: SweetAlertIcon, timer?: number, text: string }) => toast.fire({ icon, timer, text }), []);
const onPreserveMovDataHelpPress = useCallback(() => {
toast.fire({ icon: 'info', timer: 10000, text: i18n.t('Preserve all MOV/MP4 metadata tags (e.g. EXIF, GPS position etc.) from source file? Note that some players have trouble playing back files where all metadata is preserved, like iTunes and other Apple software') });
@ -128,16 +174,16 @@ const ExportConfirm = memo(({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className={styles.sheet}
className={styles['sheet']}
transition={{ duration: 0.3, easings: ['easeOut'] }}
>
<div style={{ margin: 'auto' }}>
<div style={boxStyle} className={styles.box}>
<div style={boxStyle} className={styles['box']}>
<CrossIcon size={24} style={{ position: 'absolute', right: 0, top: 0, padding: 15, boxSizing: 'content-box', cursor: 'pointer' }} role="button" onClick={onClosePress} />
<h2 style={{ marginTop: 0, marginBottom: '.5em' }}>{t('Export options')}</h2>
<table className={styles.options}>
<table className={styles['options']}>
<tbody>
{selectedSegments.length !== nonFilteredSegmentsOrInverse.length && (
<tr>
@ -156,7 +202,7 @@ const ExportConfirm = memo(({
<ExportModeButton selectedSegments={selectedSegments} />
</td>
<td>
{effectiveExportMode === 'sesgments_to_chapters' ? (
{effectiveExportMode === 'segments_to_chapters' ? (
<WarningSignIcon verticalAlign="middle" color="warning" title={i18n.t('Segments to chapters mode is active, this means that the file will not be cut. Instead chapters will be created from the segments.')} />
) : (
<HelpIcon onClick={onExportModeHelpPress} />
@ -208,7 +254,7 @@ const ExportConfirm = memo(({
{canEditTemplate && (
<tr>
<td colSpan={2}>
<OutSegTemplateEditor filePath={filePath} outSegTemplate={outSegTemplate} setOutSegTemplate={setOutSegTemplate} generateOutSegFileNames={generateOutSegFileNames} currentSegIndexSafe={currentSegIndexSafe} />
<OutSegTemplateEditor outSegTemplate={outSegTemplate} setOutSegTemplate={setOutSegTemplate} generateOutSegFileNames={generateOutSegFileNames} currentSegIndexSafe={currentSegIndexSafe} />
</td>
<td>
<HelpIcon onClick={onOutSegTemplateHelpPress} />
@ -246,7 +292,7 @@ const ExportConfirm = memo(({
<h3 style={{ marginBottom: '.5em' }}>{t('Advanced options')}</h3>
<table className={styles.options}>
<table className={styles['options']}>
<tbody>
{willMerge && (
<>
@ -397,11 +443,11 @@ const ExportConfirm = memo(({
{avoidNegativeTsWarn != null && <div style={warningStyle}>{avoidNegativeTsWarn}</div>}
</td>
<td>
<Select value={avoidNegativeTs} onChange={(e) => setAvoidNegativeTs(e.target.value)} style={{ height: 20, marginLeft: 5 }}>
<option value="auto">auto</option>
<option value="make_zero">make_zero</option>
<option value="make_non_negative">make_non_negative</option>
<option value="disabled">disabled</option>
<Select value={avoidNegativeTs} onChange={(e) => setAvoidNegativeTs(e.target.value as AvoidNegativeTs)} style={{ height: 20, marginLeft: 5 }}>
<option value={'auto' as AvoidNegativeTs}>auto</option>
<option value={'make_zero' satisfies AvoidNegativeTs}>make_zero</option>
<option value={'make_non_negative' satisfies AvoidNegativeTs}>make_non_negative</option>
<option value={'disabled' satisfies AvoidNegativeTs}>disabled</option>
</Select>
</td>
<td>

Wyświetl plik

@ -1,18 +1,20 @@
import { memo, useMemo } from 'react';
import { CSSProperties, memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { withBlur } from '../util';
import useUserSettings from '../hooks/useUserSettings';
import Select from './Select';
import { ExportMode } from '../types';
const ExportModeButton = memo(({ selectedSegments, style }) => {
const ExportModeButton = memo(({ selectedSegments, style }: { selectedSegments: unknown[], style?: CSSProperties }) => {
const { t } = useTranslation();
const { effectiveExportMode, setAutoMerge, setAutoDeleteMergedSegments, setSegmentsToChaptersOnly } = useUserSettings();
function onChange(newMode) {
function onChange(newMode: ExportMode) {
switch (newMode) {
case 'sesgments_to_chapters': {
case 'segments_to_chapters': {
setAutoMerge(false);
setAutoDeleteMergedSegments(false);
setSegmentsToChaptersOnly(true);
@ -41,10 +43,10 @@ const ExportModeButton = memo(({ selectedSegments, style }) => {
}
const selectableModes = useMemo(() => [
'separate',
...(selectedSegments.length >= 2 || effectiveExportMode === 'merge' ? ['merge'] : []),
...(selectedSegments.length >= 2 || effectiveExportMode === 'merge+separate' ? ['merge+separate'] : []),
'sesgments_to_chapters',
'separate' as const,
...(selectedSegments.length >= 2 || effectiveExportMode === 'merge' ? ['merge'] as const : []),
...(selectedSegments.length >= 2 || effectiveExportMode === 'merge+separate' ? ['merge+separate'] as const : []),
'segments_to_chapters' as const,
], [effectiveExportMode, selectedSegments.length]);
return (
@ -58,7 +60,7 @@ const ExportModeButton = memo(({ selectedSegments, style }) => {
{selectableModes.map((mode) => {
const titles = {
sesgments_to_chapters: t('Segments to chapters'),
segments_to_chapters: t('Segments to chapters'),
merge: t('Merge cuts'),
'merge+separate': t('Merge & Separate'),
separate: t('Separate files'),

Wyświetl plik

@ -3,7 +3,7 @@ import { memo } from 'react';
import TextInput from './TextInput';
const MergedOutFileName = memo(({ mergedOutFileName, setMergedOutFileName }) => (
const MergedOutFileName = memo(({ mergedOutFileName, setMergedOutFileName }: { mergedOutFileName: string | undefined, setMergedOutFileName: (a: string) => void }) => (
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<TextInput value={mergedOutFileName ?? ''} onChange={(e) => setMergedOutFileName(e.target.value)} style={{ textAlign: 'right' }} />
</div>

Wyświetl plik

@ -9,12 +9,11 @@ import { motion, AnimatePresence } from 'framer-motion';
import Swal from '../swal';
import HighlightedText from './HighlightedText';
import { defaultOutSegTemplate, segNumVariable, segSuffixVariable, generateOutSegFileNames as generateOutSegFileNamesRaw } from '../util/outputNameTemplate';
import { defaultOutSegTemplate, segNumVariable, segSuffixVariable, GenerateOutSegFileNames } from '../util/outputNameTemplate';
import useUserSettings from '../hooks/useUserSettings';
import Switch from './Switch';
import Select from './Select';
import TextInput from './TextInput';
import { SegmentToExport } from '../types';
const ReactSwal = withReactContent(Swal);
@ -25,7 +24,7 @@ const formatVariable = (variable) => `\${${variable}}`;
const extVar = formatVariable('EXT');
const OutSegTemplateEditor = memo(({ outSegTemplate, setOutSegTemplate, generateOutSegFileNames, currentSegIndexSafe }: {
outSegTemplate: string, setOutSegTemplate: (text: string) => void, generateOutSegFileNames: (a: { segments?: SegmentToExport[], template: string }) => ReturnType<typeof generateOutSegFileNamesRaw>, currentSegIndexSafe: number,
outSegTemplate: string, setOutSegTemplate: (text: string) => void, generateOutSegFileNames: GenerateOutSegFileNames, currentSegIndexSafe: number,
}) => {
const { safeOutputFileName, toggleSafeOutputFileName, outputFileNameMinZeroPadding, setOutputFileNameMinZeroPadding } = useUserSettings();

Wyświetl plik

@ -1,4 +1,4 @@
import { memo } from 'react';
import { CSSProperties, memo } from 'react';
import { useTranslation } from 'react-i18next';
import { MdEventNote } from 'react-icons/md';
@ -7,7 +7,7 @@ import { primaryTextColor } from '../colors';
import useUserSettings from '../hooks/useUserSettings';
const ToggleExportConfirm = memo(({ size = 23, style }) => {
const ToggleExportConfirm = memo(({ size = 23, style }: { size?: number | undefined, style?: CSSProperties }) => {
const { t } = useTranslation();
const { exportConfirmEnabled, toggleExportConfirmEnabled } = useUserSettings();

Wyświetl plik

@ -2,9 +2,9 @@ import React, { useContext } from 'react';
import Color from 'color';
import useUserSettingsRoot from './hooks/useUserSettingsRoot';
import { SegmentColorIndex } from './types';
import { ExportMode, SegmentColorIndex } from './types';
type UserSettingsContextType = ReturnType<typeof useUserSettingsRoot> & {
export type UserSettingsContextType = ReturnType<typeof useUserSettingsRoot> & {
toggleCaptureFormat: () => void,
changeOutDir: () => Promise<void>,
toggleKeyframeCut: (showMessage?: boolean) => void,
@ -15,7 +15,7 @@ type UserSettingsContextType = ReturnType<typeof useUserSettingsRoot> & {
togglePreserveMetadataOnMerge: () => void,
toggleSimpleMode: () => void,
toggleSafeOutputFileName: () => void,
effectiveExportMode: string,
effectiveExportMode: ExportMode,
}
interface SegColorsContextType {

Wyświetl plik

@ -3,17 +3,23 @@ import sortBy from 'lodash/sortBy';
import i18n from 'i18next';
import Timecode from 'smpte-timecode';
import minBy from 'lodash/minBy';
import invariant from 'tiny-invariant';
import { pcmAudioCodecs, getMapStreamsArgs, isMov } from './util/streams';
import { getSuffixedOutPath, isExecaFailure } from './util';
import { isDurationValid } from './segments';
import { Waveform } from '../types';
import { FFprobeProbeResult, FFprobeStream } from '../ffprobe';
const FileType = window.require('file-type');
const { pathExists } = window.require('fs-extra');
const remote = window.require('@electron/remote');
const { renderWaveformPng, mapTimesToSegments, detectSceneChanges, captureFrames, captureFrame, getFfCommandLine, runFfmpegConcat, runFfmpegWithProgress, html5ify, getDuration, abortFfmpegs, runFfmpeg, runFfprobe, getFfmpegPath, setCustomFfPath } = remote.require('./ffmpeg');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ffmpeg: { renderWaveformPng: (a: any) => Promise<Waveform>, mapTimesToSegments, detectSceneChanges, captureFrames, captureFrame, getFfCommandLine, runFfmpegConcat, runFfmpegWithProgress, html5ify, getDuration, abortFfmpegs, runFfmpeg, runFfprobe, getFfmpegPath, setCustomFfPath } = remote.require('./ffmpeg');
const { renderWaveformPng, mapTimesToSegments, detectSceneChanges, captureFrames, captureFrame, getFfCommandLine, runFfmpegConcat, runFfmpegWithProgress, html5ify, getDuration, abortFfmpegs, runFfmpeg, runFfprobe, getFfmpegPath, setCustomFfPath } = ffmpeg;
export { renderWaveformPng, mapTimesToSegments, detectSceneChanges, captureFrames, captureFrame, getFfCommandLine, runFfmpegConcat, runFfmpegWithProgress, html5ify, getDuration, abortFfmpegs, runFfprobe, getFfmpegPath, setCustomFfPath };
@ -326,7 +332,7 @@ export async function readFileMeta(filePath) {
'-of', 'json', '-show_chapters', '-show_format', '-show_entries', 'stream', '-i', filePath, '-hide_banner',
]);
let parsedJson;
let parsedJson: FFprobeProbeResult;
try {
// https://github.com/mifi/lossless-cut/issues/1342
parsedJson = JSON.parse(stdout);
@ -334,7 +340,8 @@ export async function readFileMeta(filePath) {
console.log('ffprobe stdout', stdout);
throw new Error('ffprobe returned malformed data');
}
const { streams = [], format = {}, chapters = [] } = parsedJson;
const { streams = [], format, chapters = [] } = parsedJson;
invariant(format != null);
return { format, streams, chapters };
} catch (err) {
// Windows will throw error with code ENOENT if format detection fails.
@ -379,8 +386,7 @@ function getPreferredCodecFormat(stream) {
}
async function extractNonAttachmentStreams({ customOutDir, filePath, streams, enableOverwriteOutput }: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
customOutDir?: string, filePath: string, streams: any[], enableOverwriteOutput?: boolean,
customOutDir?: string, filePath: string, streams: FFprobeStream[], enableOverwriteOutput?: boolean,
}) {
if (streams.length === 0) return [];
@ -421,8 +427,7 @@ async function extractNonAttachmentStreams({ customOutDir, filePath, streams, en
}
async function extractAttachmentStreams({ customOutDir, filePath, streams, enableOverwriteOutput }: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
customOutDir?: string, filePath: string, streams: any[], enableOverwriteOutput?: boolean,
customOutDir?: string, filePath: string, streams: FFprobeStream[], enableOverwriteOutput?: boolean,
}) {
if (streams.length === 0) return [];

Wyświetl plik

@ -9,7 +9,7 @@ import { isCuttingStart, isCuttingEnd, runFfmpegWithProgress, getFfCommandLine,
import { getMapStreamsArgs, getStreamIdsToCopy } from '../util/streams';
import { getSmartCutParams } from '../smartcut';
import { isDurationValid } from '../segments';
import { FfprobeStream } from '../types';
import { FFprobeStream } from '../../ffprobe';
const { join, resolve, dirname } = window.require('path');
const { pathExists } = window.require('fs-extra');
@ -68,7 +68,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
const getOutputPlaybackRateArgs = useCallback(() => (outputPlaybackRate !== 1 ? ['-itsscale', 1 / outputPlaybackRate] : []), [outputPlaybackRate]);
const concatFiles = useCallback(async ({ paths, outDir, outPath, metadataFromPath, includeAllStreams, streams, outFormat, ffmpegExperimental, onProgress = () => undefined, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge, videoTimebase, appendFfmpegCommandLog }: {
paths: string[], outDir: string | undefined, outPath: string, metadataFromPath: string, includeAllStreams: boolean, streams: FfprobeStream, outFormat: string, ffmpegExperimental: boolean, onProgress?: (a: number) => void, preserveMovData: boolean, movFastStart: boolean, chapters: { start: number, end: number, name: string | undefined }[] | undefined, preserveMetadataOnMerge: boolean, videoTimebase?: number | undefined, appendFfmpegCommandLog: (a: string) => void,
paths: string[], outDir: string | undefined, outPath: string, metadataFromPath: string, includeAllStreams: boolean, streams: FFprobeStream[], outFormat: string, ffmpegExperimental: boolean, onProgress?: (a: number) => void, preserveMovData: boolean, movFastStart: boolean, chapters: { start: number, end: number, name: string | undefined }[] | undefined, preserveMetadataOnMerge: boolean, videoTimebase?: number | undefined, appendFfmpegCommandLog: (a: string) => void,
}) => {
if (await shouldSkipExistingFile(outPath)) return { haveExcludedStreams: false };

Wyświetl plik

@ -4,14 +4,19 @@ import { useThrottle } from '@uidotdev/usehooks';
import { waveformColorDark, waveformColorLight } from '../colors';
import { renderWaveformPng } from '../ffmpeg';
import { RenderableWaveform } from '../types';
import { FFprobeStream } from '../../ffprobe';
const maxWaveforms = 100;
// const maxWaveforms = 3; // testing
export default ({ darkMode, filePath, relevantTime, durationSafe, waveformEnabled, audioStream, shouldShowWaveform, ffmpegExtractWindow }) => {
const creatingWaveformPromise = useRef();
const [waveforms, setWaveforms] = useState([]);
const waveformsRef = useRef();
export default ({ darkMode, filePath, relevantTime, durationSafe, waveformEnabled, audioStream, ffmpegExtractWindow }: {
darkMode: boolean, filePath: string | undefined, relevantTime: number, durationSafe: number, waveformEnabled: boolean, audioStream: FFprobeStream | undefined, ffmpegExtractWindow: number,
}) => {
const creatingWaveformPromise = useRef<Promise<unknown>>();
const [waveforms, setWaveforms] = useState<RenderableWaveform[]>([]);
const waveformsRef = useRef<RenderableWaveform[]>();
useEffect(() => {
waveformsRef.current = waveforms;
@ -33,7 +38,7 @@ export default ({ darkMode, filePath, relevantTime, durationSafe, waveformEnable
const waveformStartTime = Math.floor(timeThrottled / ffmpegExtractWindow) * ffmpegExtractWindow;
const alreadyHaveWaveformAtTime = (waveformsRef.current || []).some((waveform) => waveform.from === waveformStartTime);
const shouldRun = filePath && audioStream && timeThrottled != null && shouldShowWaveform && waveformEnabled && !alreadyHaveWaveformAtTime && !creatingWaveformPromise.current;
const shouldRun = filePath && audioStream && timeThrottled != null && waveformEnabled && !alreadyHaveWaveformAtTime && !creatingWaveformPromise.current;
if (!shouldRun) return;
try {
@ -64,9 +69,9 @@ export default ({ darkMode, filePath, relevantTime, durationSafe, waveformEnable
return () => {
aborted = true;
};
}, [filePath, timeThrottled, waveformEnabled, audioStream, shouldShowWaveform, ffmpegExtractWindow, durationSafe, waveformColor, setWaveforms]);
}, [filePath, timeThrottled, waveformEnabled, audioStream, ffmpegExtractWindow, durationSafe, waveformColor, setWaveforms]);
const lastWaveformsRef = useRef([]);
const lastWaveformsRef = useRef<RenderableWaveform[]>([]);
useEffect(() => {
const removedWaveforms = lastWaveformsRef.current.filter((wf) => !waveforms.includes(wf));
// Cleanup old

Wyświetl plik

@ -46,7 +46,7 @@ export const getCleanCutSegments = (cs: Pick<StateSegment, 'start' | 'end' | 'na
tags: seg.tags,
}));
export function findSegmentsAtCursor(apparentSegments, currentTime) {
export function findSegmentsAtCursor(apparentSegments: ApparentSegmentBase[], currentTime: number) {
const indexes: number[] = [];
apparentSegments.forEach((segment, index) => {
if (segment.start <= currentTime && segment.end >= currentTime) indexes.push(index);

Wyświetl plik

@ -1,6 +1,7 @@
import { getRealVideoStreams, getVideoTimebase } from './util/streams';
import { readKeyframesAroundTime, findNextKeyframe, findKeyframeAtExactTime } from './ffmpeg';
import { FFprobeStream } from '../ffprobe';
const { stat } = window.require('fs-extra');
@ -9,13 +10,14 @@ const mapVideoCodec = (codec: string) => ({ av1: 'libsvtav1' }[codec] ?? codec);
// eslint-disable-next-line import/prefer-default-export
export async function getSmartCutParams({ path, videoDuration, desiredCutFrom, streams }: {
path: string, videoDuration: number, desiredCutFrom: number, streams,
path: string, videoDuration: number, desiredCutFrom: number, streams: FFprobeStream[],
}) {
const videoStreams = getRealVideoStreams(streams);
if (videoStreams.length === 0) throw new Error('Smart cut only works on videos');
if (videoStreams.length > 1) throw new Error('Can only smart cut video with exactly one video stream');
const videoStream = videoStreams[0];
const [videoStream] = videoStreams;
if (videoStream == null) throw new Error('Smart cut only works on videos');
const readKeyframes = async (window: number) => readKeyframesAroundTime({ filePath: path, streamIndex: videoStream.index, aroundTime: desiredCutFrom, window });
@ -43,7 +45,7 @@ export async function getSmartCutParams({ path, videoDuration, desiredCutFrom, s
console.log('Smart cut from keyframe', { keyframe: nextKeyframe.time, desiredCutFrom });
let videoBitrate = parseInt(videoStream.bit_rate, 10);
let videoBitrate = parseInt(videoStream.bit_rate!, 10);
if (Number.isNaN(videoBitrate)) {
console.warn('Unable to detect input bitrate');
const stats = await stat(path);

Wyświetl plik

@ -1,6 +1,14 @@
import type { MenuItem, MenuItemConstructorOptions } from 'electron';
export interface ChromiumHTMLVideoElement extends HTMLVideoElement {
videoTracks?: { id: string, selected: boolean }[]
}
export interface ChromiumHTMLAudioElement extends HTMLAudioElement {
audioTracks?: { id: string, enabled: boolean }[]
}
export interface SegmentBase {
start?: number | undefined,
end?: number | undefined,
@ -63,9 +71,10 @@ export type EdlExportType = 'csv' | 'tsv-human' | 'csv-human' | 'csv-frames' | '
export type TunerType = 'wheelSensitivity' | 'keyboardNormalSeekSpeed' | 'keyboardSeekAccFactor';
export interface Waveform {
export interface RenderableWaveform {
from: number,
to: number,
duration: number,
url: string,
}
@ -76,14 +85,6 @@ export interface Thumbnail {
url: string
}
// todo types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type FfprobeStream = any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type FfprobeFormat = any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type FfprobeChapter = any;
export type FormatTimecode = (a: { seconds: number, shorten?: boolean | undefined, fileNameFriendly?: boolean | undefined }) => string;
export type GetFrameCount = (sec: number) => number | undefined;
@ -91,3 +92,5 @@ export type GetFrameCount = (sec: number) => number | undefined;
export type UpdateSegAtIndex = (index: number, newProps: Partial<StateSegment>) => void;
export type ContextMenuTemplate = (MenuItemConstructorOptions | MenuItem)[];
export type ExportMode = 'segments_to_chapters' | 'merge' | 'merge+separate' | 'separate';

Wyświetl plik

@ -176,3 +176,5 @@ export function generateOutSegFileNames({ segments, template: desiredTemplate, f
return { outSegFileNames, outSegProblems };
}
export type GenerateOutSegFileNames = (a: { segments?: SegmentToExport[], template: string }) => ReturnType<typeof generateOutSegFileNames>;

Wyświetl plik

@ -1,17 +1,39 @@
import { test, expect } from 'vitest';
import { getMapStreamsArgs, getStreamIdsToCopy } from './streams';
import { LiteFFprobeStream, getMapStreamsArgs, getStreamIdsToCopy } from './streams';
import { FFprobeStreamDisposition } from '../../ffprobe';
const streams1 = [
{ index: 0, codec_type: 'video', codec_tag: '0x0000', codec_name: 'mjpeg', disposition: { attached_pic: 1 } },
{ index: 1, codec_type: 'audio', codec_tag: '0x6134706d', codec_name: 'aac' },
{ index: 2, codec_type: 'video', codec_tag: '0x31637661', codec_name: 'h264' },
{ index: 3, codec_type: 'video', codec_tag: '0x0000', codec_name: 'hevc' },
{ index: 4, codec_type: 'audio', codec_tag: '0x6134706d', codec_name: 'aac' },
{ index: 5, codec_type: 'attachment', codec_tag: '0x0000', codec_name: 'ttf' },
{ index: 6, codec_type: 'data', codec_tag: '0x64636d74' },
{ index: 7, codec_type: 'subtitle', codec_tag: '0x0000', codec_name: 'subrip' },
const makeDisposition = (override?: Partial<FFprobeStreamDisposition>): FFprobeStreamDisposition => ({
default: 0,
dub: 0,
original: 0,
comment: 0,
lyrics: 0,
karaoke: 0,
forced: 0,
hearing_impaired: 0,
visual_impaired: 0,
clean_effects: 0,
attached_pic: 0,
timed_thumbnails: 0,
captions: 0,
descriptions: 0,
metadata: 0,
dependent: 0,
still_image: 0,
...override,
});
const streams1: LiteFFprobeStream[] = [
{ index: 0, codec_type: 'video', codec_tag: '0x0000', codec_name: 'mjpeg', disposition: makeDisposition({ attached_pic: 1 }) },
{ index: 1, codec_type: 'audio', codec_tag: '0x6134706d', codec_name: 'aac', disposition: makeDisposition() },
{ index: 2, codec_type: 'video', codec_tag: '0x31637661', codec_name: 'h264', disposition: makeDisposition() },
{ index: 3, codec_type: 'video', codec_tag: '0x0000', codec_name: 'hevc', disposition: makeDisposition() },
{ index: 4, codec_type: 'audio', codec_tag: '0x6134706d', codec_name: 'aac', disposition: makeDisposition() },
{ index: 5, codec_type: 'attachment', codec_tag: '0x0000', codec_name: 'ttf', disposition: makeDisposition() },
{ index: 6, codec_type: 'data', codec_tag: '0x64636d74', codec_name: '', disposition: makeDisposition() },
{ index: 7, codec_type: 'subtitle', codec_tag: '0x0000', codec_name: 'subrip', disposition: makeDisposition() },
];
const path = '/path/to/file';

Wyświetl plik

@ -1,3 +1,6 @@
import { FFprobeStream, FFprobeStreamDisposition } from '../../ffprobe';
import { ChromiumHTMLAudioElement, ChromiumHTMLVideoElement } from '../types';
// https://www.ffmpeg.org/doxygen/3.2/libavutil_2utils_8c_source.html#l00079
const defaultProcessedCodecTypes = new Set([
'video',
@ -99,19 +102,19 @@ export const pcmAudioCodecs = [
'pcm_vidc',
];
export function getActiveDisposition(disposition) {
export function getActiveDisposition(disposition: FFprobeStreamDisposition | undefined) {
if (disposition == null) return undefined;
const existingActiveDispositionEntry = Object.entries(disposition).find(([, value]) => value === 1);
if (!existingActiveDispositionEntry) return undefined;
return existingActiveDispositionEntry[0]; // return the key
}
export const isMov = (format: string) => ['ismv', 'ipod', 'mp4', 'mov'].includes(format);
export const isMov = (format: string | undefined) => format != null && ['ismv', 'ipod', 'mp4', 'mov'].includes(format);
type GetVideoArgsFn = (a: { streamIndex: number, outputIndex: number }) => string[] | undefined;
function getPerStreamFlags({ stream, outputIndex, outFormat, manuallyCopyDisposition = false, getVideoArgs = () => undefined }: {
stream, outputIndex: number, outFormat: string, manuallyCopyDisposition?: boolean | undefined, getVideoArgs?: GetVideoArgsFn | undefined
stream: FFprobeStream, outputIndex: number, outFormat: string, manuallyCopyDisposition?: boolean | undefined, getVideoArgs?: GetVideoArgsFn | undefined
}) {
let args: string[] = [];
@ -216,7 +219,7 @@ export function getMapStreamsArgs({ startIndex = 0, outFormat, allFilesMeta, cop
return args;
}
export function shouldCopyStreamByDefault(stream) {
export function shouldCopyStreamByDefault(stream: FFprobeStream) {
if (!defaultProcessedCodecTypes.has(stream.codec_type)) return false;
if (unprocessableCodecs.has(stream.codec_name)) return false;
return true;
@ -224,24 +227,26 @@ export function shouldCopyStreamByDefault(stream) {
export const attachedPicDisposition = 'attached_pic';
export function isStreamThumbnail(stream) {
export type LiteFFprobeStream = Pick<FFprobeStream, 'index' | 'codec_type' | 'codec_tag' | 'codec_name' | 'disposition'>;
export function isStreamThumbnail(stream: LiteFFprobeStream) {
return stream && stream.codec_type === 'video' && stream.disposition?.[attachedPicDisposition] === 1;
}
export const getAudioStreams = (streams) => streams.filter((stream) => stream.codec_type === 'audio');
export const getRealVideoStreams = (streams) => streams.filter((stream) => stream.codec_type === 'video' && !isStreamThumbnail(stream));
export const getSubtitleStreams = (streams) => streams.filter((stream) => stream.codec_type === 'subtitle');
export const getAudioStreams = <T extends LiteFFprobeStream>(streams: T[]) => streams.filter((stream) => stream.codec_type === 'audio');
export const getRealVideoStreams = <T extends LiteFFprobeStream>(streams: T[]) => streams.filter((stream) => stream.codec_type === 'video' && !isStreamThumbnail(stream));
export const getSubtitleStreams = <T extends LiteFFprobeStream>(streams: T[]) => streams.filter((stream) => stream.codec_type === 'subtitle');
// videoTracks/audioTracks seems to be 1-indexed, while ffmpeg is 0-indexes
const getHtml5TrackId = (ffmpegTrackIndex: number) => String(ffmpegTrackIndex + 1);
const getHtml5VideoTracks = (video) => [...(video.videoTracks ?? [])];
const getHtml5AudioTracks = (video) => [...(video.audioTracks ?? [])];
const getHtml5VideoTracks = (video: ChromiumHTMLVideoElement) => [...(video.videoTracks ?? [])];
const getHtml5AudioTracks = (audio: ChromiumHTMLAudioElement) => [...(audio.audioTracks ?? [])];
export const getVideoTrackForStreamIndex = (video: HTMLVideoElement, index) => getHtml5VideoTracks(video).find((videoTrack) => videoTrack.id === getHtml5TrackId(index));
export const getAudioTrackForStreamIndex = (video: HTMLVideoElement, index) => getHtml5AudioTracks(video).find((audioTrack) => audioTrack.id === getHtml5TrackId(index));
export const getVideoTrackForStreamIndex = (video: ChromiumHTMLVideoElement, index) => getHtml5VideoTracks(video).find((videoTrack) => videoTrack.id === getHtml5TrackId(index));
export const getAudioTrackForStreamIndex = (audio: ChromiumHTMLAudioElement, index) => getHtml5AudioTracks(audio).find((audioTrack) => audioTrack.id === getHtml5TrackId(index));
function resetVideoTrack(video) {
function resetVideoTrack(video: ChromiumHTMLVideoElement) {
console.log('Resetting video track');
getHtml5VideoTracks(video).forEach((track, index) => {
// eslint-disable-next-line no-param-reassign
@ -249,7 +254,7 @@ function resetVideoTrack(video) {
});
}
function resetAudioTrack(video) {
function resetAudioTrack(video: ChromiumHTMLVideoElement) {
console.log('Resetting audio track');
getHtml5AudioTracks(video).forEach((track, index) => {
// eslint-disable-next-line no-param-reassign
@ -258,7 +263,7 @@ function resetAudioTrack(video) {
}
// https://github.com/mifi/lossless-cut/issues/256
export function enableVideoTrack(video, index) {
export function enableVideoTrack(video: ChromiumHTMLVideoElement, index: number | undefined) {
if (index == null) {
resetVideoTrack(video);
return;
@ -270,7 +275,7 @@ export function enableVideoTrack(video, index) {
});
}
export function enableAudioTrack(video, index) {
export function enableAudioTrack(video: ChromiumHTMLVideoElement, index: number | undefined) {
if (index == null) {
resetAudioTrack(video);
return;
@ -282,7 +287,7 @@ export function enableAudioTrack(video, index) {
});
}
export function getStreamIdsToCopy({ streams, includeAllStreams }) {
export function getStreamIdsToCopy({ streams, includeAllStreams }: { streams: LiteFFprobeStream[], includeAllStreams: boolean }) {
if (includeAllStreams) {
return {
streamIdsToCopy: streams.map((stream) => stream.index),
@ -299,9 +304,9 @@ export function getStreamIdsToCopy({ streams, includeAllStreams }) {
const videoStreams = getRealVideoStreams(streams);
const audioStreams = getAudioStreams(streams);
const subtitleStreams = getSubtitleStreams(streams);
if (videoStreams.length > 0) streamIdsToCopy.push(videoStreams[0].index);
if (audioStreams.length > 0) streamIdsToCopy.push(audioStreams[0].index);
if (subtitleStreams.length > 0) streamIdsToCopy.push(subtitleStreams[0].index);
if (videoStreams.length > 0) streamIdsToCopy.push(videoStreams[0]!.index);
if (audioStreams.length > 0) streamIdsToCopy.push(audioStreams[0]!.index);
if (subtitleStreams.length > 0) streamIdsToCopy.push(subtitleStreams[0]!.index);
const excludedStreamIds = streams.filter((s) => !streamIdsToCopy.includes(s.index)).map((s) => s.index);
return { streamIdsToCopy, excludedStreamIds };
@ -329,7 +334,9 @@ export async function doesPlayerSupportHevcPlayback() {
// so we will detect these codecs and convert to dummy
// "properly handle" here means either play it back or give a playback error if the video codec is not supported
// todo maybe improve https://github.com/mifi/lossless-cut/issues/88#issuecomment-1363828563
export function willPlayerProperlyHandleVideo({ streams, hevcPlaybackSupported }) {
export function willPlayerProperlyHandleVideo({ streams, hevcPlaybackSupported }: {
streams: FFprobeStream[], hevcPlaybackSupported: boolean,
}) {
const realVideoStreams = getRealVideoStreams(streams);
// If audio-only format, assume all is OK
if (realVideoStreams.length === 0) return true;
@ -344,17 +351,17 @@ export function willPlayerProperlyHandleVideo({ streams, hevcPlaybackSupported }
return realVideoStreams.some((stream) => !chromiumSilentlyFailCodecs.includes(stream.codec_name));
}
export function isAudioDefinitelyNotSupported(streams) {
export function isAudioDefinitelyNotSupported(streams: FFprobeStream[]) {
const audioStreams = getAudioStreams(streams);
if (audioStreams.length === 0) return false;
// TODO this could be improved
return audioStreams.every((stream) => ['ac3', 'eac3'].includes(stream.codec_name));
}
export function getVideoTimebase(videoStream) {
export function getVideoTimebase(videoStream: FFprobeStream) {
const timebaseMatch = videoStream.time_base && videoStream.time_base.split('/');
if (timebaseMatch) {
const timebaseParsed = parseInt(timebaseMatch[1], 10);
const timebaseParsed = parseInt(timebaseMatch[1]!, 10);
if (!Number.isNaN(timebaseParsed)) return timebaseParsed;
}
return undefined;

Wyświetl plik

@ -39,6 +39,8 @@ export type LanguageKey = keyof typeof langNames;
export type TimecodeFormat = 'timecodeWithDecimalFraction' | 'frameCount' | 'timecodeWithFramesFraction';
export type AvoidNegativeTs = 'make_zero' | 'auto' | 'make_non_negative' | 'disabled';
export interface Config {
captureFormat: CaptureFormat,
customOutDir: string | undefined,
@ -61,7 +63,7 @@ export interface Config {
ffmpegExperimental: boolean,
preserveMovData: boolean,
movFastStart: boolean,
avoidNegativeTs: 'make_zero' | 'auto' | 'make_non_negative' | 'disabled',
avoidNegativeTs: AvoidNegativeTs,
hideNotifications: 'all' | undefined,
autoLoadTimecode: boolean,
segmentsToChapters: boolean,
@ -100,3 +102,11 @@ export interface Config {
export type StoreGetConfig = <T extends keyof Config>(key: T) => Config[T];
export type StoreSetConfig = <T extends keyof Config>(key: T, value: Config[T]) => void;
export type StoreResetConfig = <T extends keyof Config>(key: T) => void;
export interface Waveform {
buffer: Buffer,
from: number,
to: number,
duration: number,
createdAt: Date,
}