Pipeline working, implements FFmpegSetupManager

This commit is contained in:
Guillaume Tâche
2024-08-09 20:30:21 +02:00
parent c2efb71195
commit 155b011c2b
54 changed files with 984 additions and 314 deletions

6
.idea/scala_compiler.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ScalaCompilerConfiguration">
<option name="separateProdTestSources" value="false" />
</component>
</project>

10
.idea/sonarlint.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SonarLintProjectSettings">
<option name="moduleMapping">
<map>
<entry key="autosubtitle-gui-fx" value="autosubtitle-fx" />
</map>
</option>
</component>
</project>

View File

@@ -3,13 +3,18 @@ package com.github.gtache.autosubtitle;
import com.github.gtache.autosubtitle.subtitle.SubtitleCollection; import com.github.gtache.autosubtitle.subtitle.SubtitleCollection;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Path;
import java.util.Collection; import java.util.Collection;
public interface VideoConverter { public interface VideoConverter {
Video addSoftSubtitles(final Video video, final Collection<SubtitleCollection> subtitles) throws IOException; Video addSoftSubtitles(final Video video, final Collection<SubtitleCollection> subtitles) throws IOException;
void addSoftSubtitles(final Video video, final Collection<SubtitleCollection> subtitles, final Path path) throws IOException;
Video addHardSubtitles(final Video video, final SubtitleCollection subtitles) 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; Audio getAudio(final Video video) throws IOException;
} }

View File

@@ -29,6 +29,16 @@ public interface Subtitle {
return Duration.ofMillis(end() - start()); 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 * @return the font of the subtitle
*/ */

View File

@@ -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.ffmpeg.FFmpegModule;
import com.github.gtache.autosubtitle.modules.impl.CoreModule; 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.extractor.whisper.WhisperExtractorModule;
import com.github.gtache.autosubtitle.modules.subtitle.impl.SubtitleModule;
import com.github.gtache.autosubtitle.subtitle.converter.SubtitleConverter; import com.github.gtache.autosubtitle.subtitle.converter.SubtitleConverter;
import dagger.Component; import dagger.Component;
import javax.inject.Singleton; import javax.inject.Singleton;
import java.util.Map; 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 @Singleton
public interface CliComponent { public interface CliComponent {

View File

@@ -11,7 +11,7 @@ public enum Architecture {
//64 bit //64 bit
X86, X86_64, AMD64, X86, X86_64, AMD64,
//ARM 32 bit //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 //ARM 64 bit
ARM64, ARMV8, ARMV9, AARCH64, ARM64, ARMV8, ARMV9, AARCH64,
UNKNOWN; UNKNOWN;

View File

@@ -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); throw new IllegalArgumentException("Duration must be greater than 0 : " + duration);
} }
} }
@Override
public String videoFormat() {
return videoFormat;
}
} }

View File

@@ -2,16 +2,22 @@ package com.github.gtache.autosubtitle.modules.impl;
import com.github.gtache.autosubtitle.impl.Architecture; import com.github.gtache.autosubtitle.impl.Architecture;
import com.github.gtache.autosubtitle.impl.OS; 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.Module;
import dagger.Provides; import dagger.Provides;
import javax.inject.Singleton; /**
* Dagger module for Core
*/
@Module(includes = {SetupModule.class, SubtitleModule.class})
public final class CoreModule {
@Module private CoreModule() {
public abstract class CoreModule {
}
@Provides @Provides
@Singleton
static OS providesOS() { static OS providesOS() {
final var name = System.getProperty("os.name"); final var name = System.getProperty("os.name");
if (name.contains("Windows")) { if (name.contains("Windows")) {
@@ -24,14 +30,12 @@ public abstract class CoreModule {
} }
@Provides @Provides
@Singleton
static Architecture providesArchitecture() { static Architecture providesArchitecture() {
final var arch = System.getProperty("os.arch"); final var arch = System.getProperty("os.arch");
return Architecture.getArchitecture(arch); return Architecture.getArchitecture(arch);
} }
@Provides @Provides
@Singleton
@ExecutableExtension @ExecutableExtension
static String providesExecutableExtension(final OS os) { static String providesExecutableExtension(final OS os) {
return os == OS.WINDOWS ? ".exe" : ""; return os == OS.WINDOWS ? ".exe" : "";

View File

@@ -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();
}
}

View File

@@ -11,10 +11,13 @@ import dagger.multibindings.StringKey;
* Dagger module for subtitles * Dagger module for subtitles
*/ */
@Module @Module
public interface SubtitleModule { public abstract class SubtitleModule {
private SubtitleModule() {
}
@Binds @Binds
@IntoMap @IntoMap
@StringKey("srt") @StringKey("srt")
SubtitleConverter bindsSubtitleConverter(final SRTSubtitleConverter converter); abstract SubtitleConverter bindsSubtitleConverter(final SRTSubtitleConverter converter);
} }

View File

@@ -36,6 +36,7 @@ public abstract class AbstractProcessRunner implements ProcessRunner {
@Override @Override
public Process start(final List<String> args) throws IOException { public Process start(final List<String> args) throws IOException {
logger.info("Running {}", args);
final var builder = new ProcessBuilder(args); final var builder = new ProcessBuilder(args);
builder.redirectErrorStream(true); builder.redirectErrorStream(true);
return builder.start(); return builder.start();
@@ -46,4 +47,22 @@ public abstract class AbstractProcessRunner implements ProcessRunner {
final var process = start(args); final var process = start(args);
return new ProcessListenerImpl(process); 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<String> 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));
}
} }

View File

@@ -3,7 +3,6 @@ package com.github.gtache.autosubtitle.process.impl;
import com.github.gtache.autosubtitle.process.ProcessListener; import com.github.gtache.autosubtitle.process.ProcessListener;
import com.github.gtache.autosubtitle.process.ProcessResult; import com.github.gtache.autosubtitle.process.ProcessResult;
import java.io.BufferedInputStream;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader; import java.io.InputStreamReader;
@@ -30,7 +29,7 @@ public class ProcessListenerImpl implements ProcessListener {
*/ */
public ProcessListenerImpl(final Process process) { public ProcessListenerImpl(final Process process) {
this.process = Objects.requireNonNull(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<>(); this.output = new ArrayList<>();
} }

View File

@@ -12,6 +12,10 @@ import org.apache.logging.log4j.Logger;
import java.io.IOException; import java.io.IOException;
import java.io.UncheckedIOException; 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.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Comparator; import java.util.Comparator;
@@ -19,6 +23,8 @@ import java.util.HashSet;
import java.util.Set; import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import static java.util.Objects.requireNonNull;
/** /**
* Base class for all {@link SetupManager} implementations * 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 static final Logger logger = LogManager.getLogger(AbstractSetupManager.class);
private final Set<SetupListener> listeners; private final Set<SetupListener> listeners;
private final HttpClient httpClient;
/** /**
* Instantiates the manager * Instantiates the manager with a default client
*/ */
protected AbstractSetupManager() { 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.listeners = new HashSet<>();
this.httpClient = requireNonNull(httpClient);
} }
@Override @Override
@@ -158,6 +175,30 @@ public abstract class AbstractSetupManager extends AbstractProcessRunner impleme
logger.info("{} deleted", path); 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 { private static long getFilesCount(final Path path) throws IOException {
try (final var stream = Files.walk(path)) { try (final var stream = Files.walk(path)) {
return stream.count(); return stream.count();

View File

@@ -35,11 +35,21 @@ public class SRTSubtitleConverter implements SubtitleConverter {
return IntStream.range(0, subtitles.size()).mapToObj(i -> { return IntStream.range(0, subtitles.size()).mapToObj(i -> {
final var subtitle = subtitles.get(i); final var subtitle = subtitles.get(i);
return (i + 1) + "\n" + return (i + 1) + "\n" +
subtitle.start() + " --> " + subtitle.end() + "\n" + formatTime(subtitle.start()) + " --> " + formatTime(subtitle.end()) + "\n" +
subtitle.content(); subtitle.content();
}).collect(Collectors.joining("\n\n")); }).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 @Override
public SubtitleCollection parse(final String content) throws ParseException { public SubtitleCollection parse(final String content) throws ParseException {
try { try {

View File

@@ -4,6 +4,7 @@
module com.github.gtache.autosubtitle.core { module com.github.gtache.autosubtitle.core {
requires transitive com.github.gtache.autosubtitle.api; requires transitive com.github.gtache.autosubtitle.api;
requires transitive dagger; requires transitive dagger;
requires transitive java.net.http;
requires transitive javax.inject; requires transitive javax.inject;
requires org.apache.logging.log4j; requires org.apache.logging.log4j;

View File

@@ -16,6 +16,14 @@
<groupId>com.github.gtache.autosubtitle</groupId> <groupId>com.github.gtache.autosubtitle</groupId>
<artifactId>autosubtitle-core</artifactId> <artifactId>autosubtitle-core</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
</dependency>
<dependency>
<groupId>org.tukaani</groupId>
<artifactId>xz</artifactId>
</dependency>
</dependencies> </dependencies>
</project> </project>

View File

@@ -49,19 +49,24 @@ public class FFmpegVideoConverter extends AbstractProcessRunner implements Video
@Override @Override
public Video addSoftSubtitles(final Video video, final Collection<SubtitleCollection> subtitles) throws IOException { public Video addSoftSubtitles(final Video video, final Collection<SubtitleCollection> 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<SubtitleCollection> subtitles, final Path path) throws IOException {
final var videoPath = getPath(video); final var videoPath = getPath(video);
final var collectionMap = dumpCollections(subtitles); final var collectionMap = dumpCollections(subtitles);
final var out = getTempFile("mkv"); //Soft subtitles are only supported by mkv apparently
final var args = new ArrayList<String>(); final var args = new ArrayList<String>();
args.add(getFFmpegPath()); args.add(getFFmpegPath());
args.add("-y");
args.add("-i"); args.add("-i");
args.add(videoPath.toString()); args.add(videoPath.toString());
collectionMap.forEach((c, p) -> { collectionMap.forEach((c, p) -> {
args.add("-i"); args.add("-i");
args.add(p.toString()); args.add(p.toString());
}); });
args.add("-c");
args.add("copy");
args.add("-map"); args.add("-map");
args.add("0:v"); args.add("0:v");
args.add("-map"); args.add("-map");
@@ -71,30 +76,56 @@ public class FFmpegVideoConverter extends AbstractProcessRunner implements Video
final var n = i.getAndIncrement(); final var n = i.getAndIncrement();
args.add("-map"); args.add("-map");
args.add(String.valueOf(n)); 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("-metadata:s:s:" + n);
args.add("language=" + c.language().iso3()); args.add("language=" + c.language().iso3());
}); });
args.add(out.toString()); args.add(path.toString());
run(args); runListen(args);
return new FileVideoImpl(out, new VideoInfoImpl("mkv", video.info().width(), video.info().height(), video.info().duration()));
} }
@Override @Override
public Video addHardSubtitles(final Video video, final SubtitleCollection subtitles) throws IOException { 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 videoPath = getPath(video);
final var subtitlesPath = dumpSubtitles(subtitles); final var subtitlesPath = dumpSubtitles(subtitles);
final var out = getTempFile(video.info().videoFormat()); final var escapedPath = escapeVF(subtitlesPath.toString());
final var subtitleArg = subtitleConverter.formatName().equalsIgnoreCase("ass") ? "ass=" + subtitlesPath : "subtitles=" + subtitlesPath; final var subtitleArg = subtitleConverter.formatName().equalsIgnoreCase("ass") ? "ass='" + escapedPath + "'" : "subtitles='" + escapedPath + "'";
final var args = List.of( final var args = List.of(
getFFmpegPath(), getFFmpegPath(),
"-i", "-i",
videoPath.toString(), videoPath.toString(),
"-vf", "-vf",
subtitleArg, subtitleArg,
out.toString() path.toString()
); );
run(args); runListen(args);
return new FileVideoImpl(out, video.info()); }
private static String escapeVF(final String path) {
return path.replace("\\", "\\\\").replace(":", "\\:").replace("'", "'\\''")
.replace("%", "\\%");
} }
@Override @Override
@@ -104,6 +135,7 @@ public class FFmpegVideoConverter extends AbstractProcessRunner implements Video
final var dumpVideoPath = getTempFile("." + video.info().videoFormat()); final var dumpVideoPath = getTempFile("." + video.info().videoFormat());
final var args = List.of( final var args = List.of(
getFFmpegPath(), getFFmpegPath(),
"-y",
"-i", "-i",
videoPath.toString(), videoPath.toString(),
"-map", "-map",
@@ -113,7 +145,7 @@ public class FFmpegVideoConverter extends AbstractProcessRunner implements Video
"0:v", "0:v",
dumpVideoPath.toString() dumpVideoPath.toString()
); );
run(args); runListen(args);
Files.deleteIfExists(dumpVideoPath); Files.deleteIfExists(dumpVideoPath);
return new FileAudioImpl(audioPath, new AudioInfoImpl("wav", video.info().duration())); 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 { private Path dumpSubtitles(final SubtitleCollection subtitles) throws IOException {
final var path = getTempFile("ass"); final var path = getTempFile("srt");
Files.writeString(path, subtitleConverter.format(subtitles)); Files.writeString(path, subtitleConverter.format(subtitles));
return path; return path;
} }

View File

@@ -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 {
}

View File

@@ -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 {
}

View File

@@ -4,15 +4,23 @@ import com.github.gtache.autosubtitle.VideoConverter;
import com.github.gtache.autosubtitle.VideoLoader; import com.github.gtache.autosubtitle.VideoLoader;
import com.github.gtache.autosubtitle.ffmpeg.FFmpegVideoConverter; import com.github.gtache.autosubtitle.ffmpeg.FFmpegVideoConverter;
import com.github.gtache.autosubtitle.ffmpeg.FFprobeVideoLoader; import com.github.gtache.autosubtitle.ffmpeg.FFprobeVideoLoader;
import com.github.gtache.autosubtitle.modules.setup.ffmpeg.FFmpegSetupModule;
import dagger.Binds; import dagger.Binds;
import dagger.Module; import dagger.Module;
@Module /**
public interface FFmpegModule { * Dagger module for FFmpeg
*/
@Module(includes = FFmpegSetupModule.class)
public abstract class FFmpegModule {
private FFmpegModule() {
}
@Binds @Binds
VideoConverter bindsVideoConverter(final FFmpegVideoConverter converter); abstract VideoConverter bindsVideoConverter(final FFmpegVideoConverter converter);
@Binds @Binds
VideoLoader bindsVideoLoader(final FFprobeVideoLoader loader); abstract VideoLoader bindsVideoLoader(final FFprobeVideoLoader loader);
} }

View File

@@ -1,7 +1,11 @@
package com.github.gtache.autosubtitle.modules.setup.ffmpeg; 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.FFBundledRoot;
import com.github.gtache.autosubtitle.modules.ffmpeg.FFProbeInstallerPath;
import com.github.gtache.autosubtitle.modules.ffmpeg.FFmpegBundledPath; 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.FFmpegSystemPath;
import com.github.gtache.autosubtitle.modules.ffmpeg.FFmpegVersion; import com.github.gtache.autosubtitle.modules.ffmpeg.FFmpegVersion;
import com.github.gtache.autosubtitle.modules.ffmpeg.FFprobeBundledPath; 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.impl.ExecutableExtension;
import com.github.gtache.autosubtitle.modules.setup.impl.VideoConverterSetup; import com.github.gtache.autosubtitle.modules.setup.impl.VideoConverterSetup;
import com.github.gtache.autosubtitle.setup.SetupManager; 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.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.Binds;
import dagger.Module; import dagger.Module;
import dagger.Provides; import dagger.Provides;
import dagger.multibindings.IntoMap;
import dagger.multibindings.StringKey;
import javax.inject.Singleton;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
@@ -27,47 +37,87 @@ public abstract class FFmpegSetupModule {
private static final String FFMPEG = "ffmpeg"; private static final String FFMPEG = "ffmpeg";
private static final String FFPROBE = "ffprobe"; 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 @Binds
@VideoConverterSetup @VideoConverterSetup
abstract SetupManager bindsFFmpegSetupManager(final FFmpegSetupManager manager); abstract SetupManager bindsFFmpegSetupManager(final FFmpegSetupManager manager);
@Provides @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 @FFBundledRoot
static Path providesFFBundledRoot() { static Path providesFFBundledRoot() {
return Paths.get("tools", FFMPEG); return Paths.get("tools", FFMPEG);
} }
@Provides @Provides
@Singleton
@FFprobeBundledPath @FFprobeBundledPath
static Path providesFFProbeBundledPath(@FFBundledRoot final Path root, @ExecutableExtension final String extension) { static Path providesFFProbeBundledPath(@FFBundledRoot final Path root, @ExecutableExtension final String extension) {
return root.resolve(FFPROBE + extension); return root.resolve(FFPROBE + extension);
} }
@Provides @Provides
@Singleton
@FFprobeSystemPath @FFprobeSystemPath
static Path providesFFProbeSystemPath(@ExecutableExtension final String extension) { static Path providesFFProbeSystemPath(@ExecutableExtension final String extension) {
return Paths.get(FFPROBE + extension); return Paths.get(FFPROBE + extension);
} }
@Provides @Provides
@Singleton
@FFmpegBundledPath @FFmpegBundledPath
static Path providesFFmpegBundledPath(@FFBundledRoot final Path root, @ExecutableExtension final String extension) { static Path providesFFmpegBundledPath(@FFBundledRoot final Path root, @ExecutableExtension final String extension) {
return root.resolve(FFMPEG + extension); return root.resolve(FFMPEG + extension);
} }
@Provides @Provides
@Singleton
@FFmpegSystemPath @FFmpegSystemPath
static Path providesFFmpegSystemPath(@ExecutableExtension final String extension) { static Path providesFFmpegSystemPath(@ExecutableExtension final String extension) {
return Paths.get(FFMPEG + extension); return Paths.get(FFMPEG + extension);
} }
@Provides @Provides
@Singleton
@FFmpegVersion @FFmpegVersion
static String providesFFmpegVersion() { static String providesFFmpegVersion() {
return "7.0.1"; return "7.0.1";

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -1,11 +1,8 @@
package com.github.gtache.autosubtitle.setup.ffmpeg; package com.github.gtache.autosubtitle.setup.ffmpeg;
import com.github.gtache.autosubtitle.impl.Architecture; 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.SetupException;
import com.github.gtache.autosubtitle.setup.SetupManager;
import com.github.gtache.autosubtitle.setup.SetupStatus; import com.github.gtache.autosubtitle.setup.SetupStatus;
import com.github.gtache.autosubtitle.setup.impl.AbstractSetupManager; import com.github.gtache.autosubtitle.setup.impl.AbstractSetupManager;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
@@ -13,30 +10,30 @@ import org.apache.logging.log4j.Logger;
import javax.inject.Inject; import javax.inject.Inject;
import java.io.IOException; import java.io.IOException;
import java.net.http.HttpClient;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; 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 { public class FFmpegSetupManager extends AbstractSetupManager {
private static final Logger logger = LogManager.getLogger(FFmpegSetupManager.class); private static final Logger logger = LogManager.getLogger(FFmpegSetupManager.class);
private final Path bundledPath; private final FFmpegSetupConfiguration configuration;
private final Path systemPath; private final Map<String, Decompresser> decompressers;
private final String version;
private final OS os;
private final Architecture architecture;
private final String executableExtension;
@Inject @Inject
FFmpegSetupManager(@FFmpegBundledPath final Path bundledPath, @FFmpegSystemPath final Path systemPath, @FFmpegVersion final String version, final OS os, final Architecture architecture) { FFmpegSetupManager(final FFmpegSetupConfiguration configuration, final Map<String, Decompresser> decompressers,
this.bundledPath = Objects.requireNonNull(bundledPath); final HttpClient httpClient) {
this.systemPath = Objects.requireNonNull(systemPath); super(httpClient);
this.version = Objects.requireNonNull(version); this.configuration = requireNonNull(configuration);
this.os = Objects.requireNonNull(os); this.decompressers = Map.copyOf(decompressers);
this.architecture = Objects.requireNonNull(architecture);
this.executableExtension = os == OS.WINDOWS ? ".exe" : "";
} }
@Override @Override
@@ -61,12 +58,128 @@ public class FFmpegSetupManager extends AbstractSetupManager {
@Override @Override
public void install() throws SetupException { 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 @Override
public void uninstall() throws SetupException { public void uninstall() throws SetupException {
deleteFolder(configuration.root());
} }
@Override @Override
@@ -75,11 +188,11 @@ public class FFmpegSetupManager extends AbstractSetupManager {
} }
private boolean checkSystemFFmpeg() throws IOException { private boolean checkSystemFFmpeg() throws IOException {
final var result = run(systemPath.toString(), "-version"); final var result = run(configuration.systemFFmpegPath().toString(), "-version");
return result.exitCode() == 0; return result.exitCode() == 0;
} }
private boolean checkBundledFFmpeg() throws IOException { private boolean checkBundledFFmpeg() throws IOException {
return Files.isRegularFile(bundledPath); return Files.isRegularFile(configuration.bundledFFmpegPath());
} }
} }

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -5,11 +5,14 @@ module com.github.gtache.autosubtitle.ffmpeg {
requires transitive com.github.gtache.autosubtitle.core; requires transitive com.github.gtache.autosubtitle.core;
requires transitive dagger; requires transitive dagger;
requires transitive javax.inject; requires transitive javax.inject;
requires java.net.http;
requires org.apache.logging.log4j; 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.ffmpeg;
exports com.github.gtache.autosubtitle.setup.ffmpeg; exports com.github.gtache.autosubtitle.setup.ffmpeg;
exports com.github.gtache.autosubtitle.modules.ffmpeg; exports com.github.gtache.autosubtitle.modules.ffmpeg;
exports com.github.gtache.autosubtitle.modules.setup.ffmpeg; exports com.github.gtache.autosubtitle.modules.setup.ffmpeg;
} }

View File

@@ -4,7 +4,6 @@ import com.github.gtache.autosubtitle.gui.impl.CombinedResourceBundle;
import dagger.Module; import dagger.Module;
import dagger.Provides; import dagger.Provides;
import javax.inject.Singleton;
import java.io.IOException; import java.io.IOException;
import java.io.UncheckedIOException; import java.io.UncheckedIOException;
import java.util.ResourceBundle; import java.util.ResourceBundle;
@@ -13,9 +12,12 @@ import java.util.ResourceBundle;
* Dagger module for GUI * Dagger module for GUI
*/ */
@Module @Module
public class GuiCoreModule { public final class GuiCoreModule {
private GuiCoreModule() {
}
@Provides @Provides
@Singleton
static ResourceBundle providesBundle() { static ResourceBundle providesBundle() {
return new CombinedResourceBundle(ResourceBundle.getBundle("com.github.gtache.autosubtitle.gui.impl.MainBundle"), return new CombinedResourceBundle(ResourceBundle.getBundle("com.github.gtache.autosubtitle.gui.impl.MainBundle"),
ResourceBundle.getBundle("com.github.gtache.autosubtitle.gui.impl.SetupBundle"), ResourceBundle.getBundle("com.github.gtache.autosubtitle.gui.impl.SetupBundle"),
@@ -25,7 +27,6 @@ public class GuiCoreModule {
} }
@Provides @Provides
@Singleton
@Play @Play
static byte[] providesPlayImage() { static byte[] providesPlayImage() {
try (final var in = GuiCoreModule.class.getResourceAsStream("/com/github/gtache/autosubtitle/gui/impl/play_64.png")) { try (final var in = GuiCoreModule.class.getResourceAsStream("/com/github/gtache/autosubtitle/gui/impl/play_64.png")) {
@@ -36,7 +37,6 @@ public class GuiCoreModule {
} }
@Provides @Provides
@Singleton
@Pause @Pause
static byte[] providesPauseImage() { static byte[] providesPauseImage() {
try (final var in = GuiCoreModule.class.getResourceAsStream("/com/github/gtache/autosubtitle/gui/impl/pause_64.png")) { try (final var in = GuiCoreModule.class.getResourceAsStream("/com/github/gtache/autosubtitle/gui/impl/pause_64.png")) {
@@ -47,14 +47,12 @@ public class GuiCoreModule {
} }
@Provides @Provides
@Singleton
@FontFamily @FontFamily
static String providesFontFamily() { static String providesFontFamily() {
return "Arial"; return "Arial";
} }
@Provides @Provides
@Singleton
@FontSize @FontSize
static int providesFontSize() { static int providesFontSize() {
return 12; return 12;

View File

@@ -9,7 +9,7 @@
<version>1.0-SNAPSHOT</version> <version>1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>autosubtitle-fx</artifactId> <artifactId>autosubtitle-gui-fx</artifactId>
<properties> <properties>
<controlsfx.version>11.2.1</controlsfx.version> <controlsfx.version>11.2.1</controlsfx.version>

View File

@@ -1,14 +1,16 @@
package com.github.gtache.autosubtitle.gui.fx; 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.MediaController;
import com.github.gtache.autosubtitle.gui.TimeFormatter; 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.Pause;
import com.github.gtache.autosubtitle.modules.gui.impl.Play; 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.Subtitle;
import com.github.gtache.autosubtitle.subtitle.gui.fx.SubtitleLabel; import com.github.gtache.autosubtitle.subtitle.gui.fx.SubtitleLabel;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.binding.Bindings; import javafx.beans.binding.Bindings;
import javafx.collections.ListChangeListener;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.geometry.Insets; import javafx.geometry.Insets;
import javafx.scene.Cursor; import javafx.scene.Cursor;
@@ -31,6 +33,11 @@ import org.apache.logging.log4j.Logger;
import javax.inject.Inject; import javax.inject.Inject;
import javax.inject.Singleton; 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; import static java.util.Objects.requireNonNull;
@@ -62,6 +69,8 @@ public class FXMediaController implements MediaController {
private final Image playImage; private final Image playImage;
private final Image pauseImage; private final Image pauseImage;
private final List<Long> startTimes;
private boolean wasPlaying; private boolean wasPlaying;
@Inject @Inject
@@ -72,6 +81,7 @@ public class FXMediaController implements MediaController {
this.timeFormatter = requireNonNull(timeFormatter); this.timeFormatter = requireNonNull(timeFormatter);
this.playImage = requireNonNull(playImage); this.playImage = requireNonNull(playImage);
this.pauseImage = requireNonNull(pauseImage); this.pauseImage = requireNonNull(pauseImage);
this.startTimes = new ArrayList<>();
} }
@FXML @FXML
@@ -101,50 +111,78 @@ public class FXMediaController implements MediaController {
if (videoView.getMediaPlayer() != null) { if (videoView.getMediaPlayer() != null) {
videoView.getMediaPlayer().dispose(); videoView.getMediaPlayer().dispose();
} }
if (newValue instanceof final FileVideoImpl fileVideo) { if (newValue instanceof final File file) {
final var media = new Media(fileVideo.path().toUri().toString()); loadFileVideo(file.path());
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);
} else { } else {
logger.error("Unsupported video type : {}", newValue); logger.error("Unsupported video type : {}", newValue);
} }
}); });
model.subtitles().addListener((ListChangeListener<EditableSubtitle>) 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.disableProperty().bind(model.videoProperty().isNull());
playButton.graphicProperty().bind(Bindings.createObjectBinding(() -> { playButton.graphicProperty().bind(Bindings.createObjectBinding(() -> {
final ImageView view; final ImageView view;
@@ -158,7 +196,6 @@ public class FXMediaController implements MediaController {
view.setFitHeight(24); view.setFitHeight(24);
return view; return view;
}, model.isPlayingProperty())); }, model.isPlayingProperty()));
binder.createBindings();
} }
@FXML @FXML

View File

@@ -6,10 +6,10 @@ import com.github.gtache.autosubtitle.subtitle.EditableSubtitle;
import javafx.beans.binding.Bindings; import javafx.beans.binding.Bindings;
import javafx.beans.property.*; import javafx.beans.property.*;
import javafx.collections.FXCollections; import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javax.inject.Inject; import javax.inject.Inject;
import javax.inject.Singleton; import javax.inject.Singleton;
import java.util.List;
/** /**
* FX implementation of {@link com.github.gtache.autosubtitle.gui.MediaModel} * 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 BooleanProperty isPlaying;
private final ReadOnlyLongWrapper duration; private final ReadOnlyLongWrapper duration;
private final LongProperty position; private final LongProperty position;
private final List<EditableSubtitle> subtitles; private final ObservableList<EditableSubtitle> subtitles;
@Inject @Inject
FXMediaModel() { FXMediaModel() {
@@ -103,7 +103,7 @@ public class FXMediaModel implements MediaModel {
} }
@Override @Override
public List<EditableSubtitle> subtitles() { public ObservableList<EditableSubtitle> subtitles() {
return subtitles; return subtitles;
} }

View File

@@ -59,6 +59,7 @@ public class FXWorkController extends AbstractFXController implements WorkContro
private static final Logger logger = LogManager.getLogger(FXWorkController.class); private static final Logger logger = LogManager.getLogger(FXWorkController.class);
private static final String ALL_SUPPORTED = "All supported";
private static final List<String> VIDEO_EXTENSIONS = Stream.of("webm", "mkv", "flv", "vob", "ogv", "ogg", private static final List<String> VIDEO_EXTENSIONS = Stream.of("webm", "mkv", "flv", "vob", "ogv", "ogg",
"drc", "gif", "gifv", "mng", "avi", "mts", "m2ts", "ts", "mov", "qt", "wmv", "yuv", "rm", "rmvb", "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", "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 @FXML
private void initialize() { 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.valueProperty().bindBidirectional(model.videoLanguageProperty());
languageCombobox.setItems(model.availableVideoLanguages()); languageCombobox.setItems(model.availableVideoLanguages());
languageCombobox.setConverter(new LanguageStringConverter()); languageCombobox.setConverter(new LanguageStringConverter());
translationsCombobox.setConverter(new LanguageStringConverter()); translationsCombobox.setConverter(new LanguageStringConverter());
Bindings.bindContent(translationsCombobox.getItems(), model.availableTranslationsLanguage()); Bindings.bindContent(translationsCombobox.getItems(), model.availableTranslationsLanguage());
Bindings.bindContent(model.translations(), translationsCombobox.getCheckModel().getCheckedItems()); Bindings.bindContent(model.translations(), translationsCombobox.getCheckModel().getCheckedItems());
}
private void bindButtons() {
extractButton.disableProperty().bind(model.videoProperty().isNull().or(model.statusProperty().isNotEqualTo(WorkStatus.IDLE))); 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))); 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))); 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))); 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))); 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))); saveSubtitlesButton.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))); }
private void bindTable() {
subtitlesTable.setItems(model.subtitles()); subtitlesTable.setItems(model.subtitles());
subtitlesTable.setOnKeyPressed(e -> { subtitlesTable.setOnKeyPressed(e -> {
if (e.getCode().isLetterKey() || e.getCode().isDigitKey()) { if (e.getCode().isLetterKey() || e.getCode().isDigitKey()) {
@@ -157,6 +179,9 @@ public class FXWorkController extends AbstractFXController implements WorkContro
} else if (e.getCode() == KeyCode.LEFT) { } else if (e.getCode() == KeyCode.LEFT) {
subtitlesTable.getSelectionModel().selectPrevious(); subtitlesTable.getSelectionModel().selectPrevious();
e.consume(); e.consume();
} else if (e.getCode() == KeyCode.DELETE) {
deleteSelectedSubtitles();
e.consume();
} }
}); });
startColumn.setCellFactory(TextFieldTableCell.forTableColumn(new TimeStringConverter(timeFormatter))); 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)); 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.textProperty().bind(Bindings.createStringBinding(() -> resources.getString("work.status." + model.status().name().toLowerCase() + ".label"), model.statusProperty()));
progressLabel.visibleProperty().bind(model.statusProperty().isNotEqualTo(WorkStatus.IDLE)); progressLabel.visibleProperty().bind(model.statusProperty().isNotEqualTo(WorkStatus.IDLE));
progressBar.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)); progressBar.managedProperty().bind(model.statusProperty().isNotEqualTo(WorkStatus.IDLE));
progressDetailLabel.managedProperty().bind(model.statusProperty().isNotEqualTo(WorkStatus.IDLE)); progressDetailLabel.managedProperty().bind(model.statusProperty().isNotEqualTo(WorkStatus.IDLE));
progressBar.progressProperty().bindBidirectional(model.progressProperty()); progressBar.progressProperty().bindBidirectional(model.progressProperty());
binder.createBindings(); }
subtitleExtractor.addListener(this); private void deleteSelectedSubtitles() {
model.subtitles().removeAll(subtitlesTable.getSelectionModel().getSelectedItems());
} }
private void editFocusedCell() { private void editFocusedCell() {
@@ -213,7 +237,7 @@ public class FXWorkController extends AbstractFXController implements WorkContro
@FXML @FXML
private void fileButtonPressed() { private void fileButtonPressed() {
final var filePicker = new FileChooser(); 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.getExtensionFilters().add(extensionFilter);
filePicker.setSelectedExtensionFilter(extensionFilter); filePicker.setSelectedExtensionFilter(extensionFilter);
final var file = filePicker.showOpenDialog(window()); final var file = filePicker.showOpenDialog(window());
@@ -303,6 +327,9 @@ public class FXWorkController extends AbstractFXController implements WorkContro
@FXML @FXML
private void exportSoftPressed() { private void exportSoftPressed() {
final var filePicker = new FileChooser(); 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()); final var file = filePicker.showSaveDialog(window());
if (file != null) { if (file != null) {
final var baseCollection = model.subtitleCollection(); final var baseCollection = model.subtitleCollection();
@@ -315,7 +342,7 @@ public class FXWorkController extends AbstractFXController implements WorkContro
}, Platform::runLater) }, Platform::runLater)
.thenAcceptAsync(collections -> { .thenAcceptAsync(collections -> {
try { try {
videoConverter.addSoftSubtitles(model.video(), collections); videoConverter.addSoftSubtitles(model.video(), collections, file.toPath());
} catch (final IOException e) { } catch (final IOException e) {
throw new CompletionException(e); throw new CompletionException(e);
} }
@@ -332,11 +359,14 @@ public class FXWorkController extends AbstractFXController implements WorkContro
@FXML @FXML
private void exportHardPressed() { private void exportHardPressed() {
final var filePicker = new FileChooser(); 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()); final var file = filePicker.showSaveDialog(window());
if (file != null) { if (file != null) {
CompletableFuture.runAsync(() -> { CompletableFuture.runAsync(() -> {
try { try {
videoConverter.addHardSubtitles(model.video(), model.subtitleCollection()); videoConverter.addHardSubtitles(model.video(), model.subtitleCollection(), file.toPath());
} catch (final IOException e) { } catch (final IOException e) {
throw new CompletionException(e); throw new CompletionException(e);
} }
@@ -386,7 +416,7 @@ public class FXWorkController extends AbstractFXController implements WorkContro
@FXML @FXML
private void loadSubtitlesPressed() { private void loadSubtitlesPressed() {
final var filePicker = new FileChooser(); 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.getExtensionFilters().add(extensionFilter);
filePicker.setSelectedExtensionFilter(extensionFilter); filePicker.setSelectedExtensionFilter(extensionFilter);
final var file = filePicker.showOpenDialog(window()); final var file = filePicker.showOpenDialog(window());
@@ -396,7 +426,7 @@ public class FXWorkController extends AbstractFXController implements WorkContro
@FXML @FXML
private void saveSubtitlesPressed() { private void saveSubtitlesPressed() {
final var filePicker = new FileChooser(); 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.getExtensionFilters().add(extensionFilter);
filePicker.setSelectedExtensionFilter(extensionFilter); filePicker.setSelectedExtensionFilter(extensionFilter);
final var file = filePicker.showSaveDialog(window()); final var file = filePicker.showSaveDialog(window());

View File

@@ -75,12 +75,11 @@ public class FXWorkModel implements WorkModel {
this.extractionModel = new SimpleObjectProperty<>(); this.extractionModel = new SimpleObjectProperty<>();
this.progress = new SimpleDoubleProperty(-1); this.progress = new SimpleDoubleProperty(-1);
text.bind(Bindings.createStringBinding(() -> text.bind(Bindings.createStringBinding(() ->
subtitles.stream().map(EditableSubtitle::content).collect(Collectors.joining(" ")), subtitles.stream().map(EditableSubtitle::content).collect(Collectors.joining("")),
subtitles)); subtitles));
subtitleCollection.bind(Bindings.createObjectBinding(() -> new SubtitleCollectionImpl(text(), subtitles, videoLanguage()))); subtitleCollection.bind(Bindings.createObjectBinding(() -> new SubtitleCollectionImpl(text(), subtitles, videoLanguage()), text, subtitles, videoLanguage));
videoLanguage.addListener((observable, oldValue, newValue) -> { videoLanguage.addListener((observable, oldValue, newValue) -> FXCollections.observableArrayList(Arrays.stream(Language.values())
FXCollections.observableArrayList(Arrays.stream(Language.values()).filter(l -> l != Language.AUTO && l != newValue).sorted(Comparator.naturalOrder()).toList()); .filter(l -> l != Language.AUTO && l != newValue).sorted(Comparator.naturalOrder()).toList()));
});
} }
@Override @Override

View File

@@ -54,14 +54,12 @@ public abstract class FXModule {
} }
@Provides @Provides
@Singleton
@Play @Play
static Image providesPlayImage(@Play final byte[] playImage) { static Image providesPlayImage(@Play final byte[] playImage) {
return new Image(new ByteArrayInputStream(playImage)); return new Image(new ByteArrayInputStream(playImage));
} }
@Provides @Provides
@Singleton
@Pause @Pause
static Image providesPauseImage(@Pause final byte[] pauseImage) { static Image providesPauseImage(@Pause final byte[] pauseImage) {
return new Image(new ByteArrayInputStream(pauseImage)); return new Image(new ByteArrayInputStream(pauseImage));

View File

@@ -1,7 +1,7 @@
/** /**
* FX module for auto-subtitle * 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.core;
requires transitive com.github.gtache.autosubtitle.gui.core; requires transitive com.github.gtache.autosubtitle.gui.core;
requires transitive javafx.controls; requires transitive javafx.controls;

View File

@@ -1,93 +1,93 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<?import javafx.geometry.Insets?> <?import javafx.geometry.Insets?>
<?import javafx.scene.control.*?> <?import javafx.scene.control.Button?>
<?import javafx.scene.layout.*?> <?import javafx.scene.control.Label?>
<?import javafx.scene.control.ProgressBar?>
<?import javafx.scene.control.TableColumn?>
<?import javafx.scene.control.TableView?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.control.Tooltip?>
<?import javafx.scene.layout.ColumnConstraints?>
<?import javafx.scene.layout.GridPane?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.RowConstraints?>
<?import org.controlsfx.control.CheckComboBox?> <?import org.controlsfx.control.CheckComboBox?>
<?import org.controlsfx.control.PrefixSelectionComboBox?> <?import org.controlsfx.control.PrefixSelectionComboBox?>
<GridPane hgap="10.0" vgap="10.0" xmlns="http://javafx.com/javafx/22" xmlns:fx="http://javafx.com/fxml/1"
fx:controller="com.github.gtache.autosubtitle.gui.fx.FXWorkController"> <GridPane hgap="10.0" vgap="10.0" xmlns="http://javafx.com/javafx/22" xmlns:fx="http://javafx.com/fxml/1" fx:controller="com.github.gtache.autosubtitle.gui.fx.FXWorkController">
<columnConstraints> <columnConstraints>
<ColumnConstraints hgrow="SOMETIMES"/> <ColumnConstraints hgrow="SOMETIMES" />
<ColumnConstraints hgrow="ALWAYS"/> <ColumnConstraints hgrow="ALWAYS" />
<ColumnConstraints hgrow="SOMETIMES"/> <ColumnConstraints hgrow="SOMETIMES" />
</columnConstraints> </columnConstraints>
<rowConstraints> <rowConstraints>
<RowConstraints vgrow="SOMETIMES"/> <RowConstraints vgrow="SOMETIMES" />
<RowConstraints vgrow="ALWAYS"/> <RowConstraints vgrow="ALWAYS" />
<RowConstraints vgrow="SOMETIMES"/> <RowConstraints vgrow="SOMETIMES" />
<RowConstraints vgrow="NEVER"/> <RowConstraints vgrow="NEVER" />
</rowConstraints> </rowConstraints>
<children> <children>
<TextField fx:id="fileField" editable="false" GridPane.columnIndex="1"/> <TextField fx:id="fileField" editable="false" GridPane.columnIndex="1" />
<Button mnemonicParsing="false" onAction="#fileButtonPressed" text="%work.button.file.label" <Button mnemonicParsing="false" onAction="#fileButtonPressed" text="%work.button.file.label" GridPane.columnIndex="2" />
GridPane.columnIndex="2"/>
<HBox spacing="10.0" GridPane.columnIndex="1" GridPane.rowIndex="2"> <HBox spacing="10.0" GridPane.columnIndex="1" GridPane.rowIndex="2">
<children> <children>
<Button fx:id="loadSubtitlesButton" mnemonicParsing="false" onAction="#loadSubtitlesPressed" <Button fx:id="extractButton" mnemonicParsing="false" onAction="#extractPressed" text="%work.button.extract.label" />
text="%work.button.load.label"/>
<Button fx:id="extractButton" mnemonicParsing="false" onAction="#extractPressed"
text="%work.button.extract.label"/>
</children> </children>
</HBox> </HBox>
<HBox alignment="CENTER_RIGHT" spacing="10.0" GridPane.columnIndex="2" GridPane.rowIndex="2"> <HBox alignment="CENTER_RIGHT" spacing="10.0" GridPane.columnIndex="2" GridPane.rowIndex="2">
<children> <children>
<Button fx:id="exportSoftButton" mnemonicParsing="false" onAction="#exportSoftPressed" <Button fx:id="exportSoftButton" mnemonicParsing="false" onAction="#exportSoftPressed" text="%work.button.export.soft.label">
text="%work.button.export.soft.label">
<tooltip> <tooltip>
<Tooltip text="%work.button.export.soft.tooltip"/> <Tooltip text="%work.button.export.soft.tooltip" />
</tooltip> </tooltip>
</Button> </Button>
<Button fx:id="exportHardButton" mnemonicParsing="false" onAction="#exportHardPressed" <Button fx:id="exportHardButton" mnemonicParsing="false" onAction="#exportHardPressed" text="%work.button.export.hard.label">
text="%work.button.export.hard.label">
<tooltip> <tooltip>
<Tooltip text="%work.button.export.hard.tooltip"/> <Tooltip text="%work.button.export.hard.tooltip" />
</tooltip> </tooltip>
</Button> </Button>
</children> </children>
</HBox> </HBox>
<TableView fx:id="subtitlesTable" editable="true" GridPane.rowIndex="1"> <TableView fx:id="subtitlesTable" editable="true" GridPane.rowIndex="1">
<columns> <columns>
<TableColumn fx:id="startColumn" prefWidth="50.0" sortable="false" <TableColumn fx:id="startColumn" prefWidth="50.0" sortable="false" text="%work.table.column.from.label" />
text="%work.table.column.from.label"/> <TableColumn fx:id="endColumn" prefWidth="50.0" sortable="false" text="%work.table.column.to.label" />
<TableColumn fx:id="endColumn" prefWidth="50.0" sortable="false" text="%work.table.column.to.label"/> <TableColumn fx:id="textColumn" prefWidth="75.0" sortable="false" text="%work.table.column.text.label" />
<TableColumn fx:id="textColumn" prefWidth="75.0" sortable="false" text="%work.table.column.text.label"/>
</columns> </columns>
<columnResizePolicy> <columnResizePolicy>
<TableView fx:constant="CONSTRAINED_RESIZE_POLICY"/> <TableView fx:constant="CONSTRAINED_RESIZE_POLICY" />
</columnResizePolicy> </columnResizePolicy>
</TableView> </TableView>
<HBox spacing="10.0" GridPane.rowIndex="2"> <HBox spacing="10.0" GridPane.rowIndex="2">
<children> <children>
<Button fx:id="resetButton" mnemonicParsing="false" onAction="#resetButtonPressed" <Button fx:id="loadSubtitlesButton" mnemonicParsing="false" onAction="#loadSubtitlesPressed" text="%work.button.load.label" />
text="%work.button.reset.label"/> <Button fx:id="saveSubtitlesButton" mnemonicParsing="false" onAction="#saveSubtitlesPressed" text="%work.button.subtitles.save.label" />
<Button fx:id="saveSubtitlesButton" mnemonicParsing="false" onAction="#saveSubtitlesPressed" <Button fx:id="resetButton" mnemonicParsing="false" onAction="#resetButtonPressed" text="%work.button.reset.label" />
text="%work.button.subtitles.save.label"/> <Button fx:id="addSubtitleButton" mnemonicParsing="false" onAction="#addSubtitlePressed" text="+" />
<Button fx:id="addSubtitleButton" mnemonicParsing="false" onAction="#addSubtitlePressed" text="+"/>
</children> </children>
<GridPane.margin> <GridPane.margin>
<Insets/> <Insets />
</GridPane.margin> </GridPane.margin>
</HBox> </HBox>
<fx:include fx:id="media" source="mediaView.fxml" GridPane.columnIndex="1" GridPane.columnSpan="2147483647" <fx:include fx:id="media" source="mediaView.fxml" GridPane.columnIndex="1" GridPane.columnSpan="2147483647" GridPane.rowIndex="1" />
GridPane.rowIndex="1"/>
<HBox alignment="CENTER_LEFT" spacing="10.0"> <HBox alignment="CENTER_LEFT" spacing="10.0">
<children> <children>
<Label text="%work.language.label"/> <Label text="%work.language.label" />
<PrefixSelectionComboBox fx:id="languageCombobox"/> <PrefixSelectionComboBox fx:id="languageCombobox" />
<Label text="%work.translate.label"/> <Label text="%work.translate.label" />
<CheckComboBox fx:id="translationsCombobox"/> <CheckComboBox fx:id="translationsCombobox" />
</children> </children>
</HBox> </HBox>
<Label fx:id="progressLabel" GridPane.columnIndex="1" GridPane.rowIndex="3"/> <Label fx:id="progressLabel" GridPane.columnIndex="1" GridPane.rowIndex="3" />
<HBox spacing="10.0" GridPane.columnIndex="2" GridPane.rowIndex="3"> <HBox spacing="10.0" GridPane.columnIndex="2" GridPane.rowIndex="3">
<children> <children>
<Label fx:id="progressDetailLabel"/> <Label fx:id="progressDetailLabel" />
<ProgressBar fx:id="progressBar" maxWidth="1.7976931348623157E308" HBox.hgrow="ALWAYS"/> <ProgressBar fx:id="progressBar" maxWidth="1.7976931348623157E308" HBox.hgrow="ALWAYS" />
</children> </children>
</HBox> </HBox>
</children> </children>
<padding> <padding>
<Insets bottom="10.0" left="10.0" right="10.0" top="10.0"/> <Insets bottom="10.0" left="10.0" right="10.0" top="10.0" />
</padding> </padding>
</GridPane> </GridPane>

View File

@@ -32,7 +32,7 @@
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.github.gtache.autosubtitle</groupId> <groupId>com.github.gtache.autosubtitle</groupId>
<artifactId>autosubtitle-fx</artifactId> <artifactId>autosubtitle-gui-fx</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
</dependencies> </dependencies>

View File

@@ -18,7 +18,7 @@
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.github.gtache.autosubtitle</groupId> <groupId>com.github.gtache.autosubtitle</groupId>
<artifactId>autosubtitle-fx</artifactId> <artifactId>autosubtitle-gui-fx</artifactId>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.github.gtache.autosubtitle</groupId> <groupId>com.github.gtache.autosubtitle</groupId>

View File

@@ -17,6 +17,10 @@ import javax.inject.Singleton;
@Module @Module
public abstract class MissingComponentsModule { public abstract class MissingComponentsModule {
private MissingComponentsModule() {
}
@Provides @Provides
@Singleton @Singleton
static Translator providesTranslator() { static Translator providesTranslator() {

View File

@@ -4,9 +4,6 @@ import com.github.gtache.autosubtitle.modules.ffmpeg.FFmpegModule;
import com.github.gtache.autosubtitle.modules.gui.fx.FXModule; import com.github.gtache.autosubtitle.modules.gui.fx.FXModule;
import com.github.gtache.autosubtitle.modules.gui.impl.GuiCoreModule; import com.github.gtache.autosubtitle.modules.gui.impl.GuiCoreModule;
import com.github.gtache.autosubtitle.modules.impl.CoreModule; import com.github.gtache.autosubtitle.modules.impl.CoreModule;
import com.github.gtache.autosubtitle.modules.setup.ffmpeg.FFmpegSetupModule;
import com.github.gtache.autosubtitle.modules.setup.whisper.WhisperSetupModule;
import com.github.gtache.autosubtitle.modules.subtitle.impl.SubtitleModule;
import com.github.gtache.autosubtitle.modules.whisper.WhisperModule; import com.github.gtache.autosubtitle.modules.whisper.WhisperModule;
import dagger.Component; import dagger.Component;
import javafx.fxml.FXMLLoader; import javafx.fxml.FXMLLoader;
@@ -17,8 +14,8 @@ import javax.inject.Singleton;
* Main component * Main component
*/ */
@Singleton @Singleton
@Component(modules = {CoreModule.class, GuiCoreModule.class, FXModule.class, FFmpegModule.class, FFmpegSetupModule.class, @Component(modules = {CoreModule.class, GuiCoreModule.class, FXModule.class, FFmpegModule.class,
SubtitleModule.class, WhisperModule.class, WhisperSetupModule.class, MissingComponentsModule.class}) WhisperModule.class, MissingComponentsModule.class})
public interface RunComponent { public interface RunComponent {
/** /**

View File

@@ -3,7 +3,7 @@
*/ */
module com.github.gtache.autosubtitle.run { module com.github.gtache.autosubtitle.run {
requires com.github.gtache.autosubtitle.ffmpeg; requires com.github.gtache.autosubtitle.ffmpeg;
requires com.github.gtache.autosubtitle.fx; requires com.github.gtache.autosubtitle.gui.fx;
requires com.github.gtache.autosubtitle.whisper; requires com.github.gtache.autosubtitle.whisper;
opens com.github.gtache.autosubtitle.run to javafx.graphics; opens com.github.gtache.autosubtitle.run to javafx.graphics;

18
pom.xml
View File

@@ -25,9 +25,11 @@
<maven.compiler.target>21</maven.compiler.target> <maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<commons.compress.version>1.27.0</commons.compress.version>
<dagger.version>2.51.1</dagger.version> <dagger.version>2.51.1</dagger.version>
<log4j.version>2.23.1</log4j.version> <log4j.version>2.23.1</log4j.version>
<picocli.version>4.7.6</picocli.version> <picocli.version>4.7.6</picocli.version>
<xz.version>1.10</xz.version>
</properties> </properties>
<dependencyManagement> <dependencyManagement>
@@ -82,6 +84,11 @@
<artifactId>dagger</artifactId> <artifactId>dagger</artifactId>
<version>${dagger.version}</version> <version>${dagger.version}</version>
</dependency> </dependency>
<dependency>
<groupId>info.picocli</groupId>
<artifactId>picocli</artifactId>
<version>${picocli.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.apache.logging.log4j</groupId> <groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId> <artifactId>log4j-api</artifactId>
@@ -93,9 +100,14 @@
<version>${log4j.version}</version> <version>${log4j.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>info.picocli</groupId> <groupId>org.apache.commons</groupId>
<artifactId>picocli</artifactId> <artifactId>commons-compress</artifactId>
<version>${picocli.version}</version> <version>${commons.compress.version}</version>
</dependency>
<dependency>
<groupId>org.tukaani</groupId>
<artifactId>xz</artifactId>
<version>${xz.version}</version>
</dependency> </dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>

View File

@@ -1,11 +1,11 @@
package com.github.gtache.autosubtitle.modules.setup.whisper; package com.github.gtache.autosubtitle.modules.setup.whisper;
import com.github.gtache.autosubtitle.impl.Architecture;
import com.github.gtache.autosubtitle.impl.OS; import com.github.gtache.autosubtitle.impl.OS;
import com.github.gtache.autosubtitle.setup.whisper.CondaSetupConfiguration;
import dagger.Module; import dagger.Module;
import dagger.Provides; import dagger.Provides;
import javax.inject.Singleton;
import java.net.http.HttpClient;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
@@ -13,54 +13,54 @@ import java.nio.file.Paths;
* Setup module for Conda * Setup module for Conda
*/ */
@Module @Module
public abstract class CondaSetupModule { public final class CondaSetupModule {
private static final String CONDA = "conda"; private static final String CONDA = "conda";
private static final String MINICONDA3 = "miniconda3"; private static final String MINICONDA3 = "miniconda3";
@Provides private CondaSetupModule() {
@Singleton
static HttpClient providesHttpClient() { }
return HttpClient.newHttpClient();
@Provides
static CondaSetupConfiguration providesCondaSetupConfiguration(@CondaSystemPath final Path condaSystemPath, @CondaBundledPath final Path condaBundledPath,
@CondaMinimumMajorVersion final int condaMinimumMajorVersion, @CondaMinimumMinorVersion final int condaMinimumMinorVersion,
@CondaInstallerPath final Path condaInstallerPath, @CondaRootPath final Path condaRootPath,
final OS os, final Architecture architecture) {
return new CondaSetupConfiguration(condaRootPath, condaSystemPath, condaBundledPath, condaMinimumMajorVersion, condaMinimumMinorVersion, condaInstallerPath, os, architecture);
} }
@Provides @Provides
@Singleton
@CondaSystemPath @CondaSystemPath
static Path providesCondaSystemPath(final OS os) { static Path providesCondaSystemPath(final OS os) {
return Paths.get(os == OS.WINDOWS ? CONDA + ".bat" : CONDA); return Paths.get(os == OS.WINDOWS ? CONDA + ".bat" : CONDA);
} }
@Provides @Provides
@Singleton
@CondaBundledPath @CondaBundledPath
static Path providesCondaBundledPath(@CondaRootPath final Path root, final OS os) { static Path providesCondaBundledPath(@CondaRootPath final Path root, final OS os) {
return root.resolve("condabin").resolve(Paths.get(os == OS.WINDOWS ? CONDA + ".bat" : CONDA)); return root.resolve("condabin").resolve(Paths.get(os == OS.WINDOWS ? CONDA + ".bat" : CONDA));
} }
@Provides @Provides
@Singleton
@CondaMinimumMajorVersion @CondaMinimumMajorVersion
static int providesCondaMinimumMajorVersion() { static int providesCondaMinimumMajorVersion() {
return 24; return 24;
} }
@Provides @Provides
@Singleton
@CondaMinimumMinorVersion @CondaMinimumMinorVersion
static int providesCondaMinimumMinorVersion() { static int providesCondaMinimumMinorVersion() {
return 5; return 5;
} }
@Provides @Provides
@Singleton
@CondaRootPath @CondaRootPath
static Path providesCondaRootPath(@WhisperBundledRoot final Path root, final OS os) { static Path providesCondaRootPath(@WhisperBundledRoot final Path root, final OS os) {
return root.resolve(MINICONDA3); return root.resolve(MINICONDA3);
} }
@Provides @Provides
@Singleton
@CondaInstallerPath @CondaInstallerPath
static Path providesCondaInstallerPath(@WhisperBundledRoot final Path root, final OS os) { static Path providesCondaInstallerPath(@WhisperBundledRoot final Path root, final OS os) {
return root.resolve("cache").resolve("conda-install" + (os == OS.WINDOWS ? ".exe" : ".sh")); return root.resolve("cache").resolve("conda-install" + (os == OS.WINDOWS ? ".exe" : ".sh"));

View File

@@ -7,7 +7,6 @@ import dagger.Binds;
import dagger.Module; import dagger.Module;
import dagger.Provides; import dagger.Provides;
import javax.inject.Singleton;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
@@ -21,28 +20,24 @@ public abstract class WhisperSetupModule {
abstract SetupManager bindsSubtitleExtractorSetupManager(final WhisperSetupManager manager); abstract SetupManager bindsSubtitleExtractorSetupManager(final WhisperSetupManager manager);
@Provides @Provides
@Singleton
@PythonVersion @PythonVersion
static String providesPythonVersion() { static String providesPythonVersion() {
return "3.9.19"; return "3.9.19";
} }
@Provides @Provides
@Singleton
@WhisperVersion @WhisperVersion
static String providesWhisperVersion() { static String providesWhisperVersion() {
return "20231117"; return "20231117";
} }
@Provides @Provides
@Singleton
@WhisperBundledRoot @WhisperBundledRoot
static Path providesWhisperBundledRoot() { static Path providesWhisperBundledRoot() {
return Paths.get("tools", "whisper"); return Paths.get("tools", "whisper");
} }
@Provides @Provides
@Singleton
@WhisperVenvPath @WhisperVenvPath
static Path providesWhisperVenvPath(@WhisperBundledRoot final Path root) { static Path providesWhisperVenvPath(@WhisperBundledRoot final Path root) {
return root.resolve("whisper-env"); return root.resolve("whisper-env");

View File

@@ -11,6 +11,10 @@ import dagger.Module;
@Module @Module
public abstract class WhisperExtractorModule { public abstract class WhisperExtractorModule {
private WhisperExtractorModule() {
}
@Binds @Binds
abstract SubtitleExtractor bindsSubtitleExtractor(final WhisperSubtitleExtractor extractor); abstract SubtitleExtractor bindsSubtitleExtractor(final WhisperSubtitleExtractor extractor);
} }

View File

@@ -14,6 +14,10 @@ import javax.inject.Singleton;
@Module @Module
public abstract class WhisperJsonModule { public abstract class WhisperJsonModule {
private WhisperJsonModule() {
}
@Binds @Binds
@IntoMap @IntoMap
@StringKey("json") @StringKey("json")

View File

@@ -1,5 +1,6 @@
package com.github.gtache.autosubtitle.modules.whisper; package com.github.gtache.autosubtitle.modules.whisper;
import com.github.gtache.autosubtitle.modules.setup.whisper.WhisperSetupModule;
import com.github.gtache.autosubtitle.modules.subtitle.extractor.whisper.WhisperExtractorModule; import com.github.gtache.autosubtitle.modules.subtitle.extractor.whisper.WhisperExtractorModule;
import com.github.gtache.autosubtitle.modules.subtitle.parser.json.whisper.WhisperJsonModule; import com.github.gtache.autosubtitle.modules.subtitle.parser.json.whisper.WhisperJsonModule;
import com.github.gtache.autosubtitle.subtitle.extractor.ExtractionModelProvider; import com.github.gtache.autosubtitle.subtitle.extractor.ExtractionModelProvider;
@@ -10,9 +11,13 @@ import dagger.Module;
/** /**
* Dagger module for Whisper * Dagger module for Whisper
*/ */
@Module(includes = {WhisperJsonModule.class, WhisperExtractorModule.class}) @Module(includes = {WhisperSetupModule.class, WhisperJsonModule.class, WhisperExtractorModule.class})
public abstract class WhisperModule { public abstract class WhisperModule {
private WhisperModule() {
}
@Binds @Binds
abstract ExtractionModelProvider bindsExtractionModelProvider(final WhisperExtractionModelProvider provider); abstract ExtractionModelProvider bindsExtractionModelProvider(final WhisperExtractionModelProvider provider);
} }

View File

@@ -0,0 +1,30 @@
package com.github.gtache.autosubtitle.setup.whisper;
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 conda setup
*/
public record CondaSetupConfiguration(Path condaRootPath, Path condaSystemPath, Path condaBundledPath,
int condaMinimumMajorVersion, int condaMinimumMinorVersion,
Path condaInstallerPath, OS os, Architecture architecture) {
public CondaSetupConfiguration {
Objects.requireNonNull(condaRootPath);
Objects.requireNonNull(condaSystemPath);
Objects.requireNonNull(condaBundledPath);
if (condaMinimumMajorVersion <= 0) {
throw new IllegalArgumentException("Conda minimum major version must be > 0 : " + condaMinimumMajorVersion);
}
if (condaMinimumMinorVersion < 0) {
throw new IllegalArgumentException("Conda minimum minor version must be >= 0 : " + condaMinimumMinorVersion);
}
Objects.requireNonNull(condaInstallerPath);
Objects.requireNonNull(os);
Objects.requireNonNull(architecture);
}
}

View File

@@ -1,13 +1,6 @@
package com.github.gtache.autosubtitle.setup.whisper; package com.github.gtache.autosubtitle.setup.whisper;
import com.github.gtache.autosubtitle.impl.Architecture;
import com.github.gtache.autosubtitle.impl.OS; import com.github.gtache.autosubtitle.impl.OS;
import com.github.gtache.autosubtitle.modules.setup.whisper.CondaBundledPath;
import com.github.gtache.autosubtitle.modules.setup.whisper.CondaInstallerPath;
import com.github.gtache.autosubtitle.modules.setup.whisper.CondaMinimumMajorVersion;
import com.github.gtache.autosubtitle.modules.setup.whisper.CondaMinimumMinorVersion;
import com.github.gtache.autosubtitle.modules.setup.whisper.CondaRootPath;
import com.github.gtache.autosubtitle.modules.setup.whisper.CondaSystemPath;
import com.github.gtache.autosubtitle.setup.SetupException; import com.github.gtache.autosubtitle.setup.SetupException;
import com.github.gtache.autosubtitle.setup.SetupStatus; import com.github.gtache.autosubtitle.setup.SetupStatus;
import com.github.gtache.autosubtitle.setup.impl.AbstractSetupManager; import com.github.gtache.autosubtitle.setup.impl.AbstractSetupManager;
@@ -17,10 +10,7 @@ import org.apache.logging.log4j.Logger;
import javax.inject.Inject; import javax.inject.Inject;
import javax.inject.Singleton; import javax.inject.Singleton;
import java.io.IOException; import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Arrays; import java.util.Arrays;
@@ -36,30 +26,12 @@ public class CondaSetupManager extends AbstractSetupManager {
private static final Logger logger = LogManager.getLogger(CondaSetupManager.class); private static final Logger logger = LogManager.getLogger(CondaSetupManager.class);
private final Path condaSystemPath; private final CondaSetupConfiguration configuration;
private final Path condaBundledPath;
private final int condaMinimumMajorVersion;
private final int condaMinimumMinorVersion;
private final Path condaInstallerPath;
private final Path condaRootPath;
private final OS os;
private final Architecture architecture;
private final HttpClient httpClient;
@Inject @Inject
CondaSetupManager(@CondaSystemPath final Path condaSystemPath, @CondaBundledPath final Path condaBundledPath, CondaSetupManager(final CondaSetupConfiguration configuration, final HttpClient httpClient) {
@CondaMinimumMajorVersion final int condaMinimumMajorVersion, @CondaMinimumMinorVersion final int condaMinimumMinorVersion, super(httpClient);
@CondaInstallerPath final Path condaInstallerPath, @CondaRootPath final Path condaRootPath, this.configuration = requireNonNull(configuration);
final OS os, final Architecture architecture, final HttpClient httpClient) {
this.condaSystemPath = requireNonNull(condaSystemPath);
this.condaBundledPath = requireNonNull(condaBundledPath);
this.condaMinimumMajorVersion = condaMinimumMajorVersion;
this.condaMinimumMinorVersion = condaMinimumMinorVersion;
this.condaInstallerPath = requireNonNull(condaInstallerPath);
this.condaRootPath = requireNonNull(condaRootPath);
this.os = requireNonNull(os);
this.architecture = requireNonNull(architecture);
this.httpClient = requireNonNull(httpClient);
} }
@Override @Override
@@ -86,14 +58,15 @@ public class CondaSetupManager extends AbstractSetupManager {
} }
private void installConda() throws SetupException { private void installConda() throws SetupException {
if (Files.exists(condaInstallerPath)) { final var installerPath = configuration.condaInstallerPath();
logger.info("Conda exists at {}", condaInstallerPath); if (Files.exists(installerPath)) {
logger.info("Conda exists at {}", installerPath);
} else { } else {
logger.info("Conda installer not found, downloading"); logger.info("Conda installer not found, downloading");
downloadConda(); downloadConda();
logger.info("Conda downloaded"); logger.info("Conda downloaded");
} }
switch (os) { switch (configuration.os()) {
case WINDOWS -> installWindows(); case WINDOWS -> installWindows();
case MAC, LINUX -> installLinux(); case MAC, LINUX -> installLinux();
} }
@@ -101,10 +74,12 @@ public class CondaSetupManager extends AbstractSetupManager {
private void installLinux() throws SetupException { private void installLinux() throws SetupException {
try { try {
logger.info("Installing conda using {}", condaInstallerPath); final var installerPath = configuration.condaInstallerPath();
final var result = run("bash", condaInstallerPath.toString(), "-b", "-p", condaRootPath.toString()); final var rootPath = configuration.condaRootPath();
logger.info("Installing conda using {}", installerPath);
final var result = run("bash", installerPath.toString(), "-b", "-p", rootPath.toString());
if (result.exitCode() == 0) { if (result.exitCode() == 0) {
logger.info("Installed conda to {}", condaRootPath); logger.info("Installed conda to {}", rootPath);
} else { } else {
throw new SetupException("Error installing conda: " + result); throw new SetupException("Error installing conda: " + result);
} }
@@ -115,10 +90,12 @@ public class CondaSetupManager extends AbstractSetupManager {
private void installWindows() throws SetupException { private void installWindows() throws SetupException {
try { try {
logger.info("Installing conda using {}", condaInstallerPath); final var installerPath = configuration.condaInstallerPath();
final var result = run(condaInstallerPath.toString(), "/InstallationType=JustMe", "/RegisterPython=0", "/S", "/D=" + condaRootPath.toString()); final var rootPath = configuration.condaRootPath();
logger.info("Installing conda using {}", installerPath);
final var result = run(installerPath.toString(), "/InstallationType=JustMe", "/RegisterPython=0", "/S", "/D=" + rootPath.toString());
if (result.exitCode() == 0) { if (result.exitCode() == 0) {
logger.info("Installed conda to {}", condaRootPath); logger.info("Installed conda to {}", rootPath);
} else { } else {
throw new SetupException("Error installing conda: " + result); throw new SetupException("Error installing conda: " + result);
} }
@@ -128,15 +105,16 @@ public class CondaSetupManager extends AbstractSetupManager {
} }
private void downloadConda() throws SetupException { private void downloadConda() throws SetupException {
switch (os) { switch (configuration.os()) {
case WINDOWS -> downloadCondaWindows(); case WINDOWS -> downloadCondaWindows();
case MAC -> downloadCondaMac(); case MAC -> downloadCondaMac();
case LINUX -> downloadCondaLinux(); case LINUX -> downloadCondaLinux();
} }
logger.info("Downloaded conda to {}", condaInstallerPath); logger.info("Downloaded conda to {}", configuration.condaInstallerPath());
} }
private void downloadCondaLinux() throws SetupException { private void downloadCondaLinux() throws SetupException {
final var architecture = configuration.architecture();
if (architecture.isAMD64()) { if (architecture.isAMD64()) {
downloadConda("https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh"); downloadConda("https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh");
} else if (architecture.isARM64()) { } else if (architecture.isARM64()) {
@@ -147,6 +125,7 @@ public class CondaSetupManager extends AbstractSetupManager {
} }
private void downloadCondaMac() throws SetupException { private void downloadCondaMac() throws SetupException {
final var architecture = configuration.architecture();
if (architecture.isAMD64()) { if (architecture.isAMD64()) {
downloadConda("https://repo.anaconda.com/miniconda/Miniconda3-latest-MacOSX-x86_64.sh"); downloadConda("https://repo.anaconda.com/miniconda/Miniconda3-latest-MacOSX-x86_64.sh");
} else if (architecture.isARM64()) { } else if (architecture.isARM64()) {
@@ -162,25 +141,12 @@ public class CondaSetupManager extends AbstractSetupManager {
} }
private void downloadConda(final String url) throws SetupException { private void downloadConda(final String url) throws SetupException {
final var request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build(); download(url, configuration.condaInstallerPath());
try {
final var result = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(condaInstallerPath));
if (result.statusCode() == 200) {
logger.info("Conda download successful");
} else {
throw new SetupException("Error downloading conda: " + result.body());
}
} catch (final IOException e) {
throw new SetupException(e);
} catch (final InterruptedException e) {
Thread.currentThread().interrupt();
throw new SetupException(e);
}
} }
@Override @Override
public void uninstall() throws SetupException { public void uninstall() throws SetupException {
deleteFolder(condaRootPath); deleteFolder(configuration.condaRootPath());
} }
@Override @Override
@@ -208,7 +174,7 @@ public class CondaSetupManager extends AbstractSetupManager {
throw new SetupException("Error creating venv " + path + ": " + result.output()); throw new SetupException("Error creating venv " + path + ": " + result.output());
} }
// On Windows, we need to copy the DLLs otherwise pip may not work // On Windows, we need to copy the DLLs otherwise pip may not work
if (os == OS.WINDOWS) { if (configuration.os() == OS.WINDOWS) {
final var sourceFolder = path.resolve("Library").resolve("bin"); final var sourceFolder = path.resolve("Library").resolve("bin");
try (final var files = Files.find(sourceFolder, 1, (p, a) -> p.getFileName().toString().contains("libcrypto") || p.getFileName().toString().contains("libssl"))) { try (final var files = Files.find(sourceFolder, 1, (p, a) -> p.getFileName().toString().contains("libcrypto") || p.getFileName().toString().contains("libssl"))) {
final var fileList = files.toList(); final var fileList = files.toList();
@@ -225,16 +191,16 @@ public class CondaSetupManager extends AbstractSetupManager {
} }
public boolean venvExists(final Path path) { public boolean venvExists(final Path path) {
return Files.exists(path.resolve("bin").resolve(os == OS.WINDOWS ? "python.exe" : "python")); return Files.exists(path.resolve("bin").resolve(configuration.os() == OS.WINDOWS ? "python.exe" : "python"));
} }
private Path getCondaPath() throws SetupException { private Path getCondaPath() throws SetupException {
return isSystemCondaInstalled() ? condaSystemPath : condaBundledPath; return isSystemCondaInstalled() ? configuration.condaSystemPath() : configuration.condaBundledPath();
} }
private boolean isSystemCondaInstalled() throws SetupException { private boolean isSystemCondaInstalled() throws SetupException {
try { try {
final var result = run(condaSystemPath.toString(), "--version"); final var result = run(configuration.condaSystemPath().toString(), "--version");
if (result.exitCode() == 0) { if (result.exitCode() == 0) {
final var output = result.output().getFirst(); final var output = result.output().getFirst();
final var versionString = output.substring(output.indexOf(' ') + 1); final var versionString = output.substring(output.indexOf(' ') + 1);
@@ -242,7 +208,7 @@ public class CondaSetupManager extends AbstractSetupManager {
if (version.length == 3) { if (version.length == 3) {
final var major = Integer.parseInt(version[0]); final var major = Integer.parseInt(version[0]);
final var minor = Integer.parseInt(version[1]); final var minor = Integer.parseInt(version[1]);
return major >= condaMinimumMajorVersion && minor >= condaMinimumMinorVersion; return major >= configuration.condaMinimumMajorVersion() && minor >= configuration.condaMinimumMinorVersion();
} else { } else {
throw new SetupException("Unexpected python version: " + versionString); throw new SetupException("Unexpected python version: " + versionString);
} }
@@ -255,6 +221,6 @@ public class CondaSetupManager extends AbstractSetupManager {
} }
private boolean isBundledCondaInstalled() { private boolean isBundledCondaInstalled() {
return Files.isRegularFile(condaBundledPath); return Files.isRegularFile(configuration.condaBundledPath());
} }
} }

View File

@@ -0,0 +1,21 @@
package com.github.gtache.autosubtitle.setup.whisper;
import com.github.gtache.autosubtitle.impl.OS;
import java.nio.file.Path;
import java.util.Objects;
/**
* Configuration for whisper setup
*/
public record WhisperSetupConfiguration(Path root, Path venvPath, String pythonVersion,
String whisperVersion, OS os) {
public WhisperSetupConfiguration {
Objects.requireNonNull(root);
Objects.requireNonNull(venvPath);
Objects.requireNonNull(pythonVersion);
Objects.requireNonNull(whisperVersion);
Objects.requireNonNull(os);
}
}

View File

@@ -1,10 +1,6 @@
package com.github.gtache.autosubtitle.setup.whisper; package com.github.gtache.autosubtitle.setup.whisper;
import com.github.gtache.autosubtitle.impl.OS; import com.github.gtache.autosubtitle.impl.OS;
import com.github.gtache.autosubtitle.modules.setup.whisper.PythonVersion;
import com.github.gtache.autosubtitle.modules.setup.whisper.WhisperBundledRoot;
import com.github.gtache.autosubtitle.modules.setup.whisper.WhisperVenvPath;
import com.github.gtache.autosubtitle.modules.setup.whisper.WhisperVersion;
import com.github.gtache.autosubtitle.setup.SetupAction; import com.github.gtache.autosubtitle.setup.SetupAction;
import com.github.gtache.autosubtitle.setup.SetupException; import com.github.gtache.autosubtitle.setup.SetupException;
import com.github.gtache.autosubtitle.setup.SetupStatus; import com.github.gtache.autosubtitle.setup.SetupStatus;
@@ -20,28 +16,23 @@ import java.nio.file.Path;
import static java.util.Objects.requireNonNull; import static java.util.Objects.requireNonNull;
//TODO add gpg/signature check
/**
* Setup manager for Whisper
*/
@Singleton @Singleton
public class WhisperSetupManager extends AbstractSetupManager { public class WhisperSetupManager extends AbstractSetupManager {
private static final Logger logger = LogManager.getLogger(WhisperSetupManager.class); private static final Logger logger = LogManager.getLogger(WhisperSetupManager.class);
private static final String CONDA_ENV = "conda-env"; private static final String CONDA_ENV = "conda-env";
private final CondaSetupManager condaSetupManager; private final CondaSetupManager condaSetupManager;
private final String pythonVersion; private final WhisperSetupConfiguration configuration;
private final Path whisperRoot;
private final Path venvPath;
private final OS os;
private final String whisperVersion;
@Inject @Inject
WhisperSetupManager(final CondaSetupManager condaSetupManager, @PythonVersion final String pythonVersion, WhisperSetupManager(final CondaSetupManager condaSetupManager, final WhisperSetupConfiguration configuration) {
@WhisperBundledRoot final Path whisperRoot, @WhisperVenvPath final Path venvPath, final OS os,
@WhisperVersion final String whisperVersion) {
this.condaSetupManager = requireNonNull(condaSetupManager); this.condaSetupManager = requireNonNull(condaSetupManager);
this.pythonVersion = requireNonNull(pythonVersion); this.configuration = requireNonNull(configuration);
this.whisperRoot = requireNonNull(whisperRoot);
this.venvPath = requireNonNull(venvPath);
this.os = requireNonNull(os);
this.whisperVersion = requireNonNull(whisperVersion);
} }
@Override @Override
@@ -83,12 +74,12 @@ public class WhisperSetupManager extends AbstractSetupManager {
private void checkInstallVenv() throws SetupException { private void checkInstallVenv() throws SetupException {
sendStartEvent(SetupAction.CHECK, CONDA_ENV, 0.33); sendStartEvent(SetupAction.CHECK, CONDA_ENV, 0.33);
if (condaSetupManager.venvExists(venvPath)) { if (condaSetupManager.venvExists(configuration.venvPath())) {
sendEndEvent(SetupAction.CHECK, CONDA_ENV, 0.66); sendEndEvent(SetupAction.CHECK, CONDA_ENV, 0.66);
} else { } else {
sendEndEvent(SetupAction.CHECK, CONDA_ENV, -1); sendEndEvent(SetupAction.CHECK, CONDA_ENV, -1);
sendStartEvent(SetupAction.INSTALL, CONDA_ENV, -1); sendStartEvent(SetupAction.INSTALL, CONDA_ENV, -1);
condaSetupManager.createVenv(venvPath, pythonVersion); condaSetupManager.createVenv(configuration.venvPath(), configuration.pythonVersion());
sendEndEvent(SetupAction.INSTALL, CONDA_ENV, 0.66); sendEndEvent(SetupAction.INSTALL, CONDA_ENV, 0.66);
} }
} }
@@ -107,7 +98,7 @@ public class WhisperSetupManager extends AbstractSetupManager {
@Override @Override
public void uninstall() throws SetupException { public void uninstall() throws SetupException {
deleteFolder(whisperRoot); deleteFolder(configuration.root());
} }
@Override @Override
@@ -124,19 +115,21 @@ public class WhisperSetupManager extends AbstractSetupManager {
} }
private Path getPythonPath() { private Path getPythonPath() {
return venvPath.resolve(os == OS.WINDOWS ? "python.exe" : "python"); return configuration.venvPath().resolve(configuration.os() == OS.WINDOWS ? "python.exe" : "python");
} }
private void installWhisper() throws SetupException { private void installWhisper() throws SetupException {
final var path = getPythonPath(); final var path = getPythonPath();
try { try {
logger.info("Installing whisper"); logger.info("Installing whisper");
final var result = run(path.toString(), "-m", "pip", "install", "-U", "openai-whisper==" + whisperVersion, "numpy<2"); final var result = run(path.toString(), "-m", "pip", "install", "-U", "openai-whisper==" + configuration.whisperVersion(), "numpy<2");
if (result.exitCode() == 0) { if (result.exitCode() == 0) {
logger.info("Whisper installed"); logger.info("Whisper installed");
} else { } else {
throw new SetupException("Error installing whisper: " + result.output()); throw new SetupException("Error installing whisper: " + result.output());
} }
//TODO cuda?
final var cudaResult = run(path.toString(), "-m", "pip", "install", "-U", "torch", "--index-url", "https://download.pytorch.org/whl/cu124");
} catch (final IOException e) { } catch (final IOException e) {
throw new SetupException(e); throw new SetupException(e);
} }

View File

@@ -41,7 +41,10 @@ import static java.util.Objects.requireNonNull;
public class WhisperSubtitleExtractor extends AbstractProcessRunner implements SubtitleExtractor { public class WhisperSubtitleExtractor extends AbstractProcessRunner implements SubtitleExtractor {
private static final Logger logger = LogManager.getLogger(WhisperSubtitleExtractor.class); private static final Logger logger = LogManager.getLogger(WhisperSubtitleExtractor.class);
private static final String AUTOSUBTITLE = "autosubtitle";
private static final Pattern LINE_PROGRESS_PATTERN = Pattern.compile("^\\[\\d{2}:\\d{2}\\.\\d{3} --> (?<minutes>\\d{2}):(?<seconds>\\d{2})\\.(?<millis>\\d{3})]"); private static final Pattern LINE_PROGRESS_PATTERN = Pattern.compile("^\\[\\d{2}:\\d{2}\\.\\d{3} --> (?<minutes>\\d{2}):(?<seconds>\\d{2})\\.(?<millis>\\d{3})]");
private static final Pattern TQDM_PROGRESS_PATTERN = Pattern.compile("^(?<progress>\\d{1,3})%|.+");
private final Path venvPath; private final Path venvPath;
private final SubtitleConverter converter; private final SubtitleConverter converter;
private final OS os; private final OS os;
@@ -80,7 +83,7 @@ public class WhisperSubtitleExtractor extends AbstractProcessRunner implements S
return extract(f.path(), language, model, video.info().duration()); return extract(f.path(), language, model, video.info().duration());
} else { } else {
try { try {
final var path = Files.createTempFile("autosubtitle", video.info().videoFormat()); final var path = Files.createTempFile(AUTOSUBTITLE, video.info().videoFormat());
try (final var in = video.getInputStream()) { try (final var in = video.getInputStream()) {
Files.copy(in, path); Files.copy(in, path);
final var ret = extract(path, language, model, video.info().duration()); final var ret = extract(path, language, model, video.info().duration());
@@ -99,7 +102,7 @@ public class WhisperSubtitleExtractor extends AbstractProcessRunner implements S
return extract(f.path(), language, model, audio.info().duration()); return extract(f.path(), language, model, audio.info().duration());
} else { } else {
try { try {
final var path = Files.createTempFile("autosubtitle", audio.info().audioFormat()); final var path = Files.createTempFile(AUTOSUBTITLE, audio.info().audioFormat());
try (final var in = audio.getInputStream()) { try (final var in = audio.getInputStream()) {
Files.copy(in, path); Files.copy(in, path);
final var ret = extract(path, language, model, audio.info().duration()); final var ret = extract(path, language, model, audio.info().duration());
@@ -114,13 +117,13 @@ public class WhisperSubtitleExtractor extends AbstractProcessRunner implements S
private SubtitleCollection extract(final Path path, final Language language, final ExtractionModel model, final long duration) throws ExtractException { private SubtitleCollection extract(final Path path, final Language language, final ExtractionModel model, final long duration) throws ExtractException {
try { try {
final var outputDir = Files.createTempDirectory("autosubtitle"); final var outputDir = Files.createTempDirectory(AUTOSUBTITLE);
final var args = createArgs(path, language, model, outputDir); final var args = createArgs(path, language, model, outputDir);
final var processListener = startListen(args); final var processListener = startListen(args);
var line = processListener.readLine();
var oldProgress = -1.0; var oldProgress = -1.0;
var line = processListener.readLine();
while (line != null) { while (line != null) {
logger.info("Whisper output : {}", line); logger.info("[whisper]: {}", line);
final var newProgress = computeProgress(line, duration, oldProgress); final var newProgress = computeProgress(line, duration, oldProgress);
notifyListeners(new ExtractEventImpl(line, newProgress)); notifyListeners(new ExtractEventImpl(line, newProgress));
oldProgress = newProgress; oldProgress = newProgress;
@@ -131,11 +134,7 @@ public class WhisperSubtitleExtractor extends AbstractProcessRunner implements S
final var filename = path.getFileName().toString(); final var filename = path.getFileName().toString();
final var subtitleFilename = filename.substring(0, filename.lastIndexOf('.')) + ".json"; final var subtitleFilename = filename.substring(0, filename.lastIndexOf('.')) + ".json";
final var subtitleFile = outputDir.resolve(subtitleFilename); final var subtitleFile = outputDir.resolve(subtitleFilename);
try { return parseResult(subtitleFile);
return converter.parse(subtitleFile);
} catch (final ParseException e) {
throw new ExtractException(e);
}
} else { } else {
throw new ExtractException("Error extracting subtitles: " + result.output()); throw new ExtractException("Error extracting subtitles: " + result.output());
} }
@@ -144,6 +143,14 @@ public class WhisperSubtitleExtractor extends AbstractProcessRunner implements S
} }
} }
private SubtitleCollection parseResult(final Path subtitleFile) throws ExtractException {
try {
return converter.parse(subtitleFile);
} catch (final ParseException e) {
throw new ExtractException(e);
}
}
private static double computeProgress(final CharSequence line, final long duration, final double oldProgress) { private static double computeProgress(final CharSequence line, final long duration, final double oldProgress) {
final var matcher = LINE_PROGRESS_PATTERN.matcher(line); final var matcher = LINE_PROGRESS_PATTERN.matcher(line);
if (matcher.find()) { if (matcher.find()) {
@@ -152,6 +159,11 @@ public class WhisperSubtitleExtractor extends AbstractProcessRunner implements S
final var millis = Integer.parseInt(matcher.group("millis")); final var millis = Integer.parseInt(matcher.group("millis"));
return ((minutes * 60L + seconds) * 1000 + millis) / (double) duration; return ((minutes * 60L + seconds) * 1000 + millis) / (double) duration;
} else { } else {
final var tqdmMatcher = TQDM_PROGRESS_PATTERN.matcher(line);
if (tqdmMatcher.find()) {
final var progress = Integer.parseInt(tqdmMatcher.group("progress"));
return progress / 100.0;
}
return oldProgress; return oldProgress;
} }
} }
@@ -161,6 +173,8 @@ public class WhisperSubtitleExtractor extends AbstractProcessRunner implements S
args.add(getPythonPath().toString()); args.add(getPythonPath().toString());
args.add("-m"); args.add("-m");
args.add("whisper"); args.add("whisper");
args.add("--verbose");
args.add("False");
args.add("--model"); args.add("--model");
if (model != WhisperModels.LARGE && language == Language.EN) { if (model != WhisperModels.LARGE && language == Language.EN) {
args.add(model.name().toLowerCase() + ".en"); args.add(model.name().toLowerCase() + ".en");

View File

@@ -53,7 +53,7 @@ public class JSONSubtitleConverter implements SubtitleConverter {
return new SubtitleImpl(s.text(), start, end, null, null); return new SubtitleImpl(s.text(), start, end, null, null);
}).sorted(Comparator.comparing(Subtitle::start).thenComparing(Subtitle::end)).toList(); }).sorted(Comparator.comparing(Subtitle::start).thenComparing(Subtitle::end)).toList();
final var language = Language.getLanguage(json.language()); final var language = Language.getLanguage(json.language());
final var subtitlesText = subtitles.stream().map(Subtitle::content).collect(Collectors.joining(" ")); final var subtitlesText = subtitles.stream().map(Subtitle::content).collect(Collectors.joining(""));
if (!Objects.equals(json.text(), subtitlesText)) { if (!Objects.equals(json.text(), subtitlesText)) {
logger.warn("Not same text: {}\n\n{}", json.text(), subtitlesText); logger.warn("Not same text: {}\n\n{}", json.text(), subtitlesText);
} }