From 155b011c2b8e72245b45803fd1bd61ace6547f08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20T=C3=A2che?= Date: Fri, 9 Aug 2024 20:30:21 +0200 Subject: [PATCH] Pipeline working, implements FFmpegSetupManager --- .idea/scala_compiler.xml | 6 + .idea/sonarlint.xml | 10 ++ .../gtache/autosubtitle/VideoConverter.java | 5 + .../autosubtitle/subtitle/Subtitle.java | 10 ++ .../modules/cli/CliComponent.java | 3 +- .../autosubtitle/impl/Architecture.java | 2 +- .../autosubtitle/impl/VideoInfoImpl.java | 5 - .../autosubtitle/modules/impl/CoreModule.java | 16 +- .../modules/setup/impl/SetupModule.java | 21 +++ .../modules/subtitle/impl/SubtitleModule.java | 7 +- .../process/impl/AbstractProcessRunner.java | 19 +++ .../process/impl/ProcessListenerImpl.java | 3 +- .../setup/impl/AbstractSetupManager.java | 43 ++++- .../converter/impl/SRTSubtitleConverter.java | 12 +- core/src/main/java/module-info.java | 1 + ffmpeg/pom.xml | 8 + .../ffmpeg/FFmpegVideoConverter.java | 58 +++++-- .../modules/ffmpeg/FFProbeInstallerPath.java | 16 ++ .../modules/ffmpeg/FFmpegInstallerPath.java | 16 ++ .../modules/ffmpeg/FFmpegModule.java | 16 +- .../setup/ffmpeg/FFmpegSetupModule.java | 64 ++++++- .../setup/ffmpeg/Decompresser.java | 27 +++ .../ffmpeg/FFmpegSetupConfiguration.java | 25 +++ .../setup/ffmpeg/FFmpegSetupManager.java | 159 +++++++++++++++--- .../setup/ffmpeg/TarDecompresser.java | 57 +++++++ .../setup/ffmpeg/XZDecompresser.java | 28 +++ .../setup/ffmpeg/ZipDecompresser.java | 57 +++++++ ffmpeg/src/main/java/module-info.java | 5 +- .../modules/gui/impl/GuiCoreModule.java | 12 +- gui/fx/pom.xml | 2 +- .../gui/fx/FXMediaController.java | 119 ++++++++----- .../autosubtitle/gui/fx/FXMediaModel.java | 6 +- .../autosubtitle/gui/fx/FXWorkController.java | 58 +++++-- .../autosubtitle/gui/fx/FXWorkModel.java | 9 +- .../autosubtitle/modules/gui/fx/FXModule.java | 2 - gui/fx/src/main/java/module-info.java | 2 +- .../gtache/autosubtitle/gui/fx/workView.fxml | 90 +++++----- gui/pom.xml | 2 +- gui/run/pom.xml | 2 +- .../modules/run/MissingComponentsModule.java | 4 + .../modules/run/RunComponent.java | 7 +- gui/run/src/main/java/module-info.java | 2 +- pom.xml | 18 +- .../setup/whisper/CondaSetupModule.java | 26 +-- .../setup/whisper/WhisperSetupModule.java | 5 - .../whisper/WhisperExtractorModule.java | 4 + .../json/whisper/WhisperJsonModule.java | 4 + .../modules/whisper/WhisperModule.java | 7 +- .../whisper/CondaSetupConfiguration.java | 30 ++++ .../setup/whisper/CondaSetupManager.java | 94 ++++------- .../whisper/WhisperSetupConfiguration.java | 21 +++ .../setup/whisper/WhisperSetupManager.java | 37 ++-- .../whisper/WhisperSubtitleExtractor.java | 34 ++-- .../json/whisper/JSONSubtitleConverter.java | 2 +- 54 files changed, 984 insertions(+), 314 deletions(-) create mode 100644 .idea/scala_compiler.xml create mode 100644 .idea/sonarlint.xml create mode 100644 core/src/main/java/com/github/gtache/autosubtitle/modules/setup/impl/SetupModule.java create mode 100644 ffmpeg/src/main/java/com/github/gtache/autosubtitle/modules/ffmpeg/FFProbeInstallerPath.java create mode 100644 ffmpeg/src/main/java/com/github/gtache/autosubtitle/modules/ffmpeg/FFmpegInstallerPath.java create mode 100644 ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/Decompresser.java create mode 100644 ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/FFmpegSetupConfiguration.java create mode 100644 ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/TarDecompresser.java create mode 100644 ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/XZDecompresser.java create mode 100644 ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/ZipDecompresser.java create mode 100644 whisper/src/main/java/com/github/gtache/autosubtitle/setup/whisper/CondaSetupConfiguration.java create mode 100644 whisper/src/main/java/com/github/gtache/autosubtitle/setup/whisper/WhisperSetupConfiguration.java diff --git a/.idea/scala_compiler.xml b/.idea/scala_compiler.xml new file mode 100644 index 0000000..0717315 --- /dev/null +++ b/.idea/scala_compiler.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/sonarlint.xml b/.idea/sonarlint.xml new file mode 100644 index 0000000..8b17604 --- /dev/null +++ b/.idea/sonarlint.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/api/src/main/java/com/github/gtache/autosubtitle/VideoConverter.java b/api/src/main/java/com/github/gtache/autosubtitle/VideoConverter.java index f8b8e7e..504804d 100644 --- a/api/src/main/java/com/github/gtache/autosubtitle/VideoConverter.java +++ b/api/src/main/java/com/github/gtache/autosubtitle/VideoConverter.java @@ -3,13 +3,18 @@ package com.github.gtache.autosubtitle; import com.github.gtache.autosubtitle.subtitle.SubtitleCollection; import java.io.IOException; +import java.nio.file.Path; import java.util.Collection; public interface VideoConverter { Video addSoftSubtitles(final Video video, final Collection subtitles) throws IOException; + void addSoftSubtitles(final Video video, final Collection subtitles, final Path path) throws IOException; + Video addHardSubtitles(final Video video, final SubtitleCollection subtitles) throws IOException; + void addHardSubtitles(final Video video, final SubtitleCollection subtitles, final Path path) throws IOException; + Audio getAudio(final Video video) throws IOException; } diff --git a/api/src/main/java/com/github/gtache/autosubtitle/subtitle/Subtitle.java b/api/src/main/java/com/github/gtache/autosubtitle/subtitle/Subtitle.java index 773f932..3cac88e 100644 --- a/api/src/main/java/com/github/gtache/autosubtitle/subtitle/Subtitle.java +++ b/api/src/main/java/com/github/gtache/autosubtitle/subtitle/Subtitle.java @@ -29,6 +29,16 @@ public interface Subtitle { return Duration.ofMillis(end() - start()); } + /** + * Checks if the subtitle is shown at the given time + * + * @param time the time + * @return true if the subtitle is shown + */ + default boolean isShowing(final long time) { + return time >= start() && time <= end(); + } + /** * @return the font of the subtitle */ diff --git a/cli/src/main/java/com/github/gtache/autosubtitle/modules/cli/CliComponent.java b/cli/src/main/java/com/github/gtache/autosubtitle/modules/cli/CliComponent.java index ffccb48..9ca64da 100644 --- a/cli/src/main/java/com/github/gtache/autosubtitle/modules/cli/CliComponent.java +++ b/cli/src/main/java/com/github/gtache/autosubtitle/modules/cli/CliComponent.java @@ -4,14 +4,13 @@ import com.github.gtache.autosubtitle.modules.deepl.DeepLModule; import com.github.gtache.autosubtitle.modules.ffmpeg.FFmpegModule; import com.github.gtache.autosubtitle.modules.impl.CoreModule; import com.github.gtache.autosubtitle.modules.subtitle.extractor.whisper.WhisperExtractorModule; -import com.github.gtache.autosubtitle.modules.subtitle.impl.SubtitleModule; import com.github.gtache.autosubtitle.subtitle.converter.SubtitleConverter; import dagger.Component; import javax.inject.Singleton; import java.util.Map; -@Component(modules = {SubtitleModule.class, CoreModule.class, DeepLModule.class, FFmpegModule.class, WhisperExtractorModule.class}) +@Component(modules = {CoreModule.class, DeepLModule.class, FFmpegModule.class, WhisperExtractorModule.class}) @Singleton public interface CliComponent { diff --git a/core/src/main/java/com/github/gtache/autosubtitle/impl/Architecture.java b/core/src/main/java/com/github/gtache/autosubtitle/impl/Architecture.java index 8a26262..20c031b 100644 --- a/core/src/main/java/com/github/gtache/autosubtitle/impl/Architecture.java +++ b/core/src/main/java/com/github/gtache/autosubtitle/impl/Architecture.java @@ -11,7 +11,7 @@ public enum Architecture { //64 bit X86, X86_64, AMD64, //ARM 32 bit - ARM32, ARM, ARMV1, ARMV2, ARMV3, ARMV4, ARMV5, ARMV6, ARMV7, AARCH32, + ARM32, ARM, ARMV1, ARMV2, ARMV3, ARMV4, ARMV5, ARMV6, ARMV7, AARCH32, ARMHF, ARMEL, //ARM 64 bit ARM64, ARMV8, ARMV9, AARCH64, UNKNOWN; diff --git a/core/src/main/java/com/github/gtache/autosubtitle/impl/VideoInfoImpl.java b/core/src/main/java/com/github/gtache/autosubtitle/impl/VideoInfoImpl.java index 051ef21..911faca 100644 --- a/core/src/main/java/com/github/gtache/autosubtitle/impl/VideoInfoImpl.java +++ b/core/src/main/java/com/github/gtache/autosubtitle/impl/VideoInfoImpl.java @@ -21,9 +21,4 @@ public record VideoInfoImpl(String videoFormat, int width, int height, long dura throw new IllegalArgumentException("Duration must be greater than 0 : " + duration); } } - - @Override - public String videoFormat() { - return videoFormat; - } } diff --git a/core/src/main/java/com/github/gtache/autosubtitle/modules/impl/CoreModule.java b/core/src/main/java/com/github/gtache/autosubtitle/modules/impl/CoreModule.java index 96b6728..18768af 100644 --- a/core/src/main/java/com/github/gtache/autosubtitle/modules/impl/CoreModule.java +++ b/core/src/main/java/com/github/gtache/autosubtitle/modules/impl/CoreModule.java @@ -2,16 +2,22 @@ package com.github.gtache.autosubtitle.modules.impl; import com.github.gtache.autosubtitle.impl.Architecture; import com.github.gtache.autosubtitle.impl.OS; +import com.github.gtache.autosubtitle.modules.setup.impl.SetupModule; +import com.github.gtache.autosubtitle.modules.subtitle.impl.SubtitleModule; import dagger.Module; import dagger.Provides; -import javax.inject.Singleton; +/** + * Dagger module for Core + */ +@Module(includes = {SetupModule.class, SubtitleModule.class}) +public final class CoreModule { -@Module -public abstract class CoreModule { + private CoreModule() { + + } @Provides - @Singleton static OS providesOS() { final var name = System.getProperty("os.name"); if (name.contains("Windows")) { @@ -24,14 +30,12 @@ public abstract class CoreModule { } @Provides - @Singleton static Architecture providesArchitecture() { final var arch = System.getProperty("os.arch"); return Architecture.getArchitecture(arch); } @Provides - @Singleton @ExecutableExtension static String providesExecutableExtension(final OS os) { return os == OS.WINDOWS ? ".exe" : ""; diff --git a/core/src/main/java/com/github/gtache/autosubtitle/modules/setup/impl/SetupModule.java b/core/src/main/java/com/github/gtache/autosubtitle/modules/setup/impl/SetupModule.java new file mode 100644 index 0000000..0280791 --- /dev/null +++ b/core/src/main/java/com/github/gtache/autosubtitle/modules/setup/impl/SetupModule.java @@ -0,0 +1,21 @@ +package com.github.gtache.autosubtitle.modules.setup.impl; + +import dagger.Module; +import dagger.Provides; + +import java.net.http.HttpClient; + +/** + * Dagger core module for setup + */ +@Module +public final class SetupModule { + + private SetupModule() { + } + + @Provides + static HttpClient providesHttpClient() { + return HttpClient.newHttpClient(); + } +} diff --git a/core/src/main/java/com/github/gtache/autosubtitle/modules/subtitle/impl/SubtitleModule.java b/core/src/main/java/com/github/gtache/autosubtitle/modules/subtitle/impl/SubtitleModule.java index 7631560..fc04deb 100644 --- a/core/src/main/java/com/github/gtache/autosubtitle/modules/subtitle/impl/SubtitleModule.java +++ b/core/src/main/java/com/github/gtache/autosubtitle/modules/subtitle/impl/SubtitleModule.java @@ -11,10 +11,13 @@ import dagger.multibindings.StringKey; * Dagger module for subtitles */ @Module -public interface SubtitleModule { +public abstract class SubtitleModule { + + private SubtitleModule() { + } @Binds @IntoMap @StringKey("srt") - SubtitleConverter bindsSubtitleConverter(final SRTSubtitleConverter converter); + abstract SubtitleConverter bindsSubtitleConverter(final SRTSubtitleConverter converter); } diff --git a/core/src/main/java/com/github/gtache/autosubtitle/process/impl/AbstractProcessRunner.java b/core/src/main/java/com/github/gtache/autosubtitle/process/impl/AbstractProcessRunner.java index fd5a461..7fa315f 100644 --- a/core/src/main/java/com/github/gtache/autosubtitle/process/impl/AbstractProcessRunner.java +++ b/core/src/main/java/com/github/gtache/autosubtitle/process/impl/AbstractProcessRunner.java @@ -36,6 +36,7 @@ public abstract class AbstractProcessRunner implements ProcessRunner { @Override public Process start(final List args) throws IOException { + logger.info("Running {}", args); final var builder = new ProcessBuilder(args); builder.redirectErrorStream(true); return builder.start(); @@ -46,4 +47,22 @@ public abstract class AbstractProcessRunner implements ProcessRunner { final var process = start(args); return new ProcessListenerImpl(process); } + + /** + * Runs a process and writes the output to the log + * + * @param args the command + * @return the result + * @throws IOException if an error occurs + */ + protected ProcessResult runListen(final List args) throws IOException { + final var listener = startListen(args); + var line = listener.readLine(); + final var processName = args.getFirst(); + while (line != null) { + logger.info("[{}]: {}", processName, line); + line = listener.readLine(); + } + return listener.join(Duration.ofHours(1)); + } } diff --git a/core/src/main/java/com/github/gtache/autosubtitle/process/impl/ProcessListenerImpl.java b/core/src/main/java/com/github/gtache/autosubtitle/process/impl/ProcessListenerImpl.java index 976fd18..57d8f0d 100644 --- a/core/src/main/java/com/github/gtache/autosubtitle/process/impl/ProcessListenerImpl.java +++ b/core/src/main/java/com/github/gtache/autosubtitle/process/impl/ProcessListenerImpl.java @@ -3,7 +3,6 @@ package com.github.gtache.autosubtitle.process.impl; import com.github.gtache.autosubtitle.process.ProcessListener; import com.github.gtache.autosubtitle.process.ProcessResult; -import java.io.BufferedInputStream; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; @@ -30,7 +29,7 @@ public class ProcessListenerImpl implements ProcessListener { */ public ProcessListenerImpl(final Process process) { this.process = Objects.requireNonNull(process); - this.reader = new BufferedReader(new InputStreamReader(new BufferedInputStream(process.getInputStream()), StandardCharsets.UTF_8)); + this.reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)); this.output = new ArrayList<>(); } diff --git a/core/src/main/java/com/github/gtache/autosubtitle/setup/impl/AbstractSetupManager.java b/core/src/main/java/com/github/gtache/autosubtitle/setup/impl/AbstractSetupManager.java index 2c887a7..3d26e26 100644 --- a/core/src/main/java/com/github/gtache/autosubtitle/setup/impl/AbstractSetupManager.java +++ b/core/src/main/java/com/github/gtache/autosubtitle/setup/impl/AbstractSetupManager.java @@ -12,6 +12,10 @@ import org.apache.logging.log4j.Logger; import java.io.IOException; import java.io.UncheckedIOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.nio.file.Files; import java.nio.file.Path; import java.util.Comparator; @@ -19,6 +23,8 @@ import java.util.HashSet; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; +import static java.util.Objects.requireNonNull; + /** * Base class for all {@link SetupManager} implementations */ @@ -26,12 +32,23 @@ public abstract class AbstractSetupManager extends AbstractProcessRunner impleme private static final Logger logger = LogManager.getLogger(AbstractSetupManager.class); private final Set listeners; + private final HttpClient httpClient; /** - * Instantiates the manager + * Instantiates the manager with a default client */ protected AbstractSetupManager() { + this(HttpClient.newHttpClient()); + } + + /** + * Instantiates the manager with the given client + * + * @param httpClient The HTTP client to use + */ + protected AbstractSetupManager(final HttpClient httpClient) { this.listeners = new HashSet<>(); + this.httpClient = requireNonNull(httpClient); } @Override @@ -158,6 +175,30 @@ public abstract class AbstractSetupManager extends AbstractProcessRunner impleme logger.info("{} deleted", path); } + /** + * Downloads a file + * + * @param url The file url + * @param path The save path + * @throws SetupException If an error occurs + */ + protected void download(final String url, final Path path) throws SetupException { + final var request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build(); + try { + final var result = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(path)); + if (result.statusCode() == 200) { + logger.info("{} download successful", path); + } else { + throw new SetupException("Error downloading " + path + ": " + result.body()); + } + } catch (final IOException e) { + throw new SetupException(e); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new SetupException(e); + } + } + private static long getFilesCount(final Path path) throws IOException { try (final var stream = Files.walk(path)) { return stream.count(); diff --git a/core/src/main/java/com/github/gtache/autosubtitle/subtitle/converter/impl/SRTSubtitleConverter.java b/core/src/main/java/com/github/gtache/autosubtitle/subtitle/converter/impl/SRTSubtitleConverter.java index e9bebd5..90068ed 100644 --- a/core/src/main/java/com/github/gtache/autosubtitle/subtitle/converter/impl/SRTSubtitleConverter.java +++ b/core/src/main/java/com/github/gtache/autosubtitle/subtitle/converter/impl/SRTSubtitleConverter.java @@ -35,11 +35,21 @@ public class SRTSubtitleConverter implements SubtitleConverter { return IntStream.range(0, subtitles.size()).mapToObj(i -> { final var subtitle = subtitles.get(i); return (i + 1) + "\n" + - subtitle.start() + " --> " + subtitle.end() + "\n" + + formatTime(subtitle.start()) + " --> " + formatTime(subtitle.end()) + "\n" + subtitle.content(); }).collect(Collectors.joining("\n\n")); } + private static String formatTime(final long time) { + final var millisPerHour = 3600000; + final var millisPerMinute = 60000; + final var hours = time / millisPerHour; + final var minutes = (time - hours * millisPerHour) / millisPerMinute; + final var seconds = (time - hours * millisPerHour - minutes * millisPerMinute) / 1000; + final var millis = time - hours * millisPerHour - minutes * millisPerMinute - seconds * 1000; + return String.format("%02d:%02d:%02d,%03d", hours, minutes, seconds, millis); + } + @Override public SubtitleCollection parse(final String content) throws ParseException { try { diff --git a/core/src/main/java/module-info.java b/core/src/main/java/module-info.java index c05f919..d98ee39 100644 --- a/core/src/main/java/module-info.java +++ b/core/src/main/java/module-info.java @@ -4,6 +4,7 @@ module com.github.gtache.autosubtitle.core { requires transitive com.github.gtache.autosubtitle.api; requires transitive dagger; + requires transitive java.net.http; requires transitive javax.inject; requires org.apache.logging.log4j; diff --git a/ffmpeg/pom.xml b/ffmpeg/pom.xml index 008b641..1ae6047 100644 --- a/ffmpeg/pom.xml +++ b/ffmpeg/pom.xml @@ -16,6 +16,14 @@ com.github.gtache.autosubtitle autosubtitle-core + + org.apache.commons + commons-compress + + + org.tukaani + xz + \ No newline at end of file diff --git a/ffmpeg/src/main/java/com/github/gtache/autosubtitle/ffmpeg/FFmpegVideoConverter.java b/ffmpeg/src/main/java/com/github/gtache/autosubtitle/ffmpeg/FFmpegVideoConverter.java index 26a18b5..afebd92 100644 --- a/ffmpeg/src/main/java/com/github/gtache/autosubtitle/ffmpeg/FFmpegVideoConverter.java +++ b/ffmpeg/src/main/java/com/github/gtache/autosubtitle/ffmpeg/FFmpegVideoConverter.java @@ -49,19 +49,24 @@ public class FFmpegVideoConverter extends AbstractProcessRunner implements Video @Override public Video addSoftSubtitles(final Video video, final Collection subtitles) throws IOException { + final var out = getTempFile("mkv"); //Soft ass subtitles are only supported by mkv apparently + addSoftSubtitles(video, subtitles, out); + return new FileVideoImpl(out, new VideoInfoImpl("mkv", video.info().width(), video.info().height(), video.info().duration())); + } + + @Override + public void addSoftSubtitles(final Video video, final Collection subtitles, final Path path) throws IOException { final var videoPath = getPath(video); final var collectionMap = dumpCollections(subtitles); - final var out = getTempFile("mkv"); //Soft subtitles are only supported by mkv apparently final var args = new ArrayList(); args.add(getFFmpegPath()); + args.add("-y"); args.add("-i"); args.add(videoPath.toString()); collectionMap.forEach((c, p) -> { args.add("-i"); args.add(p.toString()); }); - args.add("-c"); - args.add("copy"); args.add("-map"); args.add("0:v"); args.add("-map"); @@ -71,30 +76,56 @@ public class FFmpegVideoConverter extends AbstractProcessRunner implements Video final var n = i.getAndIncrement(); args.add("-map"); args.add(String.valueOf(n)); + }); + args.add("-c:v"); + args.add("copy"); + args.add("-c:a"); + args.add("copy"); + final var extension = path.toString().substring(path.toString().lastIndexOf('.') + 1); + if (extension.equals("mp4")) { + args.add("-c:s"); + args.add("mov_text"); + } else { + args.add("-c:s"); + args.add(subtitleConverter.formatName()); + } + final var j = new AtomicInteger(0); + collectionMap.forEach((c, p) -> { + final var n = j.getAndIncrement(); args.add("-metadata:s:s:" + n); args.add("language=" + c.language().iso3()); }); - args.add(out.toString()); - run(args); - return new FileVideoImpl(out, new VideoInfoImpl("mkv", video.info().width(), video.info().height(), video.info().duration())); + args.add(path.toString()); + runListen(args); } @Override public Video addHardSubtitles(final Video video, final SubtitleCollection subtitles) throws IOException { + final var out = getTempFile(video.info().videoFormat()); + addHardSubtitles(video, subtitles, out); + return new FileVideoImpl(out, video.info()); + } + + @Override + public void addHardSubtitles(final Video video, final SubtitleCollection subtitles, final Path path) throws IOException { final var videoPath = getPath(video); final var subtitlesPath = dumpSubtitles(subtitles); - final var out = getTempFile(video.info().videoFormat()); - final var subtitleArg = subtitleConverter.formatName().equalsIgnoreCase("ass") ? "ass=" + subtitlesPath : "subtitles=" + subtitlesPath; + final var escapedPath = escapeVF(subtitlesPath.toString()); + final var subtitleArg = subtitleConverter.formatName().equalsIgnoreCase("ass") ? "ass='" + escapedPath + "'" : "subtitles='" + escapedPath + "'"; final var args = List.of( getFFmpegPath(), "-i", videoPath.toString(), "-vf", subtitleArg, - out.toString() + path.toString() ); - run(args); - return new FileVideoImpl(out, video.info()); + runListen(args); + } + + private static String escapeVF(final String path) { + return path.replace("\\", "\\\\").replace(":", "\\:").replace("'", "'\\''") + .replace("%", "\\%"); } @Override @@ -104,6 +135,7 @@ public class FFmpegVideoConverter extends AbstractProcessRunner implements Video final var dumpVideoPath = getTempFile("." + video.info().videoFormat()); final var args = List.of( getFFmpegPath(), + "-y", "-i", videoPath.toString(), "-map", @@ -113,7 +145,7 @@ public class FFmpegVideoConverter extends AbstractProcessRunner implements Video "0:v", dumpVideoPath.toString() ); - run(args); + runListen(args); Files.deleteIfExists(dumpVideoPath); return new FileAudioImpl(audioPath, new AudioInfoImpl("wav", video.info().duration())); } @@ -143,7 +175,7 @@ public class FFmpegVideoConverter extends AbstractProcessRunner implements Video } private Path dumpSubtitles(final SubtitleCollection subtitles) throws IOException { - final var path = getTempFile("ass"); + final var path = getTempFile("srt"); Files.writeString(path, subtitleConverter.format(subtitles)); return path; } diff --git a/ffmpeg/src/main/java/com/github/gtache/autosubtitle/modules/ffmpeg/FFProbeInstallerPath.java b/ffmpeg/src/main/java/com/github/gtache/autosubtitle/modules/ffmpeg/FFProbeInstallerPath.java new file mode 100644 index 0000000..b9a6142 --- /dev/null +++ b/ffmpeg/src/main/java/com/github/gtache/autosubtitle/modules/ffmpeg/FFProbeInstallerPath.java @@ -0,0 +1,16 @@ +package com.github.gtache.autosubtitle.modules.ffmpeg; + +import javax.inject.Qualifier; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Qualifier +@Documented +@Retention(RUNTIME) +@Target({ElementType.PARAMETER, ElementType.METHOD, ElementType.FIELD}) +public @interface FFProbeInstallerPath { +} diff --git a/ffmpeg/src/main/java/com/github/gtache/autosubtitle/modules/ffmpeg/FFmpegInstallerPath.java b/ffmpeg/src/main/java/com/github/gtache/autosubtitle/modules/ffmpeg/FFmpegInstallerPath.java new file mode 100644 index 0000000..3e9d794 --- /dev/null +++ b/ffmpeg/src/main/java/com/github/gtache/autosubtitle/modules/ffmpeg/FFmpegInstallerPath.java @@ -0,0 +1,16 @@ +package com.github.gtache.autosubtitle.modules.ffmpeg; + +import javax.inject.Qualifier; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Qualifier +@Documented +@Retention(RUNTIME) +@Target({ElementType.PARAMETER, ElementType.METHOD, ElementType.FIELD}) +public @interface FFmpegInstallerPath { +} diff --git a/ffmpeg/src/main/java/com/github/gtache/autosubtitle/modules/ffmpeg/FFmpegModule.java b/ffmpeg/src/main/java/com/github/gtache/autosubtitle/modules/ffmpeg/FFmpegModule.java index db82566..44bb4d6 100644 --- a/ffmpeg/src/main/java/com/github/gtache/autosubtitle/modules/ffmpeg/FFmpegModule.java +++ b/ffmpeg/src/main/java/com/github/gtache/autosubtitle/modules/ffmpeg/FFmpegModule.java @@ -4,15 +4,23 @@ import com.github.gtache.autosubtitle.VideoConverter; import com.github.gtache.autosubtitle.VideoLoader; import com.github.gtache.autosubtitle.ffmpeg.FFmpegVideoConverter; import com.github.gtache.autosubtitle.ffmpeg.FFprobeVideoLoader; +import com.github.gtache.autosubtitle.modules.setup.ffmpeg.FFmpegSetupModule; import dagger.Binds; import dagger.Module; -@Module -public interface FFmpegModule { +/** + * Dagger module for FFmpeg + */ +@Module(includes = FFmpegSetupModule.class) +public abstract class FFmpegModule { + + private FFmpegModule() { + + } @Binds - VideoConverter bindsVideoConverter(final FFmpegVideoConverter converter); + abstract VideoConverter bindsVideoConverter(final FFmpegVideoConverter converter); @Binds - VideoLoader bindsVideoLoader(final FFprobeVideoLoader loader); + abstract VideoLoader bindsVideoLoader(final FFprobeVideoLoader loader); } diff --git a/ffmpeg/src/main/java/com/github/gtache/autosubtitle/modules/setup/ffmpeg/FFmpegSetupModule.java b/ffmpeg/src/main/java/com/github/gtache/autosubtitle/modules/setup/ffmpeg/FFmpegSetupModule.java index c80d7cb..c617df7 100644 --- a/ffmpeg/src/main/java/com/github/gtache/autosubtitle/modules/setup/ffmpeg/FFmpegSetupModule.java +++ b/ffmpeg/src/main/java/com/github/gtache/autosubtitle/modules/setup/ffmpeg/FFmpegSetupModule.java @@ -1,7 +1,11 @@ package com.github.gtache.autosubtitle.modules.setup.ffmpeg; +import com.github.gtache.autosubtitle.impl.Architecture; +import com.github.gtache.autosubtitle.impl.OS; import com.github.gtache.autosubtitle.modules.ffmpeg.FFBundledRoot; +import com.github.gtache.autosubtitle.modules.ffmpeg.FFProbeInstallerPath; import com.github.gtache.autosubtitle.modules.ffmpeg.FFmpegBundledPath; +import com.github.gtache.autosubtitle.modules.ffmpeg.FFmpegInstallerPath; import com.github.gtache.autosubtitle.modules.ffmpeg.FFmpegSystemPath; import com.github.gtache.autosubtitle.modules.ffmpeg.FFmpegVersion; import com.github.gtache.autosubtitle.modules.ffmpeg.FFprobeBundledPath; @@ -9,12 +13,18 @@ import com.github.gtache.autosubtitle.modules.ffmpeg.FFprobeSystemPath; import com.github.gtache.autosubtitle.modules.impl.ExecutableExtension; import com.github.gtache.autosubtitle.modules.setup.impl.VideoConverterSetup; import com.github.gtache.autosubtitle.setup.SetupManager; +import com.github.gtache.autosubtitle.setup.ffmpeg.Decompresser; +import com.github.gtache.autosubtitle.setup.ffmpeg.FFmpegSetupConfiguration; import com.github.gtache.autosubtitle.setup.ffmpeg.FFmpegSetupManager; +import com.github.gtache.autosubtitle.setup.ffmpeg.TarDecompresser; +import com.github.gtache.autosubtitle.setup.ffmpeg.XZDecompresser; +import com.github.gtache.autosubtitle.setup.ffmpeg.ZipDecompresser; import dagger.Binds; import dagger.Module; import dagger.Provides; +import dagger.multibindings.IntoMap; +import dagger.multibindings.StringKey; -import javax.inject.Singleton; import java.nio.file.Path; import java.nio.file.Paths; @@ -27,47 +37,87 @@ public abstract class FFmpegSetupModule { private static final String FFMPEG = "ffmpeg"; private static final String FFPROBE = "ffprobe"; + private FFmpegSetupModule() { + + } + + @Binds + @StringKey("zip") + @IntoMap + abstract Decompresser bindsZipDecompresser(final ZipDecompresser decompresser); + + @Binds + @StringKey("tar") + @IntoMap + abstract Decompresser bindsTarDecompresser(final TarDecompresser decompresser); + + @Binds + @StringKey("xz") + @IntoMap + abstract Decompresser bindsXzDecompresser(final XZDecompresser decompresser); + @Binds @VideoConverterSetup abstract SetupManager bindsFFmpegSetupManager(final FFmpegSetupManager manager); @Provides - @Singleton + static FFmpegSetupConfiguration providesFFmpegSetupConfiguration(@FFBundledRoot final Path root, @FFmpegBundledPath final Path bundledPath, @FFmpegSystemPath final Path systemPath, + @FFmpegInstallerPath final Path ffmpegInstallerPath, @FFProbeInstallerPath final Path ffprobeInstallerPath, + final OS os, final Architecture architecture) { + return new FFmpegSetupConfiguration(root, bundledPath, systemPath, ffmpegInstallerPath, ffprobeInstallerPath, os, architecture); + } + + @Provides + @FFmpegInstallerPath + static Path providesFFmpegInstallerPath(@FFBundledRoot final Path root, final OS os) { + return root.resolve("cache").resolve("ffmpeg-installer" + getInstallerExtension(os)); + } + + @Provides + @FFProbeInstallerPath + static Path providesFFProbeInstallerPath(@FFBundledRoot final Path root, final OS os) { + return root.resolve("cache").resolve("ffprobe-installer" + getInstallerExtension(os)); + } + + private static String getInstallerExtension(final OS os) { + if (os == OS.LINUX) { + return ".tar.gz"; + } else { + return ".zip"; + } + } + + @Provides @FFBundledRoot static Path providesFFBundledRoot() { return Paths.get("tools", FFMPEG); } @Provides - @Singleton @FFprobeBundledPath static Path providesFFProbeBundledPath(@FFBundledRoot final Path root, @ExecutableExtension final String extension) { return root.resolve(FFPROBE + extension); } @Provides - @Singleton @FFprobeSystemPath static Path providesFFProbeSystemPath(@ExecutableExtension final String extension) { return Paths.get(FFPROBE + extension); } @Provides - @Singleton @FFmpegBundledPath static Path providesFFmpegBundledPath(@FFBundledRoot final Path root, @ExecutableExtension final String extension) { return root.resolve(FFMPEG + extension); } @Provides - @Singleton @FFmpegSystemPath static Path providesFFmpegSystemPath(@ExecutableExtension final String extension) { return Paths.get(FFMPEG + extension); } @Provides - @Singleton @FFmpegVersion static String providesFFmpegVersion() { return "7.0.1"; diff --git a/ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/Decompresser.java b/ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/Decompresser.java new file mode 100644 index 0000000..d93f9db --- /dev/null +++ b/ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/Decompresser.java @@ -0,0 +1,27 @@ +package com.github.gtache.autosubtitle.setup.ffmpeg; + +import java.io.IOException; +import java.nio.file.Path; + +/** + * Unzips files + */ +public interface Decompresser { + + /** + * Unzips an archive to the given destination + * + * @param archive The archive + * @param destination The destination folder + * @throws IOException if an error occurs + */ + void decompress(final Path archive, final Path destination) throws IOException; + + /** + * Checks whether the given file is supported by the decompresser + * + * @param path The file path + * @return True if the file is supported + */ + boolean isPathSupported(final Path path); +} diff --git a/ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/FFmpegSetupConfiguration.java b/ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/FFmpegSetupConfiguration.java new file mode 100644 index 0000000..49830ef --- /dev/null +++ b/ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/FFmpegSetupConfiguration.java @@ -0,0 +1,25 @@ +package com.github.gtache.autosubtitle.setup.ffmpeg; + +import com.github.gtache.autosubtitle.impl.Architecture; +import com.github.gtache.autosubtitle.impl.OS; + +import java.nio.file.Path; +import java.util.Objects; + +/** + * Configuration for FFmpeg setup + */ +public record FFmpegSetupConfiguration(Path root, Path bundledFFmpegPath, Path systemFFmpegPath, + Path ffmpegInstallerPath, Path ffprobeInstallerPath, + OS os, Architecture architecture) { + + public FFmpegSetupConfiguration { + Objects.requireNonNull(root); + Objects.requireNonNull(bundledFFmpegPath); + Objects.requireNonNull(systemFFmpegPath); + Objects.requireNonNull(ffmpegInstallerPath); + Objects.requireNonNull(ffprobeInstallerPath); + Objects.requireNonNull(os); + Objects.requireNonNull(architecture); + } +} diff --git a/ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/FFmpegSetupManager.java b/ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/FFmpegSetupManager.java index 4df0850..81a6af2 100644 --- a/ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/FFmpegSetupManager.java +++ b/ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/FFmpegSetupManager.java @@ -1,11 +1,8 @@ package com.github.gtache.autosubtitle.setup.ffmpeg; import com.github.gtache.autosubtitle.impl.Architecture; -import com.github.gtache.autosubtitle.impl.OS; -import com.github.gtache.autosubtitle.modules.ffmpeg.FFmpegBundledPath; -import com.github.gtache.autosubtitle.modules.ffmpeg.FFmpegSystemPath; -import com.github.gtache.autosubtitle.modules.ffmpeg.FFmpegVersion; import com.github.gtache.autosubtitle.setup.SetupException; +import com.github.gtache.autosubtitle.setup.SetupManager; import com.github.gtache.autosubtitle.setup.SetupStatus; import com.github.gtache.autosubtitle.setup.impl.AbstractSetupManager; import org.apache.logging.log4j.LogManager; @@ -13,30 +10,30 @@ import org.apache.logging.log4j.Logger; import javax.inject.Inject; import java.io.IOException; +import java.net.http.HttpClient; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Objects; +import java.util.Map; + +import static com.github.gtache.autosubtitle.impl.Architecture.ARMEL; +import static com.github.gtache.autosubtitle.impl.Architecture.ARMHF; +import static java.util.Objects.requireNonNull; /** - * Manager managing the FFmpeg installation + * {@link SetupManager} managing the FFmpeg installation */ +//TODO add gpg/signature check public class FFmpegSetupManager extends AbstractSetupManager { private static final Logger logger = LogManager.getLogger(FFmpegSetupManager.class); - private final Path bundledPath; - private final Path systemPath; - private final String version; - private final OS os; - private final Architecture architecture; - private final String executableExtension; + private final FFmpegSetupConfiguration configuration; + private final Map decompressers; @Inject - FFmpegSetupManager(@FFmpegBundledPath final Path bundledPath, @FFmpegSystemPath final Path systemPath, @FFmpegVersion final String version, final OS os, final Architecture architecture) { - this.bundledPath = Objects.requireNonNull(bundledPath); - this.systemPath = Objects.requireNonNull(systemPath); - this.version = Objects.requireNonNull(version); - this.os = Objects.requireNonNull(os); - this.architecture = Objects.requireNonNull(architecture); - this.executableExtension = os == OS.WINDOWS ? ".exe" : ""; + FFmpegSetupManager(final FFmpegSetupConfiguration configuration, final Map decompressers, + final HttpClient httpClient) { + super(httpClient); + this.configuration = requireNonNull(configuration); + this.decompressers = Map.copyOf(decompressers); } @Override @@ -61,12 +58,128 @@ public class FFmpegSetupManager extends AbstractSetupManager { @Override public void install() throws SetupException { - + switch (configuration.os()) { + case WINDOWS -> installWindows(); + case LINUX -> installLinux(); + case MAC -> installMac(); + } } + private void installWindows() throws SetupException { + final var url = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip"; //.sha256 + downloadFFmpeg(url); + decompressFFmpeg(); + } + + private void downloadFFmpeg(final String url) throws SetupException { + download(url, configuration.ffmpegInstallerPath()); + } + + private void downloadFFProbe(final String url) throws SetupException { + download(url, configuration.ffprobeInstallerPath()); + } + + private void installLinux() throws SetupException { + final var url = getLinuxUrl(); + downloadFFmpeg(url); + decompressFFmpegLinux(); + } + + private void decompressFFmpegLinux() throws SetupException { + try { + final var tmp = Files.createTempFile("ffmpeg", ".tar"); + decompress(configuration.ffmpegInstallerPath(), tmp); + decompress(tmp, configuration.root()); + } catch (final IOException e) { + throw new SetupException(e); + } + } + + private String getLinuxUrl() throws SetupException { + final var architecture = configuration.architecture(); + if (architecture.isAMD64()) { + return "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz"; // .md5 + } else if (architecture.isARM64()) { + return "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz"; + } else if (architecture == Architecture.I686) { + return "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-i686-static.tar.xz"; + } else if (architecture == ARMHF) { + return "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-armhf-static.tar.xz"; + } else if (architecture == ARMEL) { + return "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-armel-static.tar.xz"; + } else { + throwUnsupportedOsArchitectureException(); + return null; + } + } + + private void throwUnsupportedOsArchitectureException() throws SetupException { + throw new SetupException("Unsupported os - architecture : " + configuration.os() + " - " + configuration.architecture()); + } + + private void installMac() throws SetupException { + installFFmpegMac(); + installFFprobeMac(); + } + + private void installFFmpegMac() throws SetupException { + final var url = getMacFFmpegUrl(); + downloadFFmpeg(url); + decompress(configuration.ffmpegInstallerPath(), configuration.root()); + } + + private void decompress(final Path from, final Path to) throws SetupException { + try { + final var filename = from.getFileName().toString(); + final var extension = filename.substring(filename.lastIndexOf('.') + 1); + decompressers.get(extension).decompress(from, to); + } catch (final IOException e) { + throw new SetupException(e); + } + } + + private void decompressFFmpeg() throws SetupException { + decompress(configuration.ffmpegInstallerPath(), configuration.root()); + } + + private void decompressFFProbe() throws SetupException { + decompress(configuration.ffprobeInstallerPath(), configuration.root()); + } + + private void installFFprobeMac() throws SetupException { + final var url = getMacFFprobeUrl(); + downloadFFProbe(url); + decompressFFProbe(); + } + + private String getMacFFmpegUrl() throws SetupException { + final var architecture = configuration.architecture(); + if (architecture.isAMD64()) { + return "https://evermeet.cx/ffmpeg/getrelease/ffmpeg/zip"; // /sig + } else if (architecture.isARM64()) { + return "https://www.osxexperts.net/ffmpeg7arm.zip"; //no automatic sha? + } else { + throwUnsupportedOsArchitectureException(); + return null; + } + } + + private String getMacFFprobeUrl() throws SetupException { + final var architecture = configuration.architecture(); + if (architecture.isAMD64()) { + return "https://evermeet.cx/ffmpeg/getrelease/ffprobe/zip"; + } else if (architecture.isARM64()) { + return "https://www.osxexperts.net/ffprobe7arm.zip"; + } else { + throwUnsupportedOsArchitectureException(); + return null; + } + } + + @Override public void uninstall() throws SetupException { - + deleteFolder(configuration.root()); } @Override @@ -75,11 +188,11 @@ public class FFmpegSetupManager extends AbstractSetupManager { } private boolean checkSystemFFmpeg() throws IOException { - final var result = run(systemPath.toString(), "-version"); + final var result = run(configuration.systemFFmpegPath().toString(), "-version"); return result.exitCode() == 0; } private boolean checkBundledFFmpeg() throws IOException { - return Files.isRegularFile(bundledPath); + return Files.isRegularFile(configuration.bundledFFmpegPath()); } } diff --git a/ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/TarDecompresser.java b/ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/TarDecompresser.java new file mode 100644 index 0000000..a86568d --- /dev/null +++ b/ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/TarDecompresser.java @@ -0,0 +1,57 @@ +package com.github.gtache.autosubtitle.setup.ffmpeg; + +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Tar implementation of {@link Decompresser} + */ +public class TarDecompresser implements Decompresser { + @Override + public void decompress(final Path archive, final Path destination) throws IOException { + if (!isPathSupported(archive)) { + throw new IllegalArgumentException("Unsupported path : " + archive); + } + try (final var zis = new TarArchiveInputStream(Files.newInputStream(archive))) { + var entry = zis.getNextEntry(); + while (entry != null) { + final var newFile = newFile(destination, entry); + if (entry.isDirectory()) { + Files.createDirectories(newFile); + } else { + // fix for Windows-created archives + final var parent = newFile.getParent(); + Files.createDirectories(parent); + + // write file content + try (final var fos = Files.newOutputStream(newFile)) { + zis.transferTo(fos); + } + } + entry = zis.getNextEntry(); + } + } + } + + private static Path newFile(final Path destinationDir, final TarArchiveEntry entry) throws IOException { + final var destPath = destinationDir.resolve(entry.getName()); + + final var destDirPath = destinationDir.toAbsolutePath().toString(); + final var destFilePath = destPath.toAbsolutePath().toString(); + + if (!destFilePath.startsWith(destDirPath + File.separator)) { + throw new IOException("Entry is outside of the target dir: " + entry.getName()); + } + return destPath; + } + + @Override + public boolean isPathSupported(final Path path) { + return path.getFileName().toString().endsWith(".tar"); + } +} diff --git a/ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/XZDecompresser.java b/ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/XZDecompresser.java new file mode 100644 index 0000000..81b4e77 --- /dev/null +++ b/ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/XZDecompresser.java @@ -0,0 +1,28 @@ +package com.github.gtache.autosubtitle.setup.ffmpeg; + +import org.tukaani.xz.XZInputStream; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * XZ implementation of {@link Decompresser} + */ +public class XZDecompresser implements Decompresser { + @Override + public void decompress(final Path archive, final Path destination) throws IOException { + if (!isPathSupported(archive)) { + throw new IllegalArgumentException("Unsupported path : " + archive); + } + try (final var xzIn = new XZInputStream(Files.newInputStream(archive)); + final var out = Files.newOutputStream(destination)) { + xzIn.transferTo(out); + } + } + + @Override + public boolean isPathSupported(final Path path) { + return path.getFileName().toString().endsWith(".xz"); + } +} diff --git a/ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/ZipDecompresser.java b/ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/ZipDecompresser.java new file mode 100644 index 0000000..931d9e0 --- /dev/null +++ b/ffmpeg/src/main/java/com/github/gtache/autosubtitle/setup/ffmpeg/ZipDecompresser.java @@ -0,0 +1,57 @@ +package com.github.gtache.autosubtitle.setup.ffmpeg; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * Zip implementation of {@link Decompresser} + */ +public class ZipDecompresser implements Decompresser { + @Override + public void decompress(final Path archive, final Path destination) throws IOException { + if (!isPathSupported(archive)) { + throw new IllegalArgumentException("Unsupported path : " + archive); + } + try (final var zis = new ZipInputStream(Files.newInputStream(archive))) { + var zipEntry = zis.getNextEntry(); + while (zipEntry != null) { + final var newFile = newFile(destination, zipEntry); + if (zipEntry.isDirectory()) { + Files.createDirectories(newFile); + } else { + // fix for Windows-created archives + final var parent = newFile.getParent(); + Files.createDirectories(parent); + + // write file content + try (final var fos = Files.newOutputStream(newFile)) { + zis.transferTo(fos); + } + } + zipEntry = zis.getNextEntry(); + } + zis.closeEntry(); + } + } + + private static Path newFile(final Path destinationDir, final ZipEntry zipEntry) throws IOException { + final var destPath = destinationDir.resolve(zipEntry.getName()); + + final var destDirPath = destinationDir.toAbsolutePath().toString(); + final var destFilePath = destPath.toAbsolutePath().toString(); + + if (!destFilePath.startsWith(destDirPath + File.separator)) { + throw new IOException("Entry is outside of the target dir: " + zipEntry.getName()); + } + return destPath; + } + + @Override + public boolean isPathSupported(final Path path) { + return path.getFileName().toString().endsWith(".zip"); + } +} diff --git a/ffmpeg/src/main/java/module-info.java b/ffmpeg/src/main/java/module-info.java index 0295b4d..59d58fa 100644 --- a/ffmpeg/src/main/java/module-info.java +++ b/ffmpeg/src/main/java/module-info.java @@ -5,11 +5,14 @@ module com.github.gtache.autosubtitle.ffmpeg { requires transitive com.github.gtache.autosubtitle.core; requires transitive dagger; requires transitive javax.inject; + requires java.net.http; requires org.apache.logging.log4j; + requires org.tukaani.xz; + requires org.apache.commons.compress; exports com.github.gtache.autosubtitle.ffmpeg; exports com.github.gtache.autosubtitle.setup.ffmpeg; - + exports com.github.gtache.autosubtitle.modules.ffmpeg; exports com.github.gtache.autosubtitle.modules.setup.ffmpeg; } \ No newline at end of file diff --git a/gui/core/src/main/java/com/github/gtache/autosubtitle/modules/gui/impl/GuiCoreModule.java b/gui/core/src/main/java/com/github/gtache/autosubtitle/modules/gui/impl/GuiCoreModule.java index 8845a2d..dff92ff 100644 --- a/gui/core/src/main/java/com/github/gtache/autosubtitle/modules/gui/impl/GuiCoreModule.java +++ b/gui/core/src/main/java/com/github/gtache/autosubtitle/modules/gui/impl/GuiCoreModule.java @@ -4,7 +4,6 @@ import com.github.gtache.autosubtitle.gui.impl.CombinedResourceBundle; import dagger.Module; import dagger.Provides; -import javax.inject.Singleton; import java.io.IOException; import java.io.UncheckedIOException; import java.util.ResourceBundle; @@ -13,9 +12,12 @@ import java.util.ResourceBundle; * Dagger module for GUI */ @Module -public class GuiCoreModule { +public final class GuiCoreModule { + + private GuiCoreModule() { + } + @Provides - @Singleton static ResourceBundle providesBundle() { return new CombinedResourceBundle(ResourceBundle.getBundle("com.github.gtache.autosubtitle.gui.impl.MainBundle"), ResourceBundle.getBundle("com.github.gtache.autosubtitle.gui.impl.SetupBundle"), @@ -25,7 +27,6 @@ public class GuiCoreModule { } @Provides - @Singleton @Play static byte[] providesPlayImage() { try (final var in = GuiCoreModule.class.getResourceAsStream("/com/github/gtache/autosubtitle/gui/impl/play_64.png")) { @@ -36,7 +37,6 @@ public class GuiCoreModule { } @Provides - @Singleton @Pause static byte[] providesPauseImage() { try (final var in = GuiCoreModule.class.getResourceAsStream("/com/github/gtache/autosubtitle/gui/impl/pause_64.png")) { @@ -47,14 +47,12 @@ public class GuiCoreModule { } @Provides - @Singleton @FontFamily static String providesFontFamily() { return "Arial"; } @Provides - @Singleton @FontSize static int providesFontSize() { return 12; diff --git a/gui/fx/pom.xml b/gui/fx/pom.xml index 05a4ad7..40ebd2e 100644 --- a/gui/fx/pom.xml +++ b/gui/fx/pom.xml @@ -9,7 +9,7 @@ 1.0-SNAPSHOT - autosubtitle-fx + autosubtitle-gui-fx 11.2.1 diff --git a/gui/fx/src/main/java/com/github/gtache/autosubtitle/gui/fx/FXMediaController.java b/gui/fx/src/main/java/com/github/gtache/autosubtitle/gui/fx/FXMediaController.java index 135a8b2..07b8b39 100644 --- a/gui/fx/src/main/java/com/github/gtache/autosubtitle/gui/fx/FXMediaController.java +++ b/gui/fx/src/main/java/com/github/gtache/autosubtitle/gui/fx/FXMediaController.java @@ -1,14 +1,16 @@ package com.github.gtache.autosubtitle.gui.fx; +import com.github.gtache.autosubtitle.File; import com.github.gtache.autosubtitle.gui.MediaController; import com.github.gtache.autosubtitle.gui.TimeFormatter; -import com.github.gtache.autosubtitle.impl.FileVideoImpl; import com.github.gtache.autosubtitle.modules.gui.impl.Pause; import com.github.gtache.autosubtitle.modules.gui.impl.Play; +import com.github.gtache.autosubtitle.subtitle.EditableSubtitle; import com.github.gtache.autosubtitle.subtitle.Subtitle; import com.github.gtache.autosubtitle.subtitle.gui.fx.SubtitleLabel; import javafx.application.Platform; import javafx.beans.binding.Bindings; +import javafx.collections.ListChangeListener; import javafx.fxml.FXML; import javafx.geometry.Insets; import javafx.scene.Cursor; @@ -31,6 +33,11 @@ import org.apache.logging.log4j.Logger; import javax.inject.Inject; import javax.inject.Singleton; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; import static java.util.Objects.requireNonNull; @@ -62,6 +69,8 @@ public class FXMediaController implements MediaController { private final Image playImage; private final Image pauseImage; + private final List startTimes; + private boolean wasPlaying; @Inject @@ -72,6 +81,7 @@ public class FXMediaController implements MediaController { this.timeFormatter = requireNonNull(timeFormatter); this.playImage = requireNonNull(playImage); this.pauseImage = requireNonNull(pauseImage); + this.startTimes = new ArrayList<>(); } @FXML @@ -101,50 +111,78 @@ public class FXMediaController implements MediaController { if (videoView.getMediaPlayer() != null) { videoView.getMediaPlayer().dispose(); } - if (newValue instanceof final FileVideoImpl fileVideo) { - final var media = new Media(fileVideo.path().toUri().toString()); - final var player = new MediaPlayer(media); - player.currentTimeProperty().addListener((ignored, oldTime, newTime) -> { - final var millis = newTime.toMillis(); - playSlider.setValue(millis); - stackPane.getChildren().removeIf(Label.class::isInstance); - model.subtitles().forEach(s -> { - //TODO optimize using e.g. direction of playback - if (s.start() <= millis && s.end() >= millis) { - logger.info("Adding label {} at {}", s, millis); - final var label = createDraggableLabel(s); - stackPane.getChildren().add(label); - } - }); - }); - playSlider.setOnMousePressed(e -> { - wasPlaying = model.isPlaying(); - model.setIsPlaying(false); - }); - playSlider.valueProperty().addListener(observable1 -> { - if (playSlider.isValueChanging()) { - seek((long) playSlider.getValue()); - } - }); - playSlider.setOnMouseReleased(e -> { - final var value = playSlider.getValue(); - Platform.runLater(() -> { - seek((long) value); - model.setIsPlaying(wasPlaying); - }); - }); - player.volumeProperty().bindBidirectional(model.volumeProperty()); - player.setOnPlaying(() -> model.setIsPlaying(true)); - player.setOnPaused(() -> model.setIsPlaying(false)); - player.setOnEndOfMedia(() -> model.setIsPlaying(false)); - playSlider.setMax(model.duration()); - playSlider.setValue(0L); - videoView.setMediaPlayer(player); + if (newValue instanceof final File file) { + loadFileVideo(file.path()); } else { logger.error("Unsupported video type : {}", newValue); } }); + model.subtitles().addListener((ListChangeListener) c -> { + startTimes.clear(); + model.subtitles().stream().mapToLong(Subtitle::start).forEach(startTimes::add); + }); + bindPlayButton(); + binder.createBindings(); + } + + private void loadFileVideo(final Path fileVideoPath) { + final var media = new Media(fileVideoPath.toUri().toString()); + final var player = new MediaPlayer(media); + player.statusProperty().addListener((observable12, oldValue1, newValue1) -> + logger.info("New status: {}", newValue1)); + player.currentTimeProperty().addListener((ignored, oldTime, newTime) -> currentTimeChanged(oldTime.toMillis(), newTime.toMillis())); + playSlider.setOnMousePressed(e -> { + wasPlaying = model.isPlaying(); + model.setIsPlaying(false); + }); + playSlider.valueProperty().addListener(observable1 -> { + if (playSlider.isValueChanging()) { + seek((long) playSlider.getValue()); + } + }); + playSlider.setOnMouseReleased(e -> { + final var value = playSlider.getValue(); + Platform.runLater(() -> { + seek((long) value); + model.setIsPlaying(wasPlaying); + }); + }); + player.volumeProperty().bindBidirectional(model.volumeProperty()); + player.setOnPlaying(() -> model.setIsPlaying(true)); + player.setOnPaused(() -> model.setIsPlaying(false)); + player.setOnEndOfMedia(() -> model.setIsPlaying(false)); + playSlider.setMax(model.duration()); + playSlider.setValue(0L); + videoView.setMediaPlayer(player); + } + + private void currentTimeChanged(final double oldMillis, final double millis) { + playSlider.setValue(millis); + final var subtitleLabels = stackPane.getChildren().stream().filter(SubtitleLabel.class::isInstance).map(SubtitleLabel.class::cast).toList(); + subtitleLabels.stream().filter(s -> !s.subtitle().isShowing((long) millis)).forEach(sl -> stackPane.getChildren().remove(sl)); + final var containedSubtitles = subtitleLabels.stream().map(SubtitleLabel::subtitle).filter(s -> s.isShowing((long) millis)).collect(Collectors.toSet()); + + model.subtitles().forEach(s -> { + if (!containedSubtitles.contains(s)) { + logger.info("Adding label {} at {}", s, millis); + final var label = createDraggableLabel(s); + stackPane.getChildren().add(label); + } + }); + } + + private void currentTimeChangedOptimized(final double oldMillis, final double millis) { + final var forward = oldMillis <= millis; + + var index = Collections.binarySearch(startTimes, (long) millis); + if (index < 0) { + index = forward ? -(index + 1) : -(index + 2); + } + //TODO + } + + private void bindPlayButton() { playButton.disableProperty().bind(model.videoProperty().isNull()); playButton.graphicProperty().bind(Bindings.createObjectBinding(() -> { final ImageView view; @@ -158,7 +196,6 @@ public class FXMediaController implements MediaController { view.setFitHeight(24); return view; }, model.isPlayingProperty())); - binder.createBindings(); } @FXML diff --git a/gui/fx/src/main/java/com/github/gtache/autosubtitle/gui/fx/FXMediaModel.java b/gui/fx/src/main/java/com/github/gtache/autosubtitle/gui/fx/FXMediaModel.java index 74c7498..385cbba 100644 --- a/gui/fx/src/main/java/com/github/gtache/autosubtitle/gui/fx/FXMediaModel.java +++ b/gui/fx/src/main/java/com/github/gtache/autosubtitle/gui/fx/FXMediaModel.java @@ -6,10 +6,10 @@ import com.github.gtache.autosubtitle.subtitle.EditableSubtitle; import javafx.beans.binding.Bindings; import javafx.beans.property.*; import javafx.collections.FXCollections; +import javafx.collections.ObservableList; import javax.inject.Inject; import javax.inject.Singleton; -import java.util.List; /** * FX implementation of {@link com.github.gtache.autosubtitle.gui.MediaModel} @@ -22,7 +22,7 @@ public class FXMediaModel implements MediaModel { private final BooleanProperty isPlaying; private final ReadOnlyLongWrapper duration; private final LongProperty position; - private final List subtitles; + private final ObservableList subtitles; @Inject FXMediaModel() { @@ -103,7 +103,7 @@ public class FXMediaModel implements MediaModel { } @Override - public List subtitles() { + public ObservableList subtitles() { return subtitles; } diff --git a/gui/fx/src/main/java/com/github/gtache/autosubtitle/gui/fx/FXWorkController.java b/gui/fx/src/main/java/com/github/gtache/autosubtitle/gui/fx/FXWorkController.java index eb796cb..f6fc39b 100644 --- a/gui/fx/src/main/java/com/github/gtache/autosubtitle/gui/fx/FXWorkController.java +++ b/gui/fx/src/main/java/com/github/gtache/autosubtitle/gui/fx/FXWorkController.java @@ -59,6 +59,7 @@ public class FXWorkController extends AbstractFXController implements WorkContro private static final Logger logger = LogManager.getLogger(FXWorkController.class); + private static final String ALL_SUPPORTED = "All supported"; private static final List VIDEO_EXTENSIONS = Stream.of("webm", "mkv", "flv", "vob", "ogv", "ogg", "drc", "gif", "gifv", "mng", "avi", "mts", "m2ts", "ts", "mov", "qt", "wmv", "yuv", "rm", "rmvb", "viv", "asf", "amv", "mp4", "m4p", "m4v", "mpg", "mp2", "mpeg", "mpe", "mpv", "m2v", "m4v", "svi", @@ -131,21 +132,42 @@ public class FXWorkController extends AbstractFXController implements WorkContro @FXML private void initialize() { + bindComboboxes(); + bindButtons(); + bindTable(); + bindProgress(); + + model.selectedSubtitleProperty().addListener((observable, oldValue, newValue) -> { + if (newValue != null) { + mediaController.seek(newValue.start()); + } + }); + + binder.createBindings(); + + subtitleExtractor.addListener(this); + } + + private void bindComboboxes() { languageCombobox.valueProperty().bindBidirectional(model.videoLanguageProperty()); languageCombobox.setItems(model.availableVideoLanguages()); languageCombobox.setConverter(new LanguageStringConverter()); translationsCombobox.setConverter(new LanguageStringConverter()); Bindings.bindContent(translationsCombobox.getItems(), model.availableTranslationsLanguage()); Bindings.bindContent(model.translations(), translationsCombobox.getCheckModel().getCheckedItems()); + } + private void bindButtons() { extractButton.disableProperty().bind(model.videoProperty().isNull().or(model.statusProperty().isNotEqualTo(WorkStatus.IDLE))); + addSubtitleButton.disableProperty().bind(model.videoProperty().isNull().or(model.statusProperty().isNotEqualTo(WorkStatus.IDLE))); loadSubtitlesButton.disableProperty().bind(model.videoProperty().isNull().or(model.statusProperty().isNotEqualTo(WorkStatus.IDLE))); resetButton.disableProperty().bind(Bindings.isEmpty(model.subtitles()).or(model.statusProperty().isNotEqualTo(WorkStatus.IDLE))); exportSoftButton.disableProperty().bind(Bindings.isEmpty(model.subtitles()).or(model.statusProperty().isNotEqualTo(WorkStatus.IDLE))); exportHardButton.disableProperty().bind(Bindings.isEmpty(model.subtitles()).or(model.statusProperty().isNotEqualTo(WorkStatus.IDLE))); - addSubtitleButton.disableProperty().bind(Bindings.isEmpty(model.subtitles()).or(model.statusProperty().isNotEqualTo(WorkStatus.IDLE))); - loadSubtitlesButton.disableProperty().bind(Bindings.isEmpty(model.subtitles()).or(model.statusProperty().isNotEqualTo(WorkStatus.IDLE))); + saveSubtitlesButton.disableProperty().bind(Bindings.isEmpty(model.subtitles()).or(model.statusProperty().isNotEqualTo(WorkStatus.IDLE))); + } + private void bindTable() { subtitlesTable.setItems(model.subtitles()); subtitlesTable.setOnKeyPressed(e -> { if (e.getCode().isLetterKey() || e.getCode().isDigitKey()) { @@ -157,6 +179,9 @@ public class FXWorkController extends AbstractFXController implements WorkContro } else if (e.getCode() == KeyCode.LEFT) { subtitlesTable.getSelectionModel().selectPrevious(); e.consume(); + } else if (e.getCode() == KeyCode.DELETE) { + deleteSelectedSubtitles(); + e.consume(); } }); startColumn.setCellFactory(TextFieldTableCell.forTableColumn(new TimeStringConverter(timeFormatter))); @@ -185,11 +210,9 @@ public class FXWorkController extends AbstractFXController implements WorkContro }); subtitlesTable.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, newValue) -> model.selectedSubtitleProperty().set(newValue)); - model.selectedSubtitleProperty().addListener((observable, oldValue, newValue) -> { - if (newValue != null) { - mediaController.seek(newValue.start()); - } - }); + } + + private void bindProgress() { progressLabel.textProperty().bind(Bindings.createStringBinding(() -> resources.getString("work.status." + model.status().name().toLowerCase() + ".label"), model.statusProperty())); progressLabel.visibleProperty().bind(model.statusProperty().isNotEqualTo(WorkStatus.IDLE)); progressBar.visibleProperty().bind(model.statusProperty().isNotEqualTo(WorkStatus.IDLE)); @@ -198,9 +221,10 @@ public class FXWorkController extends AbstractFXController implements WorkContro progressBar.managedProperty().bind(model.statusProperty().isNotEqualTo(WorkStatus.IDLE)); progressDetailLabel.managedProperty().bind(model.statusProperty().isNotEqualTo(WorkStatus.IDLE)); progressBar.progressProperty().bindBidirectional(model.progressProperty()); - binder.createBindings(); + } - subtitleExtractor.addListener(this); + private void deleteSelectedSubtitles() { + model.subtitles().removeAll(subtitlesTable.getSelectionModel().getSelectedItems()); } private void editFocusedCell() { @@ -213,7 +237,7 @@ public class FXWorkController extends AbstractFXController implements WorkContro @FXML private void fileButtonPressed() { final var filePicker = new FileChooser(); - final var extensionFilter = new FileChooser.ExtensionFilter("All supported", VIDEO_EXTENSIONS); + final var extensionFilter = new FileChooser.ExtensionFilter(ALL_SUPPORTED, VIDEO_EXTENSIONS); filePicker.getExtensionFilters().add(extensionFilter); filePicker.setSelectedExtensionFilter(extensionFilter); final var file = filePicker.showOpenDialog(window()); @@ -303,6 +327,9 @@ public class FXWorkController extends AbstractFXController implements WorkContro @FXML private void exportSoftPressed() { final var filePicker = new FileChooser(); + final var extensionFilter = new FileChooser.ExtensionFilter(ALL_SUPPORTED, VIDEO_EXTENSIONS); + filePicker.getExtensionFilters().add(extensionFilter); + filePicker.setSelectedExtensionFilter(extensionFilter); final var file = filePicker.showSaveDialog(window()); if (file != null) { final var baseCollection = model.subtitleCollection(); @@ -315,7 +342,7 @@ public class FXWorkController extends AbstractFXController implements WorkContro }, Platform::runLater) .thenAcceptAsync(collections -> { try { - videoConverter.addSoftSubtitles(model.video(), collections); + videoConverter.addSoftSubtitles(model.video(), collections, file.toPath()); } catch (final IOException e) { throw new CompletionException(e); } @@ -332,11 +359,14 @@ public class FXWorkController extends AbstractFXController implements WorkContro @FXML private void exportHardPressed() { final var filePicker = new FileChooser(); + final var extensionFilter = new FileChooser.ExtensionFilter(ALL_SUPPORTED, VIDEO_EXTENSIONS); + filePicker.getExtensionFilters().add(extensionFilter); + filePicker.setSelectedExtensionFilter(extensionFilter); final var file = filePicker.showSaveDialog(window()); if (file != null) { CompletableFuture.runAsync(() -> { try { - videoConverter.addHardSubtitles(model.video(), model.subtitleCollection()); + videoConverter.addHardSubtitles(model.video(), model.subtitleCollection(), file.toPath()); } catch (final IOException e) { throw new CompletionException(e); } @@ -386,7 +416,7 @@ public class FXWorkController extends AbstractFXController implements WorkContro @FXML private void loadSubtitlesPressed() { final var filePicker = new FileChooser(); - final var extensionFilter = new FileChooser.ExtensionFilter("All supported", subtitleExtensions); + final var extensionFilter = new FileChooser.ExtensionFilter(ALL_SUPPORTED, subtitleExtensions); filePicker.getExtensionFilters().add(extensionFilter); filePicker.setSelectedExtensionFilter(extensionFilter); final var file = filePicker.showOpenDialog(window()); @@ -396,7 +426,7 @@ public class FXWorkController extends AbstractFXController implements WorkContro @FXML private void saveSubtitlesPressed() { final var filePicker = new FileChooser(); - final var extensionFilter = new FileChooser.ExtensionFilter("All supported", subtitleExtensions); + final var extensionFilter = new FileChooser.ExtensionFilter(ALL_SUPPORTED, subtitleExtensions); filePicker.getExtensionFilters().add(extensionFilter); filePicker.setSelectedExtensionFilter(extensionFilter); final var file = filePicker.showSaveDialog(window()); diff --git a/gui/fx/src/main/java/com/github/gtache/autosubtitle/gui/fx/FXWorkModel.java b/gui/fx/src/main/java/com/github/gtache/autosubtitle/gui/fx/FXWorkModel.java index 840355c..f9adc79 100644 --- a/gui/fx/src/main/java/com/github/gtache/autosubtitle/gui/fx/FXWorkModel.java +++ b/gui/fx/src/main/java/com/github/gtache/autosubtitle/gui/fx/FXWorkModel.java @@ -75,12 +75,11 @@ public class FXWorkModel implements WorkModel { this.extractionModel = new SimpleObjectProperty<>(); this.progress = new SimpleDoubleProperty(-1); text.bind(Bindings.createStringBinding(() -> - subtitles.stream().map(EditableSubtitle::content).collect(Collectors.joining(" ")), + subtitles.stream().map(EditableSubtitle::content).collect(Collectors.joining("")), subtitles)); - subtitleCollection.bind(Bindings.createObjectBinding(() -> new SubtitleCollectionImpl(text(), subtitles, videoLanguage()))); - videoLanguage.addListener((observable, oldValue, newValue) -> { - FXCollections.observableArrayList(Arrays.stream(Language.values()).filter(l -> l != Language.AUTO && l != newValue).sorted(Comparator.naturalOrder()).toList()); - }); + subtitleCollection.bind(Bindings.createObjectBinding(() -> new SubtitleCollectionImpl(text(), subtitles, videoLanguage()), text, subtitles, videoLanguage)); + videoLanguage.addListener((observable, oldValue, newValue) -> FXCollections.observableArrayList(Arrays.stream(Language.values()) + .filter(l -> l != Language.AUTO && l != newValue).sorted(Comparator.naturalOrder()).toList())); } @Override diff --git a/gui/fx/src/main/java/com/github/gtache/autosubtitle/modules/gui/fx/FXModule.java b/gui/fx/src/main/java/com/github/gtache/autosubtitle/modules/gui/fx/FXModule.java index 5027b9c..7897f1c 100644 --- a/gui/fx/src/main/java/com/github/gtache/autosubtitle/modules/gui/fx/FXModule.java +++ b/gui/fx/src/main/java/com/github/gtache/autosubtitle/modules/gui/fx/FXModule.java @@ -54,14 +54,12 @@ public abstract class FXModule { } @Provides - @Singleton @Play static Image providesPlayImage(@Play final byte[] playImage) { return new Image(new ByteArrayInputStream(playImage)); } @Provides - @Singleton @Pause static Image providesPauseImage(@Pause final byte[] pauseImage) { return new Image(new ByteArrayInputStream(pauseImage)); diff --git a/gui/fx/src/main/java/module-info.java b/gui/fx/src/main/java/module-info.java index 8d5bf11..9706af9 100644 --- a/gui/fx/src/main/java/module-info.java +++ b/gui/fx/src/main/java/module-info.java @@ -1,7 +1,7 @@ /** * FX module for auto-subtitle */ -module com.github.gtache.autosubtitle.fx { +module com.github.gtache.autosubtitle.gui.fx { requires transitive com.github.gtache.autosubtitle.core; requires transitive com.github.gtache.autosubtitle.gui.core; requires transitive javafx.controls; diff --git a/gui/fx/src/main/resources/com/github/gtache/autosubtitle/gui/fx/workView.fxml b/gui/fx/src/main/resources/com/github/gtache/autosubtitle/gui/fx/workView.fxml index e6497a8..c018305 100644 --- a/gui/fx/src/main/resources/com/github/gtache/autosubtitle/gui/fx/workView.fxml +++ b/gui/fx/src/main/resources/com/github/gtache/autosubtitle/gui/fx/workView.fxml @@ -1,93 +1,93 @@ - - + + + + + + + + + + + - + + - - - + + + - - - - + + + + - - - - - - + + + - + -