From 31637425a383521f806f94ec2cd99fb15d364147 Mon Sep 17 00:00:00 2001 From: Kristian Knarvik Date: Mon, 20 Jan 2020 13:46:11 +0100 Subject: [PATCH] Updates project to new structure --- FFmpegConvert/pom.xml | 13 + .../net/knarcraft/ffmpegconvert/Main.java | 352 ++++++++++++++ .../converter/AnimeConverter.java | 130 ++++++ .../converter/AudioConverter.java | 68 +++ .../ffmpegconvert/converter/Converter.java | 441 ++++++++++++++++++ .../converter/VideoConverter.java | 129 +++++ .../ffmpegconvert/streams/AudioStream.java | 29 ++ .../ffmpegconvert/streams/StreamObject.java | 43 ++ .../ffmpegconvert/streams/SubtitleStream.java | 65 +++ .../ffmpegconvert/streams/VideoStream.java | 13 + 10 files changed, 1283 insertions(+) create mode 100644 FFmpegConvert/pom.xml create mode 100644 FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/Main.java create mode 100644 FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/converter/AnimeConverter.java create mode 100644 FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/converter/AudioConverter.java create mode 100644 FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/converter/Converter.java create mode 100644 FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/converter/VideoConverter.java create mode 100644 FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/streams/AudioStream.java create mode 100644 FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/streams/StreamObject.java create mode 100644 FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/streams/SubtitleStream.java create mode 100644 FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/streams/VideoStream.java diff --git a/FFmpegConvert/pom.xml b/FFmpegConvert/pom.xml new file mode 100644 index 0000000..83c8275 --- /dev/null +++ b/FFmpegConvert/pom.xml @@ -0,0 +1,13 @@ + + + 4.0.0 + net.knarcraft + FFmpegConvert + 1.0-SNAPSHOT + jar + + UTF-8 + 11 + 11 + + \ No newline at end of file diff --git a/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/Main.java b/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/Main.java new file mode 100644 index 0000000..b32921f --- /dev/null +++ b/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/Main.java @@ -0,0 +1,352 @@ +package net.knarcraft.ffmpegconvert; + +import ffmpegconverter.converter.AnimeConverter; +import ffmpegconverter.converter.AudioConverter; +import ffmpegconverter.converter.Converter; +import ffmpegconverter.converter.VideoConverter; + +import java.io.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Scanner; +import java.util.function.Predicate; + +/** + * Converts a files or files in a folder to a web playable mp4. + */ +public class Main { + private static final String FFPROBE_PATH = "ffprobe"; //Can be just ffprobe if it's in the path + private static final String FFMPEG_PATH = "ffmpeg"; //Can be just ffmpeg if it's in the path + private static final Scanner READER = new Scanner(System.in, "UTF-8"); + private static final BufferedWriter WRITER = new BufferedWriter(new OutputStreamWriter(System.out)); + private static Converter con = null; + + public static void main(String[] args) throws IOException { + //System.out.println(tokenizer("AnimeConverter -audiolang jap,eng -sublang eng,nor,* \"C:\\Users\\Kristian\\Downloads\\Anime\\[Kametsu] ERASED (BD 1080p Hi10 FLAC)\"")); + //parser(tokenizer("AnimeConverter \"C:\\Users\\Kristian\\Downloads\\Anime\\[Kametsu] ERASED (BD 1080p Hi10 FLAC)\"")); + //System.exit(1); + + int choice = getChoice("Which converter do you want do use?\n1. Anime to web mp4\n2. Audio converter\n3. VideoStream converter", 1, 3); + + printl("Input for this converter:"); + switch (choice) { + case 1: + animeConverter(); + break; + case 2: + con = new AudioConverter(FFPROBE_PATH, FFMPEG_PATH, getChoice("")); + break; + case 3: + con = new VideoConverter(FFPROBE_PATH, FFMPEG_PATH, getChoice("")); + break; + default: + System.exit(1); + } + + int recursionSteps = 1; + + printl(" [Recursions]: "); + List input = readInput(2); + while (input.isEmpty()) { + print("File path required."); + input = readInput(2); + } + File folder = new File(input.get(0)); + if (input.size() > 1) { + try { + recursionSteps = Integer.parseInt(input.get(1)); + } catch (NumberFormatException e) { + printl("Recursion steps is invalid and will be ignored."); + } + } + + if (folder.isDirectory()) { + File[] files = listFilesRec(folder, con.getValidFormats(), recursionSteps); + if (files != null && files.length > 0) { + for (File file : files) { + con.convert(file); + } + } else { + printl("No valid files found in folder."); + } + } else if (folder.exists()) { + con.convert(folder); + } else { + System.out.println("Path " + folder.getAbsolutePath() + " does not point to any file or folder."); + } + WRITER.close(); + } + + /** + * Prints a string + * @param input

The string to print.

+ * @throws IOException + */ + private static void print(String input) throws IOException { + WRITER.write(input); + WRITER.flush(); + } + + /** + * Prints a string and a line break + * @param input

The string to print.

+ * @throws IOException + */ + private static void printl(String input) throws IOException { + WRITER.write(input); + WRITER.newLine(); + WRITER.flush(); + } + + private enum converterArgumentValueType { + BOOLEAN, + COMMA_SEPARATED_LIST, + SINGLE_VALUE, + INT + } + + private static class converterArgument { + private String name; + private boolean valueRequired; + private converterArgumentValueType valueType; + private converterArgument(String name, boolean valueRequired, converterArgumentValueType valueType) { + this.name = name; + this.valueRequired = valueRequired; + this.valueType = valueType; + } + + private boolean testArgumentValue(String value) { + if (value.length() == 0) { + return !valueRequired; + } + if (valueRequired && value.startsWith("-")) { + return false; + } + switch (valueType) { + case BOOLEAN: + String lower = value.toLowerCase(); + return lower.equals("true") || lower.equals("false"); + case COMMA_SEPARATED_LIST: + return !value.contains(" "); + case SINGLE_VALUE: + return !value.contains(" "); + case INT: + Integer.parseInt(value); + return true; + } + return false; + } + } + + private static void parser(List tokens) { + String[] types = {"animeconverter", "audioconverter", "videoconverter"}; + converterArgument[] commonArgs = { + new converterArgument("-recursions", true, converterArgumentValueType.INT) + }; + converterArgument[] animeArgs = { + + }; + converterArgument[] audioArgs = { + new converterArgument("-outext", true, converterArgumentValueType.SINGLE_VALUE) + }; + converterArgument[] videoArgs = { + new converterArgument("-outext", true, converterArgumentValueType.SINGLE_VALUE) + }; + String type = tokens.get(0).toLowerCase(); + if (!listContains(types, s -> s.equals(type))) { + throw new IllegalArgumentException("Unknown converter type chosen."); + } + if (tokens.size() < 2) { + throw new IllegalArgumentException("No file/folder path in argument."); + } + for (int i = 1; i < tokens.size() - 1; i++) { + //TODO: Find the type of argument and check the value + //TODO: Find an executable way to represent the chain of commands parsed + } + } + + /** + * Tokenizes a string + * @param input

A string.

+ * @return

A list of tokens.

+ */ + private static List tokenizer(String input) { + List tokens = new ArrayList<>(); + boolean startedQuote = false; + StringBuilder currentToken = new StringBuilder(); + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + switch (c) { + case ' ': + if (!startedQuote) { + //If not inside "", a space marks the end of a parameter + if (!currentToken.toString().trim().equals("")) { + tokens.add(currentToken.toString()); + currentToken = new StringBuilder(); + } else { + currentToken = new StringBuilder(); + } + } else { + currentToken.append(c); + } + break; + case '"': + if (startedQuote) { + if (!currentToken.toString().trim().equals("")) { + tokens.add(currentToken.toString()); + currentToken = new StringBuilder(); + } + startedQuote = false; + } else { + startedQuote = true; + currentToken = new StringBuilder(); + } break; + default: + currentToken.append(c); + if (i == input.length() - 1) { + tokens.add(currentToken.toString()); + } break; + } + } + return tokens; + } + + /** + * Initializes the anime converter + * @throws IOException + */ + private static void animeConverter() throws IOException { + printl("[Audio languages jpn,eng,ger,fre] [Subtitle languages eng,ger,fre] [Convert to stereo if necessary true/false] [Prevent signs&songs subtitles true/false]\nYour input: "); + List input = readInput(4); + String[] audioLang = new String[]{"jpn", "*"}; + String[] subtitleLang = new String[]{"eng", "*"}; + boolean toStereo = true; + boolean preventSigns = true; + if (input.size() > 0 && getList(input, 0) != null) { + audioLang = getList(input, 0); + } + if (input.size() > 1 && getList(input, 1) != null) { + subtitleLang = getList(input, 1); + } + if (input.size() > 2) { + toStereo = Boolean.parseBoolean(input.get(2)); + } + if (input.size() > 3) { + preventSigns = Boolean.parseBoolean(input.get(3)); + } + con = new AnimeConverter(FFPROBE_PATH, FFMPEG_PATH, audioLang, subtitleLang, toStereo, preventSigns); + } + + /** + * Gets a list from a comma separated string at index in list + * @param list

A list of tokens.

+ * @param index

The index of the token containing comma separated entries.

+ * @return

A string list.

+ */ + private static String[] getList(List list, int index) { + String[] result = null; + if (list.size() > index) { + if (list.get(index).contains(",")) { + result = list.get(index).split(","); + } else { + result = new String[]{list.get(index)}; + } + } + return result; + } + + /** + * Reads a number of tokens from the user input + * @param max

The number of tokens expected.

+ * @return

A list of tokens.

+ */ + private static List readInput(int max) { + List tokens = tokenizer(READER.nextLine()); + if (max < tokens.size()) { + throw new IllegalArgumentException("Input contains " + tokens.size() + + " arguments, but the input only supports " + max + " arguments."); + } + return tokens; + } + + /** + * Gets the user's choice + * @param prompt

The prompt shown to the user.

+ * @return

The non-empty choice given by the user.

+ * @throws IOException + */ + private static String getChoice(String prompt) throws IOException { + printl(prompt); + String choice = ""; + while (choice.equals("")) { + printl("Your input: "); + choice = READER.nextLine(); + } + return choice; + } + + /** + * Gets an integer from the user + * @param prompt The prompt to give the user + * @param min The minimum allowed value + * @param max The maximum allowed value + * @return The value given by the user + */ + private static int getChoice(String prompt, int min, int max) throws IOException { + printl(prompt); + int choice = 0; + while (choice < min || choice > max) { + printl("Your input: "); + try { + choice = Integer.parseInt(READER.next()); + } catch (NumberFormatException e) { + printl("Invalid choice. Please try again."); + } finally { + READER.nextLine(); + } + } + return choice; + } + + /** + * Tests if any element in a list fulfills a condition. + * + * @param list The list to test against + * @param predicate A predicate to use on every element in the list + * @param Anything which can be stored in a list + * @return True if at least one element fulfills the predicate + */ + private static boolean listContains(T[] list, Predicate predicate) { + for (T item : list) { + if (predicate.test(item)) { + return true; + } + } + return false; + } + + /** + * Recursively lists all files in a folder + * + * @param folder The folder to start from + * @param maxRec Maximum number of recursions + * @return A list of files + */ + private static File[] listFilesRec(File folder, String[] extensions, int maxRec) { + if (maxRec == 0) { return null; } + File[] listOfFiles = folder.listFiles((file) -> file.isFile() && listContains(extensions, (item) -> file.getName().endsWith(item))); + if (listOfFiles == null) { return null; } + if (maxRec > 1) { + File[] listOfFolders = folder.listFiles((dir, name) -> new File(dir, name).isDirectory()); + if (listOfFolders != null) { + for (File file : listOfFolders) { + File[] nextLevel = listFilesRec(file, extensions, maxRec - 1); + if (nextLevel != null) { + listOfFiles = Converter.concatenate(listOfFiles, nextLevel); + } + } + } + } + return listOfFiles; + } +} diff --git a/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/converter/AnimeConverter.java b/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/converter/AnimeConverter.java new file mode 100644 index 0000000..3c4abbd --- /dev/null +++ b/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/converter/AnimeConverter.java @@ -0,0 +1,130 @@ +package ffmpegconverter.converter; + +import ffmpegconverter.streams.AudioStream; +import ffmpegconverter.streams.StreamObject; +import ffmpegconverter.streams.SubtitleStream; +import ffmpegconverter.streams.VideoStream; + +import java.io.File; +import java.io.IOException; +import java.util.List; + +public class AnimeConverter extends Converter { + private String[] audioLang; + private String[] subtitleLang; + private boolean toStereo; + private boolean preventSignsAndSongs; + private boolean debug = false; + + /** + * @param ffprobePath Path/command to ffprobe + * @param ffmpegPath Path/command to ffmpeg + * @param audioLang List of wanted audio languages in descending order + * @param subtitleLang List of wanted subtitle languages in descending order + * @param toStereo Convert video with several audio channels to stereo + * @param preventSignsAndSongs Prevent subtitles only converting signs and songs (not speech) + */ + public AnimeConverter(String ffprobePath, String ffmpegPath, String[] audioLang, String[] subtitleLang, boolean toStereo, boolean preventSignsAndSongs) { + this.ffprobePath = ffprobePath; + this.ffmpegPath = ffmpegPath; + this.audioLang = audioLang; + this.subtitleLang = subtitleLang; + this.toStereo = toStereo; + this.preventSignsAndSongs = preventSignsAndSongs; + } + + public void convert(File file) throws IOException { + processFile(file.getParentFile(), file); + } + + /** + * Reads streams from a file, and converts it to an mp4. + * + * @param folder The folder of the file to process + * @param file The file to process + * @throws IOException If the BufferedReader fails + */ + private void processFile(File folder, File file) throws IOException { + List streams = probeFile(ffprobePath, file); + if (streams.isEmpty()) { + throw new IllegalArgumentException("The file has no valid streams. Please make sure the file exists and is not corrupt."); + } + String newPath = fileCollisionPrevention(folder.getAbsolutePath() + File.separator + stripExtension(file) + ".mp4", "mp4"); + printl("Preparing to start process..."); + String[] command = builderCommand(ffmpegPath, file.getName(), streams, newPath); + ProcessBuilder processBuilder = new ProcessBuilder(command); + convertProcess(processBuilder, folder); + } + + /** + * Generates a command for a ProcessBuilder. + * + * @param executable The executable file for ffmpeg + * @param fileName The input file + * @param streams A list of ffprobe streams + * @param outFile The output file + * @return A list of commands + */ + private String[] builderCommand(String executable, String fileName, List streams, String outFile) { + List command = ffmpegWebVideo(executable, fileName); + + if (this.debug) { + addDebug(command, 50, 120); + } + + List audioStreams = filterStreamsByType(streams, "audio"); + List videoStreams = filterStreamsByType(streams, "video"); + List subtitleStreams = filterStreamsByType(streams, "subtitle"); + + audioStreams = filterAudioStreams(audioStreams, audioLang); + subtitleStreams = filterSubtitleStreams(subtitleStreams, subtitleLang, preventSignsAndSongs); + + VideoStream videoStream = null; + AudioStream audioStream = null; + SubtitleStream subtitleStream = null; + if (videoStreams.size() > 0) { + videoStream = videoStreams.get(0); + } + if (audioStreams.size() > 0) { + audioStream = audioStreams.get(0); + } + if (subtitleStreams.size() > 0) { + subtitleStream = subtitleStreams.get(0); + } + + if (videoStream == null) { + throw new IllegalArgumentException("The file does not have any valid video streams."); + } + + 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"); + } + } + + if (subtitleStream != null && subtitleStream.getIsImageSubtitle()) { + command.add("-filter_complex"); + command.add("[0:v:" + videoStream.getAbsoluteIndex() + "][0:" + subtitleStream.getAbsoluteIndex() + "]overlay"); + } else if (subtitleStream != null) { + command.add("-map"); + command.add("0:" + videoStream.getAbsoluteIndex()); + command.add("-vf"); + command.add("subtitles='" + fileName.replace("'", "\'") + "':si=" + + subtitleStream.getRelativeIndex()); + } else { + command.add("-map"); + command.add("0:" + videoStream.getAbsoluteIndex()); + } + + command.add(outFile); + return command.toArray(new String[0]); + } + + @Override + public String[] getValidFormats() { + return VIDEO_FORMATS; + } +} diff --git a/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/converter/AudioConverter.java b/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/converter/AudioConverter.java new file mode 100644 index 0000000..f501e31 --- /dev/null +++ b/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/converter/AudioConverter.java @@ -0,0 +1,68 @@ +package ffmpegconverter.converter; + +import ffmpegconverter.streams.AudioStream; +import ffmpegconverter.streams.StreamObject; + +import java.io.File; +import java.io.IOException; +import java.util.List; + +public class AudioConverter extends Converter { + private String newExt; + + public AudioConverter(String ffprobePath, String ffmpegPath, String newExt) { + this.ffprobePath = ffprobePath; + this.ffmpegPath = ffmpegPath; + this.newExt = newExt; + } + + /** + * Reads streams from a file, and converts it to an mp4. + * + * @param folder The folder of the file to process + * @param file The file to process + * @throws IOException If the BufferedReader fails + */ + private void processFile(File folder, File file, String newExt) throws IOException { + List streams = probeFile(ffprobePath, file); + if (streams.size() == 0) { + throw new IllegalArgumentException("The file has no streams"); + } + String newPath = stripExtension(file) + "." + newExt; + convertProcess(new ProcessBuilder(builderCommand(ffmpegPath, file.getName(), streams, newPath)), folder); + } + + /** + * Generates a command for a ProcessBuilder. + * + * @param executable The executable file for ffmpeg + * @param fileName The input file + * @param streams A list of ffprobe streams + * @param outFile The output file + * @return A list of commands + */ + private String[] builderCommand(String executable, String fileName, List streams, String outFile) { + List command = generalFile(executable, fileName); + List audioStreams = filterStreamsByType(streams, "audio"); + AudioStream audioStream = null; + if (audioStreams.size() > 0) { + audioStream = audioStreams.get(0); + } + if (audioStreams.size() > 0) { + command.add("-map"); + command.add("0:" + audioStream.getAbsoluteIndex()); + } + command.add(outFile); + return command.toArray(new String[0]); + } + + @Override + public String[] getValidFormats() { + return AUDIO_FORMATS; + } + + @Override + public void convert(File file) throws IOException { + processFile(file.getParentFile(), file, newExt); + } +} \ No newline at end of file diff --git a/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/converter/Converter.java b/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/converter/Converter.java new file mode 100644 index 0000000..a9ac324 --- /dev/null +++ b/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/converter/Converter.java @@ -0,0 +1,441 @@ +package ffmpegconverter.converter; + +import ffmpegconverter.streams.AudioStream; +import ffmpegconverter.streams.StreamObject; +import ffmpegconverter.streams.SubtitleStream; +import ffmpegconverter.streams.VideoStream; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.lang.reflect.Array; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.function.Predicate; + +/** + * Implements all methods which can be usefull for any implementation of a converter. + */ +public abstract class Converter { + String ffprobePath; + String ffmpegPath; + + private static final BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(System.out)); + + private static final String PROBE_SPLIT_CHARACTER = "øæåÆØå"; + + public abstract String[] getValidFormats(); + public abstract void convert(File file) throws IOException; + + final String[] AUDIO_FORMATS = new String[] {".3gp", ".aa", ".aac", ".aax", ".act", ".aiff", ".amr", ".ape", ".au", + ".awb", ".dct", ".dss", ".dvf", ".flac", ".gsm", ".iklax", ".ivs", ".m4a", ".m4b", ".m4p", ".mmf", ".mp3", + ".mpc", ".msv", ".ogg", ".oga", ".mogg", ".opus", ".ra", ".rm", ".raw", ".sln", ".tta", ".vox", ".wav", + ".wma", ".wv", ".webm", ".8svx"}; + final String[] VIDEO_FORMATS = new String[] {".avi", ".mpg", ".mpeg", ".mkv", ".wmv", ".flv", ".webm", ".3gp", + ".rmvb", ".3gpp", ".mts", ".m4v", ".mov", ".rm", ".asf", ".mp4", ".vob", ".ogv", ".drc", ".qt", ".yuv", + ".asm", ".m4p", ".mp2", ".mpe", ".mpv", ".m2v", ".svi", ".3g2", ".roq", ".nsv"}; + + /** + * 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 read + */ + static List probeFile(String ffprobePath, File file) throws IOException { + ProcessBuilder builderProbe = new ProcessBuilder( + ffprobePath, + "-v", + "error", + "-show_entries", + "stream_tags=language,title:stream=index,codec_name,codec_type,channels", + file.toString() + ); + print("Probe command: "); + printl(builderProbe.command().toString()); + builderProbe.redirectErrorStream(true); + Process processProbe = builderProbe.start(); + BufferedReader readerProbe = new BufferedReader(new InputStreamReader(processProbe.getInputStream())); + StringBuilder output = new StringBuilder(); + while (processProbe.isAlive()) { + String read = read(readerProbe, PROBE_SPLIT_CHARACTER); + if (!read.equals("")) { + print(read); + output.append(read); + } + } + return parseStreams(stringBetween(output.toString(), "[STREAM]", "[/STREAM]")); + } + + static String fileCollisionPrevention(String targetPath, String extension) { + File file = new File(targetPath); + int i = 1; + while (file.exists()) { + file = new File(stripExtension(file) + "(" + i + ")" + "." + extension); + } + return file.toString(); + } + + /** + * Starts and prints output of a process + * @param process The process to run + * @param folder The folder the process should run in + * @throws IOException If the process can't be read + */ + static void convertProcess(ProcessBuilder process, File folder) throws IOException { + print("Command to be run: "); + printl(process.command().toString()); + process.directory(folder); + process.redirectErrorStream(true); + Process processConvert = process.start(); + BufferedReader readerConvert = new BufferedReader(new InputStreamReader(processConvert.getInputStream())); + while (processConvert.isAlive()) { + String read = read(readerConvert, "\n"); + if (!read.equals("")) { + printl(read); + } + } + printl("FFMPEG is finished."); + } + + /** + * Reads from a process reader. + * + * @param reader The reader of a process + * @return The output from the read + * @throws IOException On reader failure + */ + private static String read(BufferedReader reader, String spacer) throws IOException { + String line; + StringBuilder text = new StringBuilder(); + while (reader.ready() && (line = reader.readLine()) != null && !line.equals("") && !line.equals("\n")) { + text.append(line).append(spacer); + } + return text.toString().trim(); + } + + /** + * @return A base list of ffmpeg commands for converting a video for web + */ + static List ffmpegWebVideo(String executable, String fileName) { + List command = generalFile(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"); + return command; + } + + /** + * @return A base list of ffmpeg commands for converting a file + */ + static List generalFile(String executable, String fileName) { + List command = new ArrayList<>(); + command.add(executable); + command.add("-nostdin"); + command.add("-i"); + command.add(fileName); + return command; + } + + /** + * Adds debugging parameters for only converting parts of a file + * @param command The list containing the command to run + * @param start The offset before converting + * @param length The offset for stopping the conversion + */ + static void addDebug(List command, int start, int length) { + command.add("-ss"); + command.add("" + start); + command.add("-t"); + command.add("" + length); + } + + /** + * Lists all indexes fulfilling a predicate. + * + * @param list A list of ffprobe indexes + * @return An integer list containing just the wanted indexes + */ + private static List listIndexes(String[] list, Predicate p) { + List indexes = new ArrayList<>(); + for (String str : list) { + if (p.test(str)) { + indexes.add(Integer.parseInt(stringBetweenSingle(str, "index=", PROBE_SPLIT_CHARACTER))); + } + } + return indexes; + } + + /** + * Tests a predicate on a list + * @param list A list + * @param p A predicate + * @param Any type + * @return True if the list have an element for which the predicate is true + */ + private static boolean testPredicate(T[] list, Predicate p) { + for (T o : list) { + if (p.test(o)) { + return true; + } + } + return false; + } + + /** + * Finds all substrings between two substrings in a string. + * + * @param string The string containing the substrings + * @param start The substring before the wanted substring + * @param end The substring after the wanted substring + * @return A list of all occurrences of the substring + */ + private static String[] stringBetween(String string, String start, String end) { + int startPos = string.indexOf(start) + start.length(); + if (!string.contains(start) || string.indexOf(end, startPos) < startPos) { + return new String[]{}; + } + int endPos = string.indexOf(end, startPos); + String outString = string.substring(startPos, endPos).trim(); + String nextString = string.substring(endPos + end.length()); + return concatenate(new String[]{outString}, stringBetween(nextString, start, end)); + } + + /** + * Finds a substring between two substrings in a string. + * + * @param string The string containing the substrings + * @param start The substring before the wanted substring + * @param end The substring after the wanted substring + * @return The wanted substring. + */ + private static String stringBetweenSingle(String string, String start, String end) { + int startPos = string.indexOf(start) + start.length(); + if (!string.contains(start) || string.indexOf(end, startPos) < startPos) { + return ""; + } + return string.substring(startPos, string.indexOf(end, startPos)); + } + + /** + * Gets filename without extension from File object + * @param file A file object + * @return A filename + */ + static String stripExtension(File file) { + return file.getName().substring(0, file.getName().lastIndexOf('.')); + } + + /** + * Removes the extension from a file name + * @param file A filename + * @return A filename without its extension + */ + static String stripExtension(String file) { + return file.substring(0, file.lastIndexOf('.')); + } + + /** + * Combines two arrays to one + * + * @param a The first array + * @param b The second array + * @param Any type + * @return A new array containing all elements from the two arrays + */ + public static T[] concatenate(T[] a, T[] b) { + int aLen = a.length; + int bLen = b.length; + @SuppressWarnings("unchecked") + T[] c = (T[]) Array.newInstance(a.getClass().getComponentType(), aLen + bLen); + System.arraycopy(a, 0, c, 0, aLen); + System.arraycopy(b, 0, c, aLen, bLen); + return c; + } + + /** + * Filters parsed streams into one of the stream types + * @param streams A list of stream objects + * @param codecType The codec type of the streams to select + * @param The correct object type for the streams with the selected codec type + * @return A potentially shorter list of streams + */ + static List filterStreamsByType(List streams, String codecType) { + Iterator i = streams.iterator(); + List newStreams = new ArrayList<>(); + while (i.hasNext()) { + StreamObject next = i.next(); + if (next.getCodecType().equals(codecType)) { + newStreams.add((G) next); + } + } + return newStreams; + } + + /** + * Filters and sorts audio streams according to chosen languages + * @param audioStreams A list of audio streams + * @param audioLanguages A list of languages + * @return A list containing just audio tracks of chosen languages, sorted in order of languages + */ + static List filterAudioStreams(List audioStreams, String[] audioLanguages) { + List filtered = new ArrayList<>(); + for (String language : audioLanguages) { + for (AudioStream stream : audioStreams) { + if ((stream.getLanguage() != null && stream.getLanguage().equals(language)) || language.equals("*")) { + filtered.add(stream); + } + } + //Tries to reduce execution time from n^2 + audioStreams.removeAll(filtered); + } + return filtered; + } + + /** + * Filters and sorts subtitle streams according to chosen languages + * @param subtitleStreams A list of subtitle streams + * @param subtitleLanguages A list of languages + * @param preventSignsAndSongs Whether partial subtitles should be avoided + * @return A list containing just subtitles of chosen languages, sorted in order of languages + */ + static List filterSubtitleStreams(List subtitleStreams, String[] subtitleLanguages, + boolean preventSignsAndSongs) { + List filtered = new ArrayList<>(); + //Go through languages. Select all subtitles of the language + for (String language : subtitleLanguages) { + for (SubtitleStream stream : subtitleStreams) { + String streamLanguage = stream.getLanguage(); + if (((streamLanguage != null && streamLanguage.equals(language)) || language.equals("*")) && + (!preventSignsAndSongs || stream.getIsFullSubtitle())) { + filtered.add(stream); + } + } + //Tries to reduce execution time from n^2 + subtitleStreams.removeAll(filtered); + } + return filtered; + } + + /** + * 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 + * @return A list of StreamObjects + */ + private static List parseStreams(String[] streams) { + List parsedStreams = new ArrayList<>(); + int relativeAudioIndex = 0; + int relativeVideoIndex = 0; + int relativeSubtitleIndex = 0; + for (String stream : streams) { + String[] streamParts = stream.split(PROBE_SPLIT_CHARACTER); + if (stream.contains("codec_type=video")) { + parsedStreams.add(parseVideoStream(streamParts, relativeVideoIndex++)); + } else if (stream.contains("codec_type=audio")) { + parsedStreams.add(parseAudioStream(streamParts, relativeAudioIndex++)); + } else if (stream.contains("codec_type=subtitle")) { + parsedStreams.add(parseSubtitleStream(streamParts, relativeSubtitleIndex++)); + } + } + return parsedStreams; + } + + /** + * Parses a list of video stream parameters to a video stream object + * @param streamParts A list of parameters belonging to an video stream + * @param relativeIndex The relative index of the video stream + * @return A SubtitleStream object + * @throws NumberFormatException If codec index contains a non-numeric value + */ + private static VideoStream parseVideoStream(String[] streamParts, int relativeIndex) throws NumberFormatException { + String codec = null; + int absoluteIndex = -1; + for (String streamPart : streamParts) { + if (streamPart.contains("codec_name=")) { + codec = streamPart.replace("codec_name=", ""); + } else if (streamPart.contains("index=")) { + absoluteIndex = Integer.parseInt(streamPart.replace("index=", "")); + } + } + return new VideoStream(codec, absoluteIndex, relativeIndex); + } + + /** + * Parses a list of audio stream parameters to an audio stream object + * @param streamParts A list of parameters belonging to an audio stream + * @param relativeIndex The relative index of the audio stream + * @return A SubtitleStream object + * @throws NumberFormatException If codec index contains a non-numeric value + */ + private static AudioStream parseAudioStream(String[] streamParts, int relativeIndex) throws NumberFormatException { + String codec = null; + int absoluteIndex = -1; + String language = null; + int channels = 0; + String title = ""; + for (String streamPart : streamParts) { + if (streamPart.contains("codec_name=")) { + codec = streamPart.replace("codec_name=", ""); + } else if (streamPart.contains("index=")) { + absoluteIndex = Integer.parseInt(streamPart.replace("index=", "")); + } else if (streamPart.contains("TAG:language=")) { + language = streamPart.replace("TAG:language=", ""); + } else if (streamPart.contains("channels=")) { + channels = Integer.parseInt(streamPart.replace("channels=", "")); + } else if (streamPart.contains("TAG:title=")) { + title = streamPart.replace("TAG:title=", ""); + } + } + return new AudioStream(codec, absoluteIndex, relativeIndex, language, title, channels); + } + + /** + * Parses a list of subtitle stream parameters to a subtitle stream object + * @param streamParts A list of parameters belonging to a subtitle stream + * @param relativeIndex The relative index of the subtitle + * @return A SubtitleStream object + * @throws NumberFormatException If codec index contains a non-numeric value + */ + private static SubtitleStream parseSubtitleStream(String[] streamParts, int relativeIndex) throws NumberFormatException { + String codecName = null; + int absoluteIndex = -1; + String language = null; + String title = ""; + for (String streamPart : streamParts) { + if (streamPart.contains("codec_name=")) { + codecName = streamPart.replace("codec_name=", ""); + } else if (streamPart.contains("index=")) { + absoluteIndex = Integer.parseInt(streamPart.replace("index=", "")); + } else if (streamPart.contains("TAG:language=")) { + language = streamPart.replace("TAG:language=", ""); + } else if (streamPart.contains("TAG:title=")) { + title = streamPart.replace("TAG:title=", ""); + } + } + return new SubtitleStream(codecName, absoluteIndex, relativeIndex, language, title); + } + + static void print(String input) throws IOException { + if (!input.equals("")) { + writer.write(input); + writer.flush(); + } + } + + static void printl(String input) throws IOException { + if (!input.equals("")) { + writer.write(input); + } + writer.newLine(); + writer.flush(); + } +} diff --git a/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/converter/VideoConverter.java b/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/converter/VideoConverter.java new file mode 100644 index 0000000..2f8c519 --- /dev/null +++ b/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/converter/VideoConverter.java @@ -0,0 +1,129 @@ +package ffmpegconverter.converter; + +import ffmpegconverter.streams.AudioStream; +import ffmpegconverter.streams.StreamObject; +import ffmpegconverter.streams.VideoStream; + +import java.io.File; +import java.io.IOException; +import java.util.List; + +public class VideoConverter extends Converter { + private String newExt; + private boolean debug = false; + + public VideoConverter(String ffprobePath, String ffmpegPath, String newExt) { + this.ffprobePath = ffprobePath; + this.ffmpegPath = ffmpegPath; + this.newExt = newExt; + } + + /** + * Reads streams from a file, and converts it to an mp4. + * + * @param folder The folder of the file to process + * @param file The file to process + * @throws IOException If the BufferedReader fails + */ + private void processFile(File folder, File file, String newExt) throws IOException { + List streams = probeFile(ffprobePath, file); + if (streams.size() == 0) { + throw new IllegalArgumentException("The file has no streams"); + } + String newPath = fileCollisionPrevention(folder.getAbsolutePath() + File.separator + stripExtension(file) + "." + newExt, newExt); + convertProcess(new ProcessBuilder(builderCommand(ffmpegPath, file.getName(), streams, newPath, folder)), folder); + } + + /** + * Generates a command for a ProcessBuilder. + * + * @param executable The executable file for ffmpeg + * @param fileName The input file + * @param streams A list of ffprobe streams + * @param outFile The output file + * @return A list of commands + */ + private String[] builderCommand(String executable, String fileName, List streams, String outFile, File folder) { + List command = generalFile(executable, fileName); + + if (this.debug) { + addDebug(command, 50, 120); + } + + List audioStreams = filterStreamsByType(streams, "audio"); + List videoStreams = filterStreamsByType(streams, "video"); + + VideoStream videoStream = null; + AudioStream audioStream = null; + if (videoStreams.size() > 0) { + videoStream = videoStreams.get(0); + } + if (audioStreams.size() > 0) { + audioStream = audioStreams.get(0); + } + + String ext = hasExternalSubtitle(folder.getAbsolutePath(), fileName); + String ext2 = hasExternalImageSubtitle(folder.getAbsolutePath(), fileName); + if (!ext.equals("")) { + command.add("-vf"); + command.add("subtitles=" + stripExtension(fileName) + ext); + } else if (!ext2.equals("")) { + command.add("-i"); + command.add(stripExtension(fileName) + ext2); + if (this.debug) { + addDebug(command, 50, 120); + } + //TODO: Scale subtitles to video + command.add("-filter_complex"); + command.add("[1:s]scale=width=1920:height=800,crop=w=1920:h=800:x=0:y=out_h[sub];[" + videoStream + ":v][sub]overlay"); + command.add("-profile:v"); + command.add("baseline"); + } + + if (ext2.equals("") || !ext.equals("")) { + if (videoStreams.size() > 0) { + command.add("-map"); + command.add("0:" + videoStream); + } + if (audioStreams.size() > 0) { + command.add("-map"); + command.add("0:" + audioStream); + } + } + command.add("-af"); + command.add("pan=stereo|FL < 1.0*FL + 0.707*FC + 0.707*BL|FR < 1.0*FR + 0.707*FC + 0.707*BR"); + + command.add(outFile); + return command.toArray(new String[0]); + } + + private String hasExternalImageSubtitle(String directory, String file) { + String path = stripExtension(file); + for (String s : new String[] {".idx", ".sub"}) { + if (new File(directory + File.separator + path + s).exists()) { + return s; + } + } + return ""; + } + + private String hasExternalSubtitle(String directory, String file) { + String path = stripExtension(file); + for (String s : new String[] {".srt", ".ass"}) { + if (new File(directory + File.separator + path + s).exists()) { + return s; + } + } + return ""; + } + + @Override + public String[] getValidFormats() { + return VIDEO_FORMATS; + } + + @Override + public void convert(File file) throws IOException { + processFile(file.getParentFile(), file, newExt); + } +} \ No newline at end of file diff --git a/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/streams/AudioStream.java b/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/streams/AudioStream.java new file mode 100644 index 0000000..13df042 --- /dev/null +++ b/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/streams/AudioStream.java @@ -0,0 +1,29 @@ +package ffmpegconverter.streams; + +public class AudioStream extends StreamObject { + private String language; //The audio language + private int channels; //Whether mono, stereo, etc + private String title; //Titles exist + + public AudioStream(String codec, int absoluteIndex, int relativeIndex, String language, String title, int channels) { + this.codecType = "audio"; + this.codecName = codec; + this.absoluteIndex = absoluteIndex; + this.language = language; + this.title = title; + this.relativeIndex = relativeIndex; + this.channels = channels; + } + + public String getLanguage() { + return this.language; + } + + public int getChannels() { + return this.channels; + } + + public String getTitle() { + return this.title; + } +} diff --git a/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/streams/StreamObject.java b/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/streams/StreamObject.java new file mode 100644 index 0000000..48812ca --- /dev/null +++ b/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/streams/StreamObject.java @@ -0,0 +1,43 @@ +package ffmpegconverter.streams; + +/** + * An object representation of a stream in a media file + */ +public abstract class StreamObject { + int absoluteIndex; + int relativeIndex; + String codecName; + String codecType; + + /** + * Gets the type of the stream codec (video/audio/subtitle) + * @return codec type + */ + public String getCodecType() { + return this.codecType; + } + + /** + * Gets the name of the stream codec + * @return codec name + */ + public String getCodecName() { + return this.codecName; + } + + /** + * Gets the absolute index of a stream object + * @return absolute index + */ + public int getAbsoluteIndex() { + return this.absoluteIndex; + } + + /** + * Gets the relative index of a stream object (kth element of codec type) + * @return relative index + */ + public int getRelativeIndex() { + return this.relativeIndex; + } +} \ No newline at end of file diff --git a/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/streams/SubtitleStream.java b/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/streams/SubtitleStream.java new file mode 100644 index 0000000..ac896ab --- /dev/null +++ b/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/streams/SubtitleStream.java @@ -0,0 +1,65 @@ +package ffmpegconverter.streams; + +/** + * An object representation of a subtitle stream in a media file + */ +public class SubtitleStream extends StreamObject { + final private String language; + final private String title; //Title shown + final private boolean isFullSubtitle; //Songs and signs will be false + final private boolean isImageSubtitle; + + public SubtitleStream(String codecName, int absoluteIndex, int relativeIndex, String language, String title) { + this.codecType = "subtitle"; + this.codecName = codecName; + this.absoluteIndex = absoluteIndex; + this.language = language; + this.title = title; + this.isFullSubtitle = isFullSubtitle(); + this.relativeIndex = relativeIndex; + this.isImageSubtitle = isImageSubtitle(); + } + + /** + * Checks whether a subtitle is image based (as opposed to text based) + * @return True if the subtitle is image based + */ + private boolean isImageSubtitle() { + return codecName != null && getCodecName().equals("hdmv_pgs_subtitle"); + } + + /** + * Checks whether translates everything (as opposed to just songs and signs) + * @return True if the subtitles translate everything + */ + private boolean isFullSubtitle() { + if (getTitle() == null) { + return false; + } + String titleLowercase = getTitle().toLowerCase(); + return !(titleLowercase.contains("songs and signs") || + titleLowercase.contains("signs and songs") || + titleLowercase.contains("songs & signs") || + titleLowercase.contains("signs & songs") || + titleLowercase.contains("signs/song") || + titleLowercase.contains("songs/sign") || + titleLowercase.contains("[forced]") || + titleLowercase.contains("(forced)")); + } + + public String getLanguage() { + return this.language; + } + + public String getTitle() { + return this.title; + } + + public boolean getIsImageSubtitle() { + return this.isImageSubtitle; + } + + public boolean getIsFullSubtitle() { + return this.isFullSubtitle; + } +} \ No newline at end of file diff --git a/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/streams/VideoStream.java b/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/streams/VideoStream.java new file mode 100644 index 0000000..26db991 --- /dev/null +++ b/FFmpegConvert/src/main/java/net/knarcraft/ffmpegconvert/streams/VideoStream.java @@ -0,0 +1,13 @@ +package ffmpegconverter.streams; + +/** + * An object representation of a video stream in a media file + */ +public class VideoStream extends StreamObject { + public VideoStream(String codec, int absoluteIndex, int relativeIndex) { + this.codecType = "video"; + this.codecName = codec; + this.absoluteIndex = absoluteIndex; + this.relativeIndex = relativeIndex; + } +} \ No newline at end of file