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.
		
			
				
	
	
		
			450 lines
		
	
	
		
			19 KiB
		
	
	
	
		
			Java
		
	
	
	
	
	
			
		
		
	
	
			450 lines
		
	
	
		
			19 KiB
		
	
	
	
		
			Java
		
	
	
	
	
	
| 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 <p>The path/command to ffprobe.</p>
 | |
|      * @param file        <p>The file to probe.</p>
 | |
|      * @return <p>A list of StreamObjects.</p>
 | |
|      * @throws IOException <p>If the process can't be readProcess.</p>
 | |
|      */
 | |
|     @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 files      <p>The files to execute on</p>
 | |
|      * @return <p>A FFMPEG command for web-playable video</p>
 | |
|      */
 | |
|     @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 files      <p>The files to execute on</p>
 | |
|      * @return <p>A basic FFMPEG command</p>
 | |
|      */
 | |
|     @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 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(@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>
 | |
|      */
 | |
|     @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     <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(@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        <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>
 | |
|      */
 | |
|     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        <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 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 <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()));
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Escapes special characters which can cause trouble for ffmpeg
 | |
|      *
 | |
|      * @param fileName <p>The filename to escape.</p>
 | |
|      * @return <p>A filename with known special characters escaped.</p>
 | |
|      */
 | |
|     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        <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 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 <p>The path/command to ffprobe.</p>
 | |
|      * @param file        <p>The file to probe.</p>
 | |
|      * @return <p>A list of streams.</p>
 | |
|      * @throws IOException <p>If something goes wrong while probing.</p>
 | |
|      */
 | |
|     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 <p>A list of all streams for the current file.</p>
 | |
|      * @param file    <p>The file currently being converted.</p>
 | |
|      * @return <p>A list of StreamObjects.</p>
 | |
|      */
 | |
|     @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;
 | |
|         int relativeSubtitleIndex = 0;
 | |
| 
 | |
|         for (String stream : streams) {
 | |
|             String[] streamParts = stream.split(PROBE_SPLIT_CHARACTER);
 | |
|             Map<StreamTag, String> 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 <p>The stream info lines to parse</p>
 | |
|      * @return <p>The stream tag map parsed</p>
 | |
|      */
 | |
|     @NotNull
 | |
|     private static Map<StreamTag, String> getStreamInfo(@Nullable String[] streamParts) {
 | |
|         Map<StreamTag, String> 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    <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>
 | |
|      */
 | |
|     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<File> 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<StreamTag, String> streamInfo = getStreamInfo(streamParts);
 | |
|                 streamProbeResult.parsedStreams().add(new SubtitleStream(streamInfo, inputIndex, relativeIndex++));
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Reads from a process reader
 | |
|      *
 | |
|      * @param reader <p>The reader of a process.</p>
 | |
|      * @return <p>The output from the readProcess.</p>
 | |
|      * @throws IOException <p>On reader failure.</p>
 | |
|      */
 | |
|     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();
 | |
|     }
 | |
| 
 | |
| }
 |