Improves FFMpeg command generation

Adds an object for storing FFmpeg variables. This allows any argument type to be added at any time, removing the limitation of having to add to the command in a specific order.
Makes stream objects store the index of the input file they belong to.
Saves the result of probes to a record that's easy to pass around.
Always passes all probed files to the input files of ffmpeg.
Makes it easier to enable h26x encoding/decoding when necessary.
Removes the hard-coded behavior for external subtitles, and allows any stream to be an external stream.
This commit is contained in:
2024-04-08 20:00:09 +02:00
parent be88845731
commit 376d5655f2
20 changed files with 537 additions and 450 deletions

View File

@ -1,5 +1,7 @@
package net.knarcraft.ffmpegconverter.utility;
import net.knarcraft.ffmpegconverter.container.FFMpegCommand;
import net.knarcraft.ffmpegconverter.container.StreamProbeResult;
import net.knarcraft.ffmpegconverter.streams.AudioStream;
import net.knarcraft.ffmpegconverter.streams.StreamObject;
import net.knarcraft.ffmpegconverter.streams.StreamTag;
@ -48,6 +50,13 @@ public final class FFMpegHelper {
Plan: Sort all streams by set criteria. Map all selected streams by looping using their relative index for
selection.
Streams should probably have an input index, so it's easier to treat extra subtitles more seamlessly. So, by
including any external subtitle files as input, there would be no need to fiddle more with storing input files.
Instead of storing the ffmpeg command as a list of strings, it should be stored as an object with different list
for input arguments and output arguments. That way, it would be much easier to add input-related arguments later
in the process.
*/
/**
@ -58,71 +67,73 @@ public final class FFMpegHelper {
* @return <p>A list of StreamObjects.</p>
* @throws IOException <p>If the process can't be readProcess.</p>
*/
public static List<StreamObject> probeFile(String ffprobePath, File file) throws IOException {
@NotNull
public static StreamProbeResult probeFile(@NotNull String ffprobePath, @NotNull File file) throws IOException {
return parseStreams(ffprobePath, probeForStreams(ffprobePath, file), file);
}
/**
* Creates a list containing all required arguments for converting a video to a web playable video
*
* @param executable <p>The executable to use (ffmpeg/ffprobe).</p>
* @param fileName <p>The name of the file to execute on.</p>
* @return <p>A base list of ffmpeg commands for converting a video for web</p>
* @param executable <p>The executable to use (ffmpeg/ffprobe)</p>
* @param files <p>The files to execute on</p>
* @return <p>A FFMPEG command for web-playable video</p>
*/
public static List<String> getFFMpegWebVideoCommand(String executable, String fileName) {
List<String> command = getFFMpegGeneralFileCommand(executable, fileName);
command.add("-vcodec");
command.add("h264");
command.add("-pix_fmt");
command.add("yuv420p");
command.add("-ar");
command.add("48000");
command.add("-movflags");
command.add("+faststart");
@NotNull
public static FFMpegCommand getFFMpegWebVideoCommand(@NotNull String executable, @NotNull List<File> files) {
FFMpegCommand command = getFFMpegGeneralFileCommand(executable, files);
command.addOutputFileOption("-vcodec", "h264");
command.addOutputFileOption("-pix_fmt", "yuv420p");
command.addOutputFileOption("-ar", "48000");
command.addOutputFileOption("-movflags", "+faststart");
command.addOutputFileOption("-map_metadata", "0");
command.addOutputFileOption("-movflags", "+use_metadata_tags");
return command;
}
/**
* Creates a list containing command line arguments for a general file
*
* @param executable <p>The executable to use (ffmpeg/ffprobe).</p>
* @param fileName <p>The name of the file to execute on.</p>
* @return <p>A base list of ffmpeg commands for converting a file.</p>
* @param executable <p>The executable to use (ffmpeg/ffprobe)</p>
* @param files <p>The files to execute on</p>
* @return <p>A basic FFMPEG command</p>
*/
public static List<String> getFFMpegGeneralFileCommand(String executable, String fileName) {
List<String> command = new ArrayList<>();
command.add(executable);
command.add("-nostdin");
command.add("-i");
command.add(fileName);
@NotNull
public static FFMpegCommand getFFMpegGeneralFileCommand(@NotNull String executable, @NotNull List<File> files) {
FFMpegCommand command = new FFMpegCommand(executable);
command.addGlobalOption("-nostdin");
for (File file : files) {
command.addInputFile(file.getName());
}
command.addOutputFileOption("-map_metadata", "0");
command.addOutputFileOption("-movflags", "+use_metadata_tags");
return command;
}
/**
* Adds debugging parameters for only converting parts of a file
*
* @param command <p>The list containing the command to run.</p>
* @param start <p>The offset before converting.</p>
* @param length <p>The offset for stopping the conversion.</p>
* @param command <p>The command to add to</p>
* @param start <p>The time to start at</p>
* @param duration <p>The duration of video to output</p>
*/
public static void addDebugArguments(List<String> command, int start, int length) {
command.add("-ss");
command.add("" + start);
command.add("-t");
command.add("" + length);
public static void addDebugArguments(@NotNull FFMpegCommand command, int start, int duration) {
command.addInputFileOption("-ss", String.valueOf(start));
command.addOutputFileOption("-t", String.valueOf(duration));
}
/**
* Starts and prints output of a process
*
* @param processBuilder <p>The process to run.</p>
* @param folder <p>The folder the process should run in.</p>
* @param spacer <p>The character(s) to use between each new line read.</p>
* @param write <p>Whether to write the output directly instead of storing it.</p>
* @throws IOException <p>If the process can't be readProcess.</p>
* @param processBuilder <p>The process to run</p>
* @param folder <p>The folder the process should run in</p>
* @param spacer <p>The character(s) to use between each new line read</p>
* @param write <p>Whether to write the output directly instead of storing it</p>
* @throws IOException <p>If the process can't be readProcess</p>
*/
public static String runProcess(ProcessBuilder processBuilder, File folder, String spacer, boolean write)
throws IOException {
@NotNull
public static String runProcess(@NotNull ProcessBuilder processBuilder, @NotNull File folder,
@NotNull String spacer, boolean write) throws IOException {
//Give the user information about what's about to happen
OutputUtil.print("Command to be run: ");
OutputUtil.println(processBuilder.command().toString());
@ -136,13 +147,15 @@ public final class FFMpegHelper {
StringBuilder output = new StringBuilder();
while (process.isAlive()) {
String read = readProcess(processReader, spacer);
if (!read.isEmpty()) {
if (write) {
OutputUtil.println(read);
} else {
OutputUtil.printDebug(read);
output.append(read);
}
if (read.isEmpty()) {
continue;
}
if (write) {
OutputUtil.println(read);
} else {
OutputUtil.printDebug(read);
output.append(read);
}
}
OutputUtil.println("Process finished.");
@ -150,21 +163,16 @@ public final class FFMpegHelper {
}
/**
* Adds audio to a command
* Maps an audio track to a ffmpeg command's output
*
* @param command <p>The command to add audio to.</p>
* @param audioStream <p>The audio stream to be added.</p>
* @param toStereo <p>Whether to convert the audio stream to stereo.</p>
* @param command <p>The command to add the audio track to</p>
* @param audioStream <p>The audio stream to be mapped</p>
* @param toStereo <p>Whether to convert the audio stream to stereo</p>
*/
public static void addAudioStream(List<String> command, AudioStream audioStream, boolean toStereo) {
if (audioStream != null) {
command.add("-map");
command.add("0:" + audioStream.getAbsoluteIndex());
if (toStereo && audioStream.getChannels() > 2) {
command.add("-af");
command.add("pan=stereo|FL=FC+0.30*FL+0.30*BL|FR=FC+0.30*FR+0.30*BR");
//command.add("pan=stereo|FL < 1.0*FL + 0.707*FC + 0.707*BL|FR < 1.0*FR + 0.707*FC + 0.707*BR");
}
public static void addAudioStream(@NotNull FFMpegCommand command, @NotNull AudioStream audioStream, boolean toStereo) {
mapStream(command, audioStream);
if (toStereo && audioStream.getChannels() > 2) {
command.addOutputFileOption("-af", "pan=stereo|FL=FC+0.30*FL+0.30*BL|FR=FC+0.30*FR+0.30*BR");
}
}
@ -174,37 +182,23 @@ public final class FFMpegHelper {
* @param command <p>The list containing the rest of the command.</p>
* @param subtitleStream <p>The subtitle stream to be used.</p>
* @param videoStream <p>The video stream to be used.</p>
* @param file <p>The file to convert.</p>
*/
public static void addSubtitleAndVideoStream(List<String> command, SubtitleStream subtitleStream,
VideoStream videoStream, File file) {
public static void addSubtitleAndVideoStream(@NotNull FFMpegCommand command, @Nullable SubtitleStream subtitleStream,
@NotNull VideoStream videoStream) {
//No appropriate subtitle was found. Just add the video stream.
if (subtitleStream == null) {
addVideoStream(command, videoStream);
mapStream(command, videoStream);
return;
}
//Add the correct command arguments depending on the subtitle type
if (!subtitleStream.getIsImageSubtitle()) {
addSubtitle(command, subtitleStream, videoStream);
} else if (file.getName().equals(subtitleStream.getFile())) {
addInternalImageSubtitle(command, subtitleStream, videoStream);
addBurnedInSubtitle(command, subtitleStream, videoStream);
} else {
addExternalImageSubtitle(command, subtitleStream, videoStream);
addBurnedInImageSubtitle(command, subtitleStream, videoStream);
}
}
/**
* Adds video mapping to a command
*
* @param command <p>The list containing the rest of the command.</p>
* @param videoStream <p>The video stream to be used.</p>
*/
private static void addVideoStream(List<String> command, VideoStream videoStream) {
command.add("-map");
command.add(String.format("0:%d", videoStream.getAbsoluteIndex()));
}
/**
* Adds subtitle commands to a command list
*
@ -212,14 +206,75 @@ public final class FFMpegHelper {
* @param subtitleStream <p>The subtitle stream to add.</p>
* @param videoStream <p>The video stream to burn the subtitle into.</p>
*/
private static void addSubtitle(List<String> command, SubtitleStream subtitleStream, VideoStream videoStream) {
command.add("-map");
command.add(String.format("0:%d", videoStream.getAbsoluteIndex()));
command.add("-vf");
String safeFileName = escapeSpecialCharactersInFileName(subtitleStream.getFile());
private static void addBurnedInSubtitle(@NotNull FFMpegCommand command, @NotNull SubtitleStream subtitleStream,
@NotNull VideoStream videoStream) {
mapStream(command, videoStream);
String safeFileName = escapeSpecialCharactersInFileName(
command.getInputFiles().get(subtitleStream.getInputIndex()));
String subtitleCommand = String.format("subtitles='%s':si=%d", safeFileName,
subtitleStream.getRelativeIndex());
command.add(subtitleCommand);
command.addOutputFileOption("-vf", subtitleCommand);
}
/**
* Adds arguments for converting a file to h264 using hardware acceleration
*
* @param command <p>The command to add the arguments to</p>
* @param quality <p>The quality to encode. 0 = best, 51 = worst.</p>
*/
public static void addH264HardwareEncoding(@NotNull FFMpegCommand command, int quality) {
command.addOutputFileOption("-codec:v", "h264_nvenc");
command.addOutputFileOption("-profile", "high");
command.addOutputFileOption("-preset", "p7");
command.addOutputFileOption("-crf", String.valueOf(quality));
}
/**
* Adds arguments for converting a file to h265 using hardware acceleration
*
* @param command <p>The command to add the arguments to</p>
* @param quality <p>The quality to encode. 0 = best, 51 = worst.</p>
*/
public static void addH265HardwareEncoding(@NotNull FFMpegCommand command, int quality) {
command.addOutputFileOption("-codec:v", "hevc_nvenc");
command.addOutputFileOption("-profile", "main10");
command.addOutputFileOption("-preset", "p7");
command.addOutputFileOption("-tag:v", "hvc1");
command.addOutputFileOption("-crf", String.valueOf(quality));
}
/**
* Adds arguments for using hardware acceleration during h264 decoding
*
* @param command <p>The command to add the arguments to</p>
*/
public static void addH26xHardwareDecoding(@NotNull FFMpegCommand command) {
command.addInputFileOption("-hwaccel", "cuda");
command.addInputFileOption("-hwaccel_output_format", "cuda");
}
/**
* Maps all streams in the given list to the output in the given command
*
* @param command <p>The command to add the mappings to</p>
* @param streams <p>The streams to map</p>
*/
public static void mapAllStreams(@NotNull FFMpegCommand command, @NotNull List<StreamObject> streams) {
for (StreamObject stream : streams) {
mapStream(command, stream);
}
}
/**
* Maps the given stream to the given FFMPEG command's output
*
* @param command <p>The command to map the stream to</p>
* @param stream <p>The stream to map</p>
*/
public static void mapStream(@NotNull FFMpegCommand command, @NotNull StreamObject stream) {
command.addOutputFileOption("-map", String.format("%d:%d",
stream.getInputIndex(), stream.getAbsoluteIndex()));
}
/**
@ -237,38 +292,22 @@ public final class FFMpegHelper {
.replace("[", "\\[");
}
/**
* Adds image subtitle commands to a command list
*
* @param command <p>The list containing the FFmpeg commands.</p>
* @param subtitleStream <p>The subtitle stream to add.</p>
* @param videoStream <p>The video stream to burn the subtitle into.</p>
*/
private static void addInternalImageSubtitle(List<String> command, SubtitleStream subtitleStream,
VideoStream videoStream) {
command.add("-filter_complex");
String filter = String.format("[0:v:%d][0:%d]overlay", videoStream.getRelativeIndex(),
subtitleStream.getAbsoluteIndex());
command.add(filter);
}
/**
* Adds external image subtitle commands to a command list
*
* @param command <p>The list containing the FFmpeg commands.</p>
* @param externalImageSubtitle <p>The external image subtitle stream to add.</p>
* @param videoStream <p>The video stream to burn the subtitle into.</p>
* @param command <p>The FFMPEG command to modify</p>
* @param subtitleStream <p>The external image subtitle stream to burn in</p>
* @param videoStream <p>The video stream to burn the subtitle into</p>
*/
private static void addExternalImageSubtitle(List<String> command, SubtitleStream externalImageSubtitle,
VideoStream videoStream) {
command.add("-i");
command.add(externalImageSubtitle.getFile());
command.add("-filter_complex");
command.add(String.format("[1:s]scale=width=%d:height=%d,crop=w=%d:h=%d:x=0:y=out_h[sub];[%d:v]" +
"[sub]overlay", videoStream.getWidth(), videoStream.getHeight(), videoStream.getWidth(),
videoStream.getHeight(), videoStream.getAbsoluteIndex()));
command.add("-profile:v");
command.add("baseline");
private static void addBurnedInImageSubtitle(@NotNull FFMpegCommand command,
@NotNull SubtitleStream subtitleStream,
@NotNull VideoStream videoStream) {
command.addOutputFileOption("-filter_complex",
String.format("[%d:%d]scale=width=%d:height=%d,crop=w=%d:h=%d:x=0:y=out_h[sub];[%d:%d][sub]overlay",
subtitleStream.getInputIndex(), subtitleStream.getAbsoluteIndex(), videoStream.getWidth(),
videoStream.getHeight(), videoStream.getWidth(), videoStream.getHeight(),
videoStream.getInputIndex(), videoStream.getAbsoluteIndex()));
command.addOutputFileOption("-profile:v", "baseline");
}
/**
@ -280,13 +319,11 @@ public final class FFMpegHelper {
* @throws IOException <p>If something goes wrong while probing.</p>
*/
private static String[] probeForStreams(String ffprobePath, File file) throws IOException {
ProcessBuilder processBuilder = new ProcessBuilder(
ffprobePath,
"-v",
"error",
"-show_streams",
file.toString()
);
FFMpegCommand probeCommand = new FFMpegCommand(ffprobePath);
probeCommand.addGlobalOption("-v", "error", "-show_streams");
probeCommand.addInputFile(file.toString());
ProcessBuilder processBuilder = new ProcessBuilder(probeCommand.getResult());
String result = runProcess(processBuilder, file.getParentFile(), PROBE_SPLIT_CHARACTER, false);
return StringUtil.stringBetween(result, "[STREAM]", "[/STREAM]");
}
@ -298,7 +335,9 @@ public final class FFMpegHelper {
* @param file <p>The file currently being converted.</p>
* @return <p>A list of StreamObjects.</p>
*/
private static List<StreamObject> parseStreams(String ffprobePath, String[] streams, File file) throws IOException {
@NotNull
private static StreamProbeResult parseStreams(@NotNull String ffprobePath, @NotNull String[] streams,
@NotNull File file) throws IOException {
List<StreamObject> parsedStreams = new ArrayList<>();
int relativeAudioIndex = 0;
int relativeVideoIndex = 0;
@ -311,19 +350,19 @@ public final class FFMpegHelper {
String codecType = ValueParsingHelper.parseString(streamInfo.get(StreamTag.CODEC_TYPE), "");
switch (codecType) {
case "video":
parsedStreams.add(new VideoStream(streamInfo, relativeVideoIndex++));
parsedStreams.add(new VideoStream(streamInfo, 0, relativeVideoIndex++));
break;
case "audio":
parsedStreams.add(new AudioStream(streamInfo, relativeAudioIndex++));
parsedStreams.add(new AudioStream(streamInfo, 0, relativeAudioIndex++));
break;
case "subtitle":
parsedStreams.add(new SubtitleStream(streamInfo, relativeSubtitleIndex++, file.getName(), true));
parsedStreams.add(new SubtitleStream(streamInfo, 0, relativeSubtitleIndex++));
break;
}
}
List<StreamObject> externalSubtitles = getExternalSubtitles(ffprobePath, file.getParentFile(), file.getName());
parsedStreams.addAll(externalSubtitles);
return parsedStreams;
StreamProbeResult probeResult = new StreamProbeResult(List.of(file), parsedStreams);
getExternalSubtitles(probeResult, ffprobePath, file.getParentFile(), file.getName());
return probeResult;
}
/**
@ -349,26 +388,25 @@ public final class FFMpegHelper {
}
/**
* Checks whether there exists an external image subtitle with the same filename as the file
* Tries to find any external subtitles adjacent to the first input file, and appends it to the given probe result
*
* @param ffprobePath <p>The path/command to ffprobe.</p>
* @param directory <p>The directory containing the file.</p>
* @param convertingFile <p>The file to be converted.</p>
* @return <p>The extension of the subtitle or empty if no subtitle was found.</p>
* @param ffprobePath <p>The path/command to ffprobe</p>
* @param directory <p>The directory containing the file</p>
* @param convertingFile <p>The first/main file to be converted</p>
*/
@NotNull
private static List<StreamObject> getExternalSubtitles(@NotNull String ffprobePath, @NotNull File directory,
@NotNull String convertingFile) throws IOException {
List<StreamObject> parsedStreams = new ArrayList<>();
private static void getExternalSubtitles(@NotNull StreamProbeResult streamProbeResult,
@NotNull String ffprobePath, @NotNull File directory,
@NotNull String convertingFile) throws IOException {
//Find all files in the same directory with external subtitle formats
if (subtitleFormats == null) {
subtitleFormats = FileUtil.readFileLines("subtitle_formats.txt");
}
File[] subtitleFiles = FileUtil.listFilesRecursive(directory, subtitleFormats, 1);
// TODO: Generalize this for external audio tracks
//Return early if no files were found
if (subtitleFiles == null) {
return parsedStreams;
return;
}
String fileTitle = FileUtil.stripExtension(convertingFile);
@ -379,15 +417,17 @@ public final class FFMpegHelper {
(subtitleFile) -> subtitleFile.getName().contains(fileTitle));
for (File subtitleFile : subtitleFilesList) {
int inputIndex = streamProbeResult.parsedFiles().size();
streamProbeResult.parsedFiles().add(subtitleFile);
//Probe the files and add them to the result list
String[] streams = probeForStreams(ffprobePath, subtitleFile);
int relativeIndex = 0;
for (String stream : streams) {
String[] streamParts = stream.split(PROBE_SPLIT_CHARACTER);
Map<StreamTag, String> streamInfo = getStreamInfo(streamParts);
parsedStreams.add(new SubtitleStream(streamInfo, 0, subtitleFile.getName(), false));
streamProbeResult.parsedStreams().add(new SubtitleStream(streamInfo, inputIndex, relativeIndex++));
}
}
return parsedStreams;
}
/**

View File

@ -142,43 +142,4 @@ public final class FileUtil {
return file.substring(0, file.lastIndexOf('.'));
}
/**
* Gets the locale specifying the language of the given file name
*
* @param fileName <p>The file name to check</p>
* @return <p>The locale, or null if no locale could be parsed</p>
*/
public static @Nullable String getLanguage(@NotNull String fileName) {
fileName = stripExtension(fileName);
String possibleLanguage = getExtension(fileName);
// NRK Nett-TV has a tendency to use nb-ttv for Norwegian for some reason
possibleLanguage = possibleLanguage.replace("nb-ttv", "nb-nor");
// TODO: Some languages are specified by using "-en" or "-English" or ".en" or ".English" at the end of file names
if (possibleLanguage.length() <= 1 || (possibleLanguage.length() >= 4 &&
(!possibleLanguage.contains("-") || possibleLanguage.length() >= 8))) {
return null;
}
// Hope the text is an actual valid language
if (!possibleLanguage.contains("-")) {
return possibleLanguage;
}
// Make sure the "-" has at least two characters on each side
String[] parts = possibleLanguage.split("-");
if (parts[0].length() < 2 || parts[1].length() < 2) {
return null;
}
if (parts[1].length() == 3) {
// Return three-letter country code
return parts[1].toLowerCase();
} else {
// Return en-US country code
return parts[0].substring(0, 2).toLowerCase() + "-" + parts[1].substring(0, 2).toUpperCase();
}
}
}