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 java.io.IOException;
import java.nio.file.Path;
import java.util.Collection;
public interface VideoConverter {
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;
void addHardSubtitles(final Video video, final SubtitleCollection subtitles, final Path path) throws IOException;
Audio getAudio(final Video video) throws IOException;
}

View File

@@ -29,6 +29,16 @@ public interface Subtitle {
return Duration.ofMillis(end() - start());
}
/**
* Checks if the subtitle is shown at the given time
*
* @param time the time
* @return true if the subtitle is shown
*/
default boolean isShowing(final long time) {
return time >= start() && time <= end();
}
/**
* @return the font of the subtitle
*/

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

View File

@@ -11,7 +11,7 @@ public enum Architecture {
//64 bit
X86, X86_64, AMD64,
//ARM 32 bit
ARM32, ARM, ARMV1, ARMV2, ARMV3, ARMV4, ARMV5, ARMV6, ARMV7, AARCH32,
ARM32, ARM, ARMV1, ARMV2, ARMV3, ARMV4, ARMV5, ARMV6, ARMV7, AARCH32, ARMHF, ARMEL,
//ARM 64 bit
ARM64, ARMV8, ARMV9, AARCH64,
UNKNOWN;

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

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
*/
@Module
public interface SubtitleModule {
public abstract class SubtitleModule {
private SubtitleModule() {
}
@Binds
@IntoMap
@StringKey("srt")
SubtitleConverter bindsSubtitleConverter(final SRTSubtitleConverter converter);
abstract SubtitleConverter bindsSubtitleConverter(final SRTSubtitleConverter converter);
}

View File

@@ -36,6 +36,7 @@ public abstract class AbstractProcessRunner implements ProcessRunner {
@Override
public Process start(final List<String> args) throws IOException {
logger.info("Running {}", args);
final var builder = new ProcessBuilder(args);
builder.redirectErrorStream(true);
return builder.start();
@@ -46,4 +47,22 @@ public abstract class AbstractProcessRunner implements ProcessRunner {
final var process = start(args);
return new ProcessListenerImpl(process);
}
/**
* Runs a process and writes the output to the log
*
* @param args the command
* @return the result
* @throws IOException if an error occurs
*/
protected ProcessResult runListen(final List<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.ProcessResult;
import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
@@ -30,7 +29,7 @@ public class ProcessListenerImpl implements ProcessListener {
*/
public ProcessListenerImpl(final Process process) {
this.process = Objects.requireNonNull(process);
this.reader = new BufferedReader(new InputStreamReader(new BufferedInputStream(process.getInputStream()), StandardCharsets.UTF_8));
this.reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
this.output = new ArrayList<>();
}

View File

@@ -12,6 +12,10 @@ import org.apache.logging.log4j.Logger;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
@@ -19,6 +23,8 @@ import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import static java.util.Objects.requireNonNull;
/**
* Base class for all {@link SetupManager} implementations
*/
@@ -26,12 +32,23 @@ public abstract class AbstractSetupManager extends AbstractProcessRunner impleme
private static final Logger logger = LogManager.getLogger(AbstractSetupManager.class);
private final Set<SetupListener> listeners;
private final HttpClient httpClient;
/**
* Instantiates the manager
* Instantiates the manager with a default client
*/
protected AbstractSetupManager() {
this(HttpClient.newHttpClient());
}
/**
* Instantiates the manager with the given client
*
* @param httpClient The HTTP client to use
*/
protected AbstractSetupManager(final HttpClient httpClient) {
this.listeners = new HashSet<>();
this.httpClient = requireNonNull(httpClient);
}
@Override
@@ -158,6 +175,30 @@ public abstract class AbstractSetupManager extends AbstractProcessRunner impleme
logger.info("{} deleted", path);
}
/**
* Downloads a file
*
* @param url The file url
* @param path The save path
* @throws SetupException If an error occurs
*/
protected void download(final String url, final Path path) throws SetupException {
final var request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
try {
final var result = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(path));
if (result.statusCode() == 200) {
logger.info("{} download successful", path);
} else {
throw new SetupException("Error downloading " + path + ": " + result.body());
}
} catch (final IOException e) {
throw new SetupException(e);
} catch (final InterruptedException e) {
Thread.currentThread().interrupt();
throw new SetupException(e);
}
}
private static long getFilesCount(final Path path) throws IOException {
try (final var stream = Files.walk(path)) {
return stream.count();

View File

@@ -35,11 +35,21 @@ public class SRTSubtitleConverter implements SubtitleConverter {
return IntStream.range(0, subtitles.size()).mapToObj(i -> {
final var subtitle = subtitles.get(i);
return (i + 1) + "\n" +
subtitle.start() + " --> " + subtitle.end() + "\n" +
formatTime(subtitle.start()) + " --> " + formatTime(subtitle.end()) + "\n" +
subtitle.content();
}).collect(Collectors.joining("\n\n"));
}
private static String formatTime(final long time) {
final var millisPerHour = 3600000;
final var millisPerMinute = 60000;
final var hours = time / millisPerHour;
final var minutes = (time - hours * millisPerHour) / millisPerMinute;
final var seconds = (time - hours * millisPerHour - minutes * millisPerMinute) / 1000;
final var millis = time - hours * millisPerHour - minutes * millisPerMinute - seconds * 1000;
return String.format("%02d:%02d:%02d,%03d", hours, minutes, seconds, millis);
}
@Override
public SubtitleCollection parse(final String content) throws ParseException {
try {

View File

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

View File

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

View File

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

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.ffmpeg.FFmpegVideoConverter;
import com.github.gtache.autosubtitle.ffmpeg.FFprobeVideoLoader;
import com.github.gtache.autosubtitle.modules.setup.ffmpeg.FFmpegSetupModule;
import dagger.Binds;
import dagger.Module;
@Module
public interface FFmpegModule {
/**
* Dagger module for FFmpeg
*/
@Module(includes = FFmpegSetupModule.class)
public abstract class FFmpegModule {
private FFmpegModule() {
}
@Binds
VideoConverter bindsVideoConverter(final FFmpegVideoConverter converter);
abstract VideoConverter bindsVideoConverter(final FFmpegVideoConverter converter);
@Binds
VideoLoader bindsVideoLoader(final FFprobeVideoLoader loader);
abstract VideoLoader bindsVideoLoader(final FFprobeVideoLoader loader);
}

View File

@@ -1,7 +1,11 @@
package com.github.gtache.autosubtitle.modules.setup.ffmpeg;
import com.github.gtache.autosubtitle.impl.Architecture;
import com.github.gtache.autosubtitle.impl.OS;
import com.github.gtache.autosubtitle.modules.ffmpeg.FFBundledRoot;
import com.github.gtache.autosubtitle.modules.ffmpeg.FFProbeInstallerPath;
import com.github.gtache.autosubtitle.modules.ffmpeg.FFmpegBundledPath;
import com.github.gtache.autosubtitle.modules.ffmpeg.FFmpegInstallerPath;
import com.github.gtache.autosubtitle.modules.ffmpeg.FFmpegSystemPath;
import com.github.gtache.autosubtitle.modules.ffmpeg.FFmpegVersion;
import com.github.gtache.autosubtitle.modules.ffmpeg.FFprobeBundledPath;
@@ -9,12 +13,18 @@ import com.github.gtache.autosubtitle.modules.ffmpeg.FFprobeSystemPath;
import com.github.gtache.autosubtitle.modules.impl.ExecutableExtension;
import com.github.gtache.autosubtitle.modules.setup.impl.VideoConverterSetup;
import com.github.gtache.autosubtitle.setup.SetupManager;
import com.github.gtache.autosubtitle.setup.ffmpeg.Decompresser;
import com.github.gtache.autosubtitle.setup.ffmpeg.FFmpegSetupConfiguration;
import com.github.gtache.autosubtitle.setup.ffmpeg.FFmpegSetupManager;
import com.github.gtache.autosubtitle.setup.ffmpeg.TarDecompresser;
import com.github.gtache.autosubtitle.setup.ffmpeg.XZDecompresser;
import com.github.gtache.autosubtitle.setup.ffmpeg.ZipDecompresser;
import dagger.Binds;
import dagger.Module;
import dagger.Provides;
import dagger.multibindings.IntoMap;
import dagger.multibindings.StringKey;
import javax.inject.Singleton;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -27,47 +37,87 @@ public abstract class FFmpegSetupModule {
private static final String FFMPEG = "ffmpeg";
private static final String FFPROBE = "ffprobe";
private FFmpegSetupModule() {
}
@Binds
@StringKey("zip")
@IntoMap
abstract Decompresser bindsZipDecompresser(final ZipDecompresser decompresser);
@Binds
@StringKey("tar")
@IntoMap
abstract Decompresser bindsTarDecompresser(final TarDecompresser decompresser);
@Binds
@StringKey("xz")
@IntoMap
abstract Decompresser bindsXzDecompresser(final XZDecompresser decompresser);
@Binds
@VideoConverterSetup
abstract SetupManager bindsFFmpegSetupManager(final FFmpegSetupManager manager);
@Provides
@Singleton
static FFmpegSetupConfiguration providesFFmpegSetupConfiguration(@FFBundledRoot final Path root, @FFmpegBundledPath final Path bundledPath, @FFmpegSystemPath final Path systemPath,
@FFmpegInstallerPath final Path ffmpegInstallerPath, @FFProbeInstallerPath final Path ffprobeInstallerPath,
final OS os, final Architecture architecture) {
return new FFmpegSetupConfiguration(root, bundledPath, systemPath, ffmpegInstallerPath, ffprobeInstallerPath, os, architecture);
}
@Provides
@FFmpegInstallerPath
static Path providesFFmpegInstallerPath(@FFBundledRoot final Path root, final OS os) {
return root.resolve("cache").resolve("ffmpeg-installer" + getInstallerExtension(os));
}
@Provides
@FFProbeInstallerPath
static Path providesFFProbeInstallerPath(@FFBundledRoot final Path root, final OS os) {
return root.resolve("cache").resolve("ffprobe-installer" + getInstallerExtension(os));
}
private static String getInstallerExtension(final OS os) {
if (os == OS.LINUX) {
return ".tar.gz";
} else {
return ".zip";
}
}
@Provides
@FFBundledRoot
static Path providesFFBundledRoot() {
return Paths.get("tools", FFMPEG);
}
@Provides
@Singleton
@FFprobeBundledPath
static Path providesFFProbeBundledPath(@FFBundledRoot final Path root, @ExecutableExtension final String extension) {
return root.resolve(FFPROBE + extension);
}
@Provides
@Singleton
@FFprobeSystemPath
static Path providesFFProbeSystemPath(@ExecutableExtension final String extension) {
return Paths.get(FFPROBE + extension);
}
@Provides
@Singleton
@FFmpegBundledPath
static Path providesFFmpegBundledPath(@FFBundledRoot final Path root, @ExecutableExtension final String extension) {
return root.resolve(FFMPEG + extension);
}
@Provides
@Singleton
@FFmpegSystemPath
static Path providesFFmpegSystemPath(@ExecutableExtension final String extension) {
return Paths.get(FFMPEG + extension);
}
@Provides
@Singleton
@FFmpegVersion
static String providesFFmpegVersion() {
return "7.0.1";

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;
import com.github.gtache.autosubtitle.impl.Architecture;
import com.github.gtache.autosubtitle.impl.OS;
import com.github.gtache.autosubtitle.modules.ffmpeg.FFmpegBundledPath;
import com.github.gtache.autosubtitle.modules.ffmpeg.FFmpegSystemPath;
import com.github.gtache.autosubtitle.modules.ffmpeg.FFmpegVersion;
import com.github.gtache.autosubtitle.setup.SetupException;
import com.github.gtache.autosubtitle.setup.SetupManager;
import com.github.gtache.autosubtitle.setup.SetupStatus;
import com.github.gtache.autosubtitle.setup.impl.AbstractSetupManager;
import org.apache.logging.log4j.LogManager;
@@ -13,30 +10,30 @@ import org.apache.logging.log4j.Logger;
import javax.inject.Inject;
import java.io.IOException;
import java.net.http.HttpClient;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
import java.util.Map;
import static com.github.gtache.autosubtitle.impl.Architecture.ARMEL;
import static com.github.gtache.autosubtitle.impl.Architecture.ARMHF;
import static java.util.Objects.requireNonNull;
/**
* Manager managing the FFmpeg installation
* {@link SetupManager} managing the FFmpeg installation
*/
//TODO add gpg/signature check
public class FFmpegSetupManager extends AbstractSetupManager {
private static final Logger logger = LogManager.getLogger(FFmpegSetupManager.class);
private final Path bundledPath;
private final Path systemPath;
private final String version;
private final OS os;
private final Architecture architecture;
private final String executableExtension;
private final FFmpegSetupConfiguration configuration;
private final Map<String, Decompresser> decompressers;
@Inject
FFmpegSetupManager(@FFmpegBundledPath final Path bundledPath, @FFmpegSystemPath final Path systemPath, @FFmpegVersion final String version, final OS os, final Architecture architecture) {
this.bundledPath = Objects.requireNonNull(bundledPath);
this.systemPath = Objects.requireNonNull(systemPath);
this.version = Objects.requireNonNull(version);
this.os = Objects.requireNonNull(os);
this.architecture = Objects.requireNonNull(architecture);
this.executableExtension = os == OS.WINDOWS ? ".exe" : "";
FFmpegSetupManager(final FFmpegSetupConfiguration configuration, final Map<String, Decompresser> decompressers,
final HttpClient httpClient) {
super(httpClient);
this.configuration = requireNonNull(configuration);
this.decompressers = Map.copyOf(decompressers);
}
@Override
@@ -61,12 +58,128 @@ public class FFmpegSetupManager extends AbstractSetupManager {
@Override
public void install() throws SetupException {
switch (configuration.os()) {
case WINDOWS -> installWindows();
case LINUX -> installLinux();
case MAC -> installMac();
}
}
private void installWindows() throws SetupException {
final var url = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip"; //.sha256
downloadFFmpeg(url);
decompressFFmpeg();
}
private void downloadFFmpeg(final String url) throws SetupException {
download(url, configuration.ffmpegInstallerPath());
}
private void downloadFFProbe(final String url) throws SetupException {
download(url, configuration.ffprobeInstallerPath());
}
private void installLinux() throws SetupException {
final var url = getLinuxUrl();
downloadFFmpeg(url);
decompressFFmpegLinux();
}
private void decompressFFmpegLinux() throws SetupException {
try {
final var tmp = Files.createTempFile("ffmpeg", ".tar");
decompress(configuration.ffmpegInstallerPath(), tmp);
decompress(tmp, configuration.root());
} catch (final IOException e) {
throw new SetupException(e);
}
}
private String getLinuxUrl() throws SetupException {
final var architecture = configuration.architecture();
if (architecture.isAMD64()) {
return "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz"; // .md5
} else if (architecture.isARM64()) {
return "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz";
} else if (architecture == Architecture.I686) {
return "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-i686-static.tar.xz";
} else if (architecture == ARMHF) {
return "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-armhf-static.tar.xz";
} else if (architecture == ARMEL) {
return "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-armel-static.tar.xz";
} else {
throwUnsupportedOsArchitectureException();
return null;
}
}
private void throwUnsupportedOsArchitectureException() throws SetupException {
throw new SetupException("Unsupported os - architecture : " + configuration.os() + " - " + configuration.architecture());
}
private void installMac() throws SetupException {
installFFmpegMac();
installFFprobeMac();
}
private void installFFmpegMac() throws SetupException {
final var url = getMacFFmpegUrl();
downloadFFmpeg(url);
decompress(configuration.ffmpegInstallerPath(), configuration.root());
}
private void decompress(final Path from, final Path to) throws SetupException {
try {
final var filename = from.getFileName().toString();
final var extension = filename.substring(filename.lastIndexOf('.') + 1);
decompressers.get(extension).decompress(from, to);
} catch (final IOException e) {
throw new SetupException(e);
}
}
private void decompressFFmpeg() throws SetupException {
decompress(configuration.ffmpegInstallerPath(), configuration.root());
}
private void decompressFFProbe() throws SetupException {
decompress(configuration.ffprobeInstallerPath(), configuration.root());
}
private void installFFprobeMac() throws SetupException {
final var url = getMacFFprobeUrl();
downloadFFProbe(url);
decompressFFProbe();
}
private String getMacFFmpegUrl() throws SetupException {
final var architecture = configuration.architecture();
if (architecture.isAMD64()) {
return "https://evermeet.cx/ffmpeg/getrelease/ffmpeg/zip"; // /sig
} else if (architecture.isARM64()) {
return "https://www.osxexperts.net/ffmpeg7arm.zip"; //no automatic sha?
} else {
throwUnsupportedOsArchitectureException();
return null;
}
}
private String getMacFFprobeUrl() throws SetupException {
final var architecture = configuration.architecture();
if (architecture.isAMD64()) {
return "https://evermeet.cx/ffmpeg/getrelease/ffprobe/zip";
} else if (architecture.isARM64()) {
return "https://www.osxexperts.net/ffprobe7arm.zip";
} else {
throwUnsupportedOsArchitectureException();
return null;
}
}
@Override
public void uninstall() throws SetupException {
deleteFolder(configuration.root());
}
@Override
@@ -75,11 +188,11 @@ public class FFmpegSetupManager extends AbstractSetupManager {
}
private boolean checkSystemFFmpeg() throws IOException {
final var result = run(systemPath.toString(), "-version");
final var result = run(configuration.systemFFmpegPath().toString(), "-version");
return result.exitCode() == 0;
}
private boolean checkBundledFFmpeg() throws IOException {
return Files.isRegularFile(bundledPath);
return Files.isRegularFile(configuration.bundledFFmpegPath());
}
}

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 dagger;
requires transitive javax.inject;
requires java.net.http;
requires org.apache.logging.log4j;
requires org.tukaani.xz;
requires org.apache.commons.compress;
exports com.github.gtache.autosubtitle.ffmpeg;
exports com.github.gtache.autosubtitle.setup.ffmpeg;
exports com.github.gtache.autosubtitle.modules.ffmpeg;
exports com.github.gtache.autosubtitle.modules.setup.ffmpeg;
}

View File

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

View File

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

View File

@@ -1,14 +1,16 @@
package com.github.gtache.autosubtitle.gui.fx;
import com.github.gtache.autosubtitle.File;
import com.github.gtache.autosubtitle.gui.MediaController;
import com.github.gtache.autosubtitle.gui.TimeFormatter;
import com.github.gtache.autosubtitle.impl.FileVideoImpl;
import com.github.gtache.autosubtitle.modules.gui.impl.Pause;
import com.github.gtache.autosubtitle.modules.gui.impl.Play;
import com.github.gtache.autosubtitle.subtitle.EditableSubtitle;
import com.github.gtache.autosubtitle.subtitle.Subtitle;
import com.github.gtache.autosubtitle.subtitle.gui.fx.SubtitleLabel;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.collections.ListChangeListener;
import javafx.fxml.FXML;
import javafx.geometry.Insets;
import javafx.scene.Cursor;
@@ -31,6 +33,11 @@ import org.apache.logging.log4j.Logger;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import static java.util.Objects.requireNonNull;
@@ -62,6 +69,8 @@ public class FXMediaController implements MediaController {
private final Image playImage;
private final Image pauseImage;
private final List<Long> startTimes;
private boolean wasPlaying;
@Inject
@@ -72,6 +81,7 @@ public class FXMediaController implements MediaController {
this.timeFormatter = requireNonNull(timeFormatter);
this.playImage = requireNonNull(playImage);
this.pauseImage = requireNonNull(pauseImage);
this.startTimes = new ArrayList<>();
}
@FXML
@@ -101,50 +111,78 @@ public class FXMediaController implements MediaController {
if (videoView.getMediaPlayer() != null) {
videoView.getMediaPlayer().dispose();
}
if (newValue instanceof final FileVideoImpl fileVideo) {
final var media = new Media(fileVideo.path().toUri().toString());
final var player = new MediaPlayer(media);
player.currentTimeProperty().addListener((ignored, oldTime, newTime) -> {
final var millis = newTime.toMillis();
playSlider.setValue(millis);
stackPane.getChildren().removeIf(Label.class::isInstance);
model.subtitles().forEach(s -> {
//TODO optimize using e.g. direction of playback
if (s.start() <= millis && s.end() >= millis) {
logger.info("Adding label {} at {}", s, millis);
final var label = createDraggableLabel(s);
stackPane.getChildren().add(label);
}
});
});
playSlider.setOnMousePressed(e -> {
wasPlaying = model.isPlaying();
model.setIsPlaying(false);
});
playSlider.valueProperty().addListener(observable1 -> {
if (playSlider.isValueChanging()) {
seek((long) playSlider.getValue());
}
});
playSlider.setOnMouseReleased(e -> {
final var value = playSlider.getValue();
Platform.runLater(() -> {
seek((long) value);
model.setIsPlaying(wasPlaying);
});
});
player.volumeProperty().bindBidirectional(model.volumeProperty());
player.setOnPlaying(() -> model.setIsPlaying(true));
player.setOnPaused(() -> model.setIsPlaying(false));
player.setOnEndOfMedia(() -> model.setIsPlaying(false));
playSlider.setMax(model.duration());
playSlider.setValue(0L);
videoView.setMediaPlayer(player);
if (newValue instanceof final File file) {
loadFileVideo(file.path());
} else {
logger.error("Unsupported video type : {}", newValue);
}
});
model.subtitles().addListener((ListChangeListener<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.graphicProperty().bind(Bindings.createObjectBinding(() -> {
final ImageView view;
@@ -158,7 +196,6 @@ public class FXMediaController implements MediaController {
view.setFitHeight(24);
return view;
}, model.isPlayingProperty()));
binder.createBindings();
}
@FXML

View File

@@ -6,10 +6,10 @@ import com.github.gtache.autosubtitle.subtitle.EditableSubtitle;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.util.List;
/**
* FX implementation of {@link com.github.gtache.autosubtitle.gui.MediaModel}
@@ -22,7 +22,7 @@ public class FXMediaModel implements MediaModel {
private final BooleanProperty isPlaying;
private final ReadOnlyLongWrapper duration;
private final LongProperty position;
private final List<EditableSubtitle> subtitles;
private final ObservableList<EditableSubtitle> subtitles;
@Inject
FXMediaModel() {
@@ -103,7 +103,7 @@ public class FXMediaModel implements MediaModel {
}
@Override
public List<EditableSubtitle> subtitles() {
public ObservableList<EditableSubtitle> 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 String ALL_SUPPORTED = "All supported";
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",
"viv", "asf", "amv", "mp4", "m4p", "m4v", "mpg", "mp2", "mpeg", "mpe", "mpv", "m2v", "m4v", "svi",
@@ -131,21 +132,42 @@ public class FXWorkController extends AbstractFXController implements WorkContro
@FXML
private void initialize() {
bindComboboxes();
bindButtons();
bindTable();
bindProgress();
model.selectedSubtitleProperty().addListener((observable, oldValue, newValue) -> {
if (newValue != null) {
mediaController.seek(newValue.start());
}
});
binder.createBindings();
subtitleExtractor.addListener(this);
}
private void bindComboboxes() {
languageCombobox.valueProperty().bindBidirectional(model.videoLanguageProperty());
languageCombobox.setItems(model.availableVideoLanguages());
languageCombobox.setConverter(new LanguageStringConverter());
translationsCombobox.setConverter(new LanguageStringConverter());
Bindings.bindContent(translationsCombobox.getItems(), model.availableTranslationsLanguage());
Bindings.bindContent(model.translations(), translationsCombobox.getCheckModel().getCheckedItems());
}
private void bindButtons() {
extractButton.disableProperty().bind(model.videoProperty().isNull().or(model.statusProperty().isNotEqualTo(WorkStatus.IDLE)));
addSubtitleButton.disableProperty().bind(model.videoProperty().isNull().or(model.statusProperty().isNotEqualTo(WorkStatus.IDLE)));
loadSubtitlesButton.disableProperty().bind(model.videoProperty().isNull().or(model.statusProperty().isNotEqualTo(WorkStatus.IDLE)));
resetButton.disableProperty().bind(Bindings.isEmpty(model.subtitles()).or(model.statusProperty().isNotEqualTo(WorkStatus.IDLE)));
exportSoftButton.disableProperty().bind(Bindings.isEmpty(model.subtitles()).or(model.statusProperty().isNotEqualTo(WorkStatus.IDLE)));
exportHardButton.disableProperty().bind(Bindings.isEmpty(model.subtitles()).or(model.statusProperty().isNotEqualTo(WorkStatus.IDLE)));
addSubtitleButton.disableProperty().bind(Bindings.isEmpty(model.subtitles()).or(model.statusProperty().isNotEqualTo(WorkStatus.IDLE)));
loadSubtitlesButton.disableProperty().bind(Bindings.isEmpty(model.subtitles()).or(model.statusProperty().isNotEqualTo(WorkStatus.IDLE)));
saveSubtitlesButton.disableProperty().bind(Bindings.isEmpty(model.subtitles()).or(model.statusProperty().isNotEqualTo(WorkStatus.IDLE)));
}
private void bindTable() {
subtitlesTable.setItems(model.subtitles());
subtitlesTable.setOnKeyPressed(e -> {
if (e.getCode().isLetterKey() || e.getCode().isDigitKey()) {
@@ -157,6 +179,9 @@ public class FXWorkController extends AbstractFXController implements WorkContro
} else if (e.getCode() == KeyCode.LEFT) {
subtitlesTable.getSelectionModel().selectPrevious();
e.consume();
} else if (e.getCode() == KeyCode.DELETE) {
deleteSelectedSubtitles();
e.consume();
}
});
startColumn.setCellFactory(TextFieldTableCell.forTableColumn(new TimeStringConverter(timeFormatter)));
@@ -185,11 +210,9 @@ public class FXWorkController extends AbstractFXController implements WorkContro
});
subtitlesTable.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, newValue) -> model.selectedSubtitleProperty().set(newValue));
model.selectedSubtitleProperty().addListener((observable, oldValue, newValue) -> {
if (newValue != null) {
mediaController.seek(newValue.start());
}
});
}
private void bindProgress() {
progressLabel.textProperty().bind(Bindings.createStringBinding(() -> resources.getString("work.status." + model.status().name().toLowerCase() + ".label"), model.statusProperty()));
progressLabel.visibleProperty().bind(model.statusProperty().isNotEqualTo(WorkStatus.IDLE));
progressBar.visibleProperty().bind(model.statusProperty().isNotEqualTo(WorkStatus.IDLE));
@@ -198,9 +221,10 @@ public class FXWorkController extends AbstractFXController implements WorkContro
progressBar.managedProperty().bind(model.statusProperty().isNotEqualTo(WorkStatus.IDLE));
progressDetailLabel.managedProperty().bind(model.statusProperty().isNotEqualTo(WorkStatus.IDLE));
progressBar.progressProperty().bindBidirectional(model.progressProperty());
binder.createBindings();
}
subtitleExtractor.addListener(this);
private void deleteSelectedSubtitles() {
model.subtitles().removeAll(subtitlesTable.getSelectionModel().getSelectedItems());
}
private void editFocusedCell() {
@@ -213,7 +237,7 @@ public class FXWorkController extends AbstractFXController implements WorkContro
@FXML
private void fileButtonPressed() {
final var filePicker = new FileChooser();
final var extensionFilter = new FileChooser.ExtensionFilter("All supported", VIDEO_EXTENSIONS);
final var extensionFilter = new FileChooser.ExtensionFilter(ALL_SUPPORTED, VIDEO_EXTENSIONS);
filePicker.getExtensionFilters().add(extensionFilter);
filePicker.setSelectedExtensionFilter(extensionFilter);
final var file = filePicker.showOpenDialog(window());
@@ -303,6 +327,9 @@ public class FXWorkController extends AbstractFXController implements WorkContro
@FXML
private void exportSoftPressed() {
final var filePicker = new FileChooser();
final var extensionFilter = new FileChooser.ExtensionFilter(ALL_SUPPORTED, VIDEO_EXTENSIONS);
filePicker.getExtensionFilters().add(extensionFilter);
filePicker.setSelectedExtensionFilter(extensionFilter);
final var file = filePicker.showSaveDialog(window());
if (file != null) {
final var baseCollection = model.subtitleCollection();
@@ -315,7 +342,7 @@ public class FXWorkController extends AbstractFXController implements WorkContro
}, Platform::runLater)
.thenAcceptAsync(collections -> {
try {
videoConverter.addSoftSubtitles(model.video(), collections);
videoConverter.addSoftSubtitles(model.video(), collections, file.toPath());
} catch (final IOException e) {
throw new CompletionException(e);
}
@@ -332,11 +359,14 @@ public class FXWorkController extends AbstractFXController implements WorkContro
@FXML
private void exportHardPressed() {
final var filePicker = new FileChooser();
final var extensionFilter = new FileChooser.ExtensionFilter(ALL_SUPPORTED, VIDEO_EXTENSIONS);
filePicker.getExtensionFilters().add(extensionFilter);
filePicker.setSelectedExtensionFilter(extensionFilter);
final var file = filePicker.showSaveDialog(window());
if (file != null) {
CompletableFuture.runAsync(() -> {
try {
videoConverter.addHardSubtitles(model.video(), model.subtitleCollection());
videoConverter.addHardSubtitles(model.video(), model.subtitleCollection(), file.toPath());
} catch (final IOException e) {
throw new CompletionException(e);
}
@@ -386,7 +416,7 @@ public class FXWorkController extends AbstractFXController implements WorkContro
@FXML
private void loadSubtitlesPressed() {
final var filePicker = new FileChooser();
final var extensionFilter = new FileChooser.ExtensionFilter("All supported", subtitleExtensions);
final var extensionFilter = new FileChooser.ExtensionFilter(ALL_SUPPORTED, subtitleExtensions);
filePicker.getExtensionFilters().add(extensionFilter);
filePicker.setSelectedExtensionFilter(extensionFilter);
final var file = filePicker.showOpenDialog(window());
@@ -396,7 +426,7 @@ public class FXWorkController extends AbstractFXController implements WorkContro
@FXML
private void saveSubtitlesPressed() {
final var filePicker = new FileChooser();
final var extensionFilter = new FileChooser.ExtensionFilter("All supported", subtitleExtensions);
final var extensionFilter = new FileChooser.ExtensionFilter(ALL_SUPPORTED, subtitleExtensions);
filePicker.getExtensionFilters().add(extensionFilter);
filePicker.setSelectedExtensionFilter(extensionFilter);
final var file = filePicker.showSaveDialog(window());

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
/**
* FX module for auto-subtitle
*/
module com.github.gtache.autosubtitle.fx {
module com.github.gtache.autosubtitle.gui.fx {
requires transitive com.github.gtache.autosubtitle.core;
requires transitive com.github.gtache.autosubtitle.gui.core;
requires transitive javafx.controls;

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,10 @@ import javax.inject.Singleton;
@Module
public abstract class MissingComponentsModule {
private MissingComponentsModule() {
}
@Provides
@Singleton
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.impl.GuiCoreModule;
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 dagger.Component;
import javafx.fxml.FXMLLoader;
@@ -17,8 +14,8 @@ import javax.inject.Singleton;
* Main component
*/
@Singleton
@Component(modules = {CoreModule.class, GuiCoreModule.class, FXModule.class, FFmpegModule.class, FFmpegSetupModule.class,
SubtitleModule.class, WhisperModule.class, WhisperSetupModule.class, MissingComponentsModule.class})
@Component(modules = {CoreModule.class, GuiCoreModule.class, FXModule.class, FFmpegModule.class,
WhisperModule.class, MissingComponentsModule.class})
public interface RunComponent {
/**

View File

@@ -3,7 +3,7 @@
*/
module com.github.gtache.autosubtitle.run {
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;
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>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<commons.compress.version>1.27.0</commons.compress.version>
<dagger.version>2.51.1</dagger.version>
<log4j.version>2.23.1</log4j.version>
<picocli.version>4.7.6</picocli.version>
<xz.version>1.10</xz.version>
</properties>
<dependencyManagement>
@@ -82,6 +84,11 @@
<artifactId>dagger</artifactId>
<version>${dagger.version}</version>
</dependency>
<dependency>
<groupId>info.picocli</groupId>
<artifactId>picocli</artifactId>
<version>${picocli.version}</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
@@ -93,9 +100,14 @@
<version>${log4j.version}</version>
</dependency>
<dependency>
<groupId>info.picocli</groupId>
<artifactId>picocli</artifactId>
<version>${picocli.version}</version>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
<version>${commons.compress.version}</version>
</dependency>
<dependency>
<groupId>org.tukaani</groupId>
<artifactId>xz</artifactId>
<version>${xz.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

View File

@@ -1,11 +1,11 @@
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.setup.whisper.CondaSetupConfiguration;
import dagger.Module;
import dagger.Provides;
import javax.inject.Singleton;
import java.net.http.HttpClient;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -13,54 +13,54 @@ import java.nio.file.Paths;
* Setup module for Conda
*/
@Module
public abstract class CondaSetupModule {
public final class CondaSetupModule {
private static final String CONDA = "conda";
private static final String MINICONDA3 = "miniconda3";
@Provides
@Singleton
static HttpClient providesHttpClient() {
return HttpClient.newHttpClient();
private CondaSetupModule() {
}
@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
@Singleton
@CondaSystemPath
static Path providesCondaSystemPath(final OS os) {
return Paths.get(os == OS.WINDOWS ? CONDA + ".bat" : CONDA);
}
@Provides
@Singleton
@CondaBundledPath
static Path providesCondaBundledPath(@CondaRootPath final Path root, final OS os) {
return root.resolve("condabin").resolve(Paths.get(os == OS.WINDOWS ? CONDA + ".bat" : CONDA));
}
@Provides
@Singleton
@CondaMinimumMajorVersion
static int providesCondaMinimumMajorVersion() {
return 24;
}
@Provides
@Singleton
@CondaMinimumMinorVersion
static int providesCondaMinimumMinorVersion() {
return 5;
}
@Provides
@Singleton
@CondaRootPath
static Path providesCondaRootPath(@WhisperBundledRoot final Path root, final OS os) {
return root.resolve(MINICONDA3);
}
@Provides
@Singleton
@CondaInstallerPath
static Path providesCondaInstallerPath(@WhisperBundledRoot final Path root, final OS os) {
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.Provides;
import javax.inject.Singleton;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -21,28 +20,24 @@ public abstract class WhisperSetupModule {
abstract SetupManager bindsSubtitleExtractorSetupManager(final WhisperSetupManager manager);
@Provides
@Singleton
@PythonVersion
static String providesPythonVersion() {
return "3.9.19";
}
@Provides
@Singleton
@WhisperVersion
static String providesWhisperVersion() {
return "20231117";
}
@Provides
@Singleton
@WhisperBundledRoot
static Path providesWhisperBundledRoot() {
return Paths.get("tools", "whisper");
}
@Provides
@Singleton
@WhisperVenvPath
static Path providesWhisperVenvPath(@WhisperBundledRoot final Path root) {
return root.resolve("whisper-env");

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
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.parser.json.whisper.WhisperJsonModule;
import com.github.gtache.autosubtitle.subtitle.extractor.ExtractionModelProvider;
@@ -10,9 +11,13 @@ import dagger.Module;
/**
* Dagger module for Whisper
*/
@Module(includes = {WhisperJsonModule.class, WhisperExtractorModule.class})
@Module(includes = {WhisperSetupModule.class, WhisperJsonModule.class, WhisperExtractorModule.class})
public abstract class WhisperModule {
private WhisperModule() {
}
@Binds
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;
import com.github.gtache.autosubtitle.impl.Architecture;
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.SetupStatus;
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.Singleton;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
@@ -36,30 +26,12 @@ public class CondaSetupManager extends AbstractSetupManager {
private static final Logger logger = LogManager.getLogger(CondaSetupManager.class);
private final Path condaSystemPath;
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;
private final CondaSetupConfiguration configuration;
@Inject
CondaSetupManager(@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, 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);
CondaSetupManager(final CondaSetupConfiguration configuration, final HttpClient httpClient) {
super(httpClient);
this.configuration = requireNonNull(configuration);
}
@Override
@@ -86,14 +58,15 @@ public class CondaSetupManager extends AbstractSetupManager {
}
private void installConda() throws SetupException {
if (Files.exists(condaInstallerPath)) {
logger.info("Conda exists at {}", condaInstallerPath);
final var installerPath = configuration.condaInstallerPath();
if (Files.exists(installerPath)) {
logger.info("Conda exists at {}", installerPath);
} else {
logger.info("Conda installer not found, downloading");
downloadConda();
logger.info("Conda downloaded");
}
switch (os) {
switch (configuration.os()) {
case WINDOWS -> installWindows();
case MAC, LINUX -> installLinux();
}
@@ -101,10 +74,12 @@ public class CondaSetupManager extends AbstractSetupManager {
private void installLinux() throws SetupException {
try {
logger.info("Installing conda using {}", condaInstallerPath);
final var result = run("bash", condaInstallerPath.toString(), "-b", "-p", condaRootPath.toString());
final var installerPath = configuration.condaInstallerPath();
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) {
logger.info("Installed conda to {}", condaRootPath);
logger.info("Installed conda to {}", rootPath);
} else {
throw new SetupException("Error installing conda: " + result);
}
@@ -115,10 +90,12 @@ public class CondaSetupManager extends AbstractSetupManager {
private void installWindows() throws SetupException {
try {
logger.info("Installing conda using {}", condaInstallerPath);
final var result = run(condaInstallerPath.toString(), "/InstallationType=JustMe", "/RegisterPython=0", "/S", "/D=" + condaRootPath.toString());
final var installerPath = configuration.condaInstallerPath();
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) {
logger.info("Installed conda to {}", condaRootPath);
logger.info("Installed conda to {}", rootPath);
} else {
throw new SetupException("Error installing conda: " + result);
}
@@ -128,15 +105,16 @@ public class CondaSetupManager extends AbstractSetupManager {
}
private void downloadConda() throws SetupException {
switch (os) {
switch (configuration.os()) {
case WINDOWS -> downloadCondaWindows();
case MAC -> downloadCondaMac();
case LINUX -> downloadCondaLinux();
}
logger.info("Downloaded conda to {}", condaInstallerPath);
logger.info("Downloaded conda to {}", configuration.condaInstallerPath());
}
private void downloadCondaLinux() throws SetupException {
final var architecture = configuration.architecture();
if (architecture.isAMD64()) {
downloadConda("https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh");
} else if (architecture.isARM64()) {
@@ -147,6 +125,7 @@ public class CondaSetupManager extends AbstractSetupManager {
}
private void downloadCondaMac() throws SetupException {
final var architecture = configuration.architecture();
if (architecture.isAMD64()) {
downloadConda("https://repo.anaconda.com/miniconda/Miniconda3-latest-MacOSX-x86_64.sh");
} else if (architecture.isARM64()) {
@@ -162,25 +141,12 @@ public class CondaSetupManager extends AbstractSetupManager {
}
private void downloadConda(final String url) throws SetupException {
final var request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
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);
}
download(url, configuration.condaInstallerPath());
}
@Override
public void uninstall() throws SetupException {
deleteFolder(condaRootPath);
deleteFolder(configuration.condaRootPath());
}
@Override
@@ -208,7 +174,7 @@ public class CondaSetupManager extends AbstractSetupManager {
throw new SetupException("Error creating venv " + path + ": " + result.output());
}
// 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");
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();
@@ -225,16 +191,16 @@ public class CondaSetupManager extends AbstractSetupManager {
}
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 {
return isSystemCondaInstalled() ? condaSystemPath : condaBundledPath;
return isSystemCondaInstalled() ? configuration.condaSystemPath() : configuration.condaBundledPath();
}
private boolean isSystemCondaInstalled() throws SetupException {
try {
final var result = run(condaSystemPath.toString(), "--version");
final var result = run(configuration.condaSystemPath().toString(), "--version");
if (result.exitCode() == 0) {
final var output = result.output().getFirst();
final var versionString = output.substring(output.indexOf(' ') + 1);
@@ -242,7 +208,7 @@ public class CondaSetupManager extends AbstractSetupManager {
if (version.length == 3) {
final var major = Integer.parseInt(version[0]);
final var minor = Integer.parseInt(version[1]);
return major >= condaMinimumMajorVersion && minor >= condaMinimumMinorVersion;
return major >= configuration.condaMinimumMajorVersion() && minor >= configuration.condaMinimumMinorVersion();
} else {
throw new SetupException("Unexpected python version: " + versionString);
}
@@ -255,6 +221,6 @@ public class CondaSetupManager extends AbstractSetupManager {
}
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;
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.SetupException;
import com.github.gtache.autosubtitle.setup.SetupStatus;
@@ -20,28 +16,23 @@ import java.nio.file.Path;
import static java.util.Objects.requireNonNull;
//TODO add gpg/signature check
/**
* Setup manager for Whisper
*/
@Singleton
public class WhisperSetupManager extends AbstractSetupManager {
private static final Logger logger = LogManager.getLogger(WhisperSetupManager.class);
private static final String CONDA_ENV = "conda-env";
private final CondaSetupManager condaSetupManager;
private final String pythonVersion;
private final Path whisperRoot;
private final Path venvPath;
private final OS os;
private final String whisperVersion;
private final WhisperSetupConfiguration configuration;
@Inject
WhisperSetupManager(final CondaSetupManager condaSetupManager, @PythonVersion final String pythonVersion,
@WhisperBundledRoot final Path whisperRoot, @WhisperVenvPath final Path venvPath, final OS os,
@WhisperVersion final String whisperVersion) {
WhisperSetupManager(final CondaSetupManager condaSetupManager, final WhisperSetupConfiguration configuration) {
this.condaSetupManager = requireNonNull(condaSetupManager);
this.pythonVersion = requireNonNull(pythonVersion);
this.whisperRoot = requireNonNull(whisperRoot);
this.venvPath = requireNonNull(venvPath);
this.os = requireNonNull(os);
this.whisperVersion = requireNonNull(whisperVersion);
this.configuration = requireNonNull(configuration);
}
@Override
@@ -83,12 +74,12 @@ public class WhisperSetupManager extends AbstractSetupManager {
private void checkInstallVenv() throws SetupException {
sendStartEvent(SetupAction.CHECK, CONDA_ENV, 0.33);
if (condaSetupManager.venvExists(venvPath)) {
if (condaSetupManager.venvExists(configuration.venvPath())) {
sendEndEvent(SetupAction.CHECK, CONDA_ENV, 0.66);
} else {
sendEndEvent(SetupAction.CHECK, 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);
}
}
@@ -107,7 +98,7 @@ public class WhisperSetupManager extends AbstractSetupManager {
@Override
public void uninstall() throws SetupException {
deleteFolder(whisperRoot);
deleteFolder(configuration.root());
}
@Override
@@ -124,19 +115,21 @@ public class WhisperSetupManager extends AbstractSetupManager {
}
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 {
final var path = getPythonPath();
try {
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) {
logger.info("Whisper installed");
} else {
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) {
throw new SetupException(e);
}

View File

@@ -41,7 +41,10 @@ import static java.util.Objects.requireNonNull;
public class WhisperSubtitleExtractor extends AbstractProcessRunner implements SubtitleExtractor {
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 TQDM_PROGRESS_PATTERN = Pattern.compile("^(?<progress>\\d{1,3})%|.+");
private final Path venvPath;
private final SubtitleConverter converter;
private final OS os;
@@ -80,7 +83,7 @@ public class WhisperSubtitleExtractor extends AbstractProcessRunner implements S
return extract(f.path(), language, model, video.info().duration());
} else {
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()) {
Files.copy(in, path);
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());
} else {
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()) {
Files.copy(in, path);
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 {
try {
final var outputDir = Files.createTempDirectory("autosubtitle");
final var outputDir = Files.createTempDirectory(AUTOSUBTITLE);
final var args = createArgs(path, language, model, outputDir);
final var processListener = startListen(args);
var line = processListener.readLine();
var oldProgress = -1.0;
var line = processListener.readLine();
while (line != null) {
logger.info("Whisper output : {}", line);
logger.info("[whisper]: {}", line);
final var newProgress = computeProgress(line, duration, oldProgress);
notifyListeners(new ExtractEventImpl(line, newProgress));
oldProgress = newProgress;
@@ -131,11 +134,7 @@ public class WhisperSubtitleExtractor extends AbstractProcessRunner implements S
final var filename = path.getFileName().toString();
final var subtitleFilename = filename.substring(0, filename.lastIndexOf('.')) + ".json";
final var subtitleFile = outputDir.resolve(subtitleFilename);
try {
return converter.parse(subtitleFile);
} catch (final ParseException e) {
throw new ExtractException(e);
}
return parseResult(subtitleFile);
} else {
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) {
final var matcher = LINE_PROGRESS_PATTERN.matcher(line);
if (matcher.find()) {
@@ -152,6 +159,11 @@ public class WhisperSubtitleExtractor extends AbstractProcessRunner implements S
final var millis = Integer.parseInt(matcher.group("millis"));
return ((minutes * 60L + seconds) * 1000 + millis) / (double) duration;
} 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;
}
}
@@ -161,6 +173,8 @@ public class WhisperSubtitleExtractor extends AbstractProcessRunner implements S
args.add(getPythonPath().toString());
args.add("-m");
args.add("whisper");
args.add("--verbose");
args.add("False");
args.add("--model");
if (model != WhisperModels.LARGE && language == Language.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);
}).sorted(Comparator.comparing(Subtitle::start).thenComparing(Subtitle::end)).toList();
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)) {
logger.warn("Not same text: {}\n\n{}", json.text(), subtitlesText);
}