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; import net.knarcraft.ffmpegconverter.streams.SubtitleStream; import net.knarcraft.ffmpegconverter.streams.VideoStream; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.io.InputStreamReader; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; /** * A class which helps with ffmpeg probing and converting */ public final class FFMpegHelper { private static final String PROBE_SPLIT_CHARACTER = "øæåÆØå"; private static String[] subtitleFormats = null; private FFMpegHelper() { } /* Developer notes: Setting the default track: -disposition:s:0 +default Where s,a,v is the type specifier, and the number is the relative index To unset default: -disposition:s:0 -default -map command for reordering: First number is the index of the input file, which is 0, unless more files are given as input Optionally, a,v,s can be used to select the type of stream to map Lastly, the number is either the global index of the stream, or the relative, if a type has been specified If only the first number is given, all streams from that file are selected The output file will contain all mapped streams in the order they are mapped 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. */ /** * Gets streams from a file * * @param ffprobePath

The path/command to ffprobe.

* @param file

The file to probe.

* @return

A list of StreamObjects.

* @throws IOException

If the process can't be readProcess.

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

The executable to use (ffmpeg/ffprobe)

* @param files

The files to execute on

* @return

A FFMPEG command for web-playable video

*/ @NotNull public static FFMpegCommand getFFMpegWebVideoCommand(@NotNull String executable, @NotNull List 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

The executable to use (ffmpeg/ffprobe)

* @param files

The files to execute on

* @return

A basic FFMPEG command

*/ @NotNull public static FFMpegCommand getFFMpegGeneralFileCommand(@NotNull String executable, @NotNull List 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

The command to add to

* @param start

The time to start at

* @param duration

The duration of video to output

*/ 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

The process to run

* @param folder

The folder the process should run in

* @param spacer

The character(s) to use between each new line read

* @param write

Whether to write the output directly instead of storing it

* @throws IOException

If the process can't be readProcess

*/ @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()); //Set directory and error stream processBuilder.directory(folder); processBuilder.redirectErrorStream(true); Process process = processBuilder.start(); BufferedReader processReader = new BufferedReader(new InputStreamReader(process.getInputStream())); StringBuilder output = new StringBuilder(); while (process.isAlive()) { String read = readProcess(processReader, spacer); if (read.isEmpty()) { continue; } if (write) { OutputUtil.println(read); } else { OutputUtil.printDebug(read); output.append(read); } } OutputUtil.println("Process finished."); return output.toString(); } /** * Maps an audio track to a ffmpeg command's output * * @param command

The command to add the audio track to

* @param audioStream

The audio stream to be mapped

* @param toStereo

Whether to convert the audio stream to stereo

*/ 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"); } } /** * Adds subtitles and video mapping to a command * * @param command

The list containing the rest of the command.

* @param subtitleStream

The subtitle stream to be used.

* @param videoStream

The video stream to be used.

*/ 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) { mapStream(command, videoStream); return; } //Add the correct command arguments depending on the subtitle type if (!subtitleStream.getIsImageSubtitle()) { addBurnedInSubtitle(command, subtitleStream, videoStream); } else { addBurnedInImageSubtitle(command, subtitleStream, videoStream); } } /** * Adds subtitle commands to a command list * * @param command

The list containing the FFmpeg commands.

* @param subtitleStream

The subtitle stream to add.

* @param videoStream

The video stream to burn the subtitle into.

*/ 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.addOutputFileOption("-vf", subtitleCommand); } /** * Adds arguments for converting a file to h264 using hardware acceleration * * @param command

The command to add the arguments to

* @param quality

The quality to encode. 0 = best, 51 = worst.

*/ 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

The command to add the arguments to

* @param quality

The quality to encode. 0 = best, 51 = worst.

*/ 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

The command to add the arguments to

*/ 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

The command to add the mappings to

* @param streams

The streams to map

*/ public static void mapAllStreams(@NotNull FFMpegCommand command, @NotNull List streams) { for (StreamObject stream : streams) { mapStream(command, stream); } } /** * Maps the given stream to the given FFMPEG command's output * * @param command

The command to map the stream to

* @param stream

The stream to map

*/ public static void mapStream(@NotNull FFMpegCommand command, @NotNull StreamObject stream) { command.addOutputFileOption("-map", String.format("%d:%d", stream.getInputIndex(), stream.getAbsoluteIndex())); } /** * Escapes special characters which can cause trouble for ffmpeg * * @param fileName

The filename to escape.

* @return

A filename with known special characters escaped.

*/ private static String escapeSpecialCharactersInFileName(String fileName) { return fileName.replaceAll("\\\\", "\\\\\\\\\\\\\\\\") .replaceAll("'", "'\\\\\\\\\\\\\''") .replaceAll("%", "\\\\\\\\\\\\%") .replaceAll(":", "\\\\\\\\\\\\:") .replace("]", "\\]") .replace("[", "\\["); } /** * Adds external image subtitle commands to a command list * * @param command

The FFMPEG command to modify

* @param subtitleStream

The external image subtitle stream to burn in

* @param videoStream

The video stream to burn the subtitle into

*/ 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"); } /** * Gets a list of all streams in a file * * @param ffprobePath

The path/command to ffprobe.

* @param file

The file to probe.

* @return

A list of streams.

* @throws IOException

If something goes wrong while probing.

*/ private static String[] probeForStreams(String ffprobePath, File file) throws IOException { 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]"); } /** * Takes a list of all streams and parses each stream into one of three objects * * @param streams

A list of all streams for the current file.

* @param file

The file currently being converted.

* @return

A list of StreamObjects.

*/ @NotNull private static StreamProbeResult parseStreams(@NotNull String ffprobePath, @NotNull String[] streams, @NotNull File file) throws IOException { List parsedStreams = new ArrayList<>(); int relativeAudioIndex = 0; int relativeVideoIndex = 0; int relativeSubtitleIndex = 0; for (String stream : streams) { String[] streamParts = stream.split(PROBE_SPLIT_CHARACTER); Map streamInfo = getStreamInfo(streamParts); String codecType = ValueParsingHelper.parseString(streamInfo.get(StreamTag.CODEC_TYPE), ""); switch (codecType) { case "video": parsedStreams.add(new VideoStream(streamInfo, 0, relativeVideoIndex++)); break; case "audio": parsedStreams.add(new AudioStream(streamInfo, 0, relativeAudioIndex++)); break; case "subtitle": parsedStreams.add(new SubtitleStream(streamInfo, 0, relativeSubtitleIndex++)); break; } } StreamProbeResult probeResult = new StreamProbeResult(List.of(file), parsedStreams); getExternalSubtitles(probeResult, ffprobePath, file.getParentFile(), file.getName()); return probeResult; } /** * Gets stream info from the given raw stream info lines * * @param streamParts

The stream info lines to parse

* @return

The stream tag map parsed

*/ @NotNull private static Map getStreamInfo(@Nullable String[] streamParts) { Map streamInfo = new HashMap<>(); for (String part : streamParts) { if (part == null || !part.contains("=")) { continue; } String[] keyValue = part.split("="); StreamTag tag = StreamTag.getFromString(keyValue[0]); if (tag != null) { streamInfo.put(tag, keyValue[1]); } } return streamInfo; } /** * Tries to find any external subtitles adjacent to the first input file, and appends it to the given probe result * * @param ffprobePath

The path/command to ffprobe

* @param directory

The directory containing the file

* @param convertingFile

The first/main file to be converted

*/ 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; } String fileTitle = FileUtil.stripExtension(convertingFile); List subtitleFilesList = new ArrayList<>(Arrays.asList(subtitleFiles)); //Finds the files which are subtitles probably belonging to the file subtitleFilesList = ListUtil.getMatching(subtitleFilesList, (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 streamInfo = getStreamInfo(streamParts); streamProbeResult.parsedStreams().add(new SubtitleStream(streamInfo, inputIndex, relativeIndex++)); } } } /** * Reads from a process reader * * @param reader

The reader of a process.

* @return

The output from the readProcess.

* @throws IOException

On reader failure.

*/ private static String readProcess(BufferedReader reader, String spacer) throws IOException { String line; StringBuilder text = new StringBuilder(); while (reader.ready() && (line = reader.readLine()) != null && !line.isEmpty() && !line.equals("\n")) { text.append(line).append(spacer); } return text.toString().trim(); } }