diff --git a/build.gradle b/build.gradle
index 399fa767..05e2b53c 100644
--- a/build.gradle
+++ b/build.gradle
@@ -67,6 +67,7 @@ test {
javaLauncher = providers.provider { new JlinkJavaLauncher(jlink.get()) }
useJUnitPlatform()
+ jvmArgs('--enable-final-field-mutation=ALL-UNNAMED')
testLogging {
events('started', 'passed', 'failed', 'skipped')
@@ -86,7 +87,7 @@ installDist.mustRunAfter(generateCohArchive)
application {
mainClass = 'com.github.stickerifier.stickerify.runner.Main'
- applicationDefaultJvmArgs = ['-XX:+UseCompactObjectHeaders', '-XX:+UseShenandoahGC', '-XX:ShenandoahGCMode=generational']
+ applicationDefaultJvmArgs = ['-XX:+UseCompactObjectHeaders', '-XX:+UseShenandoahGC', '-XX:ShenandoahGCMode=generational', '--enable-final-field-mutation=ALL-UNNAMED']
}
distributions {
diff --git a/src/main/java/com/github/stickerifier/stickerify/media/MediaHelper.java b/src/main/java/com/github/stickerifier/stickerify/media/MediaHelper.java
index 5ba78ee7..4e5dc3b3 100644
--- a/src/main/java/com/github/stickerifier/stickerify/media/MediaHelper.java
+++ b/src/main/java/com/github/stickerifier/stickerify/media/MediaHelper.java
@@ -33,6 +33,7 @@
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.file.Files;
+import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.zip.GZIPInputStream;
@@ -183,7 +184,7 @@ private static boolean isVideoCompliant(File file) throws MediaException, Interr
* @throws InterruptedException if the current thread is interrupted while retrieving file info
*/
static MultimediaInfo retrieveMultimediaInfo(File file) throws MediaException, InterruptedException {
- var command = new String[] {
+ var command = List.of(
"ffprobe",
"-hide_banner",
"-v", "error",
@@ -191,7 +192,7 @@ static MultimediaInfo retrieveMultimediaInfo(File file) throws MediaException, I
"-show_format",
"-show_streams",
file.getAbsolutePath()
- };
+ );
try {
var output = ProcessHelper.executeCommand(command);
@@ -386,7 +387,7 @@ && isSizeCompliant(imageInfo.width(), imageInfo.height())
*/
private static File convertToWebp(File file) throws MediaException, InterruptedException {
var webpImage = createTempFile("webp");
- var command = new String[] {
+ var command = List.of(
"ffmpeg",
"-y",
"-hide_banner",
@@ -397,7 +398,7 @@ private static File convertToWebp(File file) throws MediaException, InterruptedE
"-lossless", "1",
"-compression_level", "6",
webpImage.getAbsolutePath()
- };
+ );
try {
ProcessHelper.executeCommand(command);
@@ -466,9 +467,47 @@ private static void deleteFile(File file) throws FileOperationException {
* @throws InterruptedException if the current thread is interrupted while converting the video file
*/
private static File convertToWebm(File file) throws MediaException, InterruptedException {
+ var optimisticBitrate = List.of(
+ "-b:v", "650K",
+ "-maxrate", "650K",
+ "-bufsize", "1300K"
+ );
+ var fallbackBitrate = List.of(
+ "-b:v", "250K",
+ "-maxrate", "250K",
+ "-bufsize", "125K",
+ "-qmin", "25"
+ );
+
+ var webmVideo = convertVideoWithBitrate(file, optimisticBitrate);
+ if (webmVideo.exists() && webmVideo.length() <= MAX_VIDEO_FILE_SIZE) {
+ return webmVideo;
+ }
+
+ LOGGER.at(Level.WARN).log("Resulting file was too large (actual size was {} bytes), retrying with lower bitrate", webmVideo.length());
+ try {
+ deleteFile(webmVideo);
+ } catch (FileOperationException e) {
+ LOGGER.at(Level.WARN).setCause(e).log("Could not delete file");
+ }
+
+ return convertVideoWithBitrate(file, fallbackBitrate);
+ }
+
+ /**
+ * Given a video file and a set of bitrate rules, it converts it to a WebM file of the proper dimension (max 512 x 512),
+ * based on the requirements specified by Telegram documentation.
+ *
+ * @param file the file to convert
+ * @param bitrateCommands the list of bitrate commands to add to the conversion
+ * @return converted video
+ * @throws MediaException if file conversion is not successful
+ * @throws InterruptedException if the current thread is interrupted while converting the video file
+ */
+ private static File convertVideoWithBitrate(File file, List bitrateCommands) throws MediaException, InterruptedException {
var webmVideo = createTempFile("webm");
var logPrefix = webmVideo.getAbsolutePath() + "-passlog";
- var baseCommand = new String[] {
+ var baseCommand = List.of(
"ffmpeg",
"-y",
"-hide_banner",
@@ -476,16 +515,32 @@ private static File convertToWebm(File file) throws MediaException, InterruptedE
"-i", file.getAbsolutePath(),
"-vf", "scale='if(gt(iw,ih),%1$d,%2$d)':'if(gt(iw,ih),%2$d,%1$d)',fps='min(%3$d,source_fps)'".formatted(MAX_SIDE_LENGTH, VIDEO_KEEP_ASPECT_RATIO, MAX_VIDEO_FRAMES),
"-c:v", "libvpx-" + VP9_CODEC,
- "-b:v", "650K",
+ "-row-mt", "1",
+ "-threads", "2",
+ "-qmax", "63",
+ "-g", "120",
+ "-auto-alt-ref", "0",
"-pix_fmt", "yuv420p",
"-t", String.valueOf(MAX_VIDEO_DURATION_SECONDS),
"-an",
+ "-enc_time_base", "1/1000",
"-passlogfile", logPrefix
- };
+ );
+ var firstPass = List.of(
+ "-cpu-used", "8",
+ "-pass", "1",
+ "-f", "webm",
+ OsConstants.NULL_FILE
+ );
+ var secondPass = List.of(
+ "-cpu-used", "4",
+ "-pass", "2",
+ webmVideo.getAbsolutePath()
+ );
try {
- ProcessHelper.executeCommand(buildFfmpegCommand(baseCommand, "-pass", "1", "-f", "webm", OsConstants.NULL_FILE));
- ProcessHelper.executeCommand(buildFfmpegCommand(baseCommand, "-pass", "2", webmVideo.getAbsolutePath()));
+ ProcessHelper.executeCommand(buildFfmpegCommand(baseCommand, bitrateCommands, firstPass));
+ ProcessHelper.executeCommand(buildFfmpegCommand(baseCommand, bitrateCommands, secondPass));
} catch (ProcessException e) {
try {
deleteFile(webmVideo);
@@ -505,18 +560,19 @@ private static File convertToWebm(File file) throws MediaException, InterruptedE
}
/**
- * Builds the ffmpeg command combining a base part with a specific part (useful for 2 pass processing)
+ * Builds the ffmpeg command combining multiple parts (useful for 2 pass processing)
*
- * @param baseCommand the common ffmpeg command
- * @param additionalOptions command specific options
+ * @param commands a series of list containing commands
* @return the complete ffmpeg invocation command
*/
- private static String[] buildFfmpegCommand(String[] baseCommand, String... additionalOptions) {
- var commands = new String[baseCommand.length + additionalOptions.length];
- System.arraycopy(baseCommand, 0, commands, 0, baseCommand.length);
- System.arraycopy(additionalOptions, 0, commands, baseCommand.length, additionalOptions.length);
+ @SafeVarargs
+ private static List buildFfmpegCommand(final List... commands) {
+ var command = new ArrayList();
+ for (List cmd : commands) {
+ command.addAll(cmd);
+ }
- return commands;
+ return command;
}
private MediaHelper() {
diff --git a/src/main/java/com/github/stickerifier/stickerify/process/ProcessHelper.java b/src/main/java/com/github/stickerifier/stickerify/process/ProcessHelper.java
index 5aee46ce..c69ed402 100644
--- a/src/main/java/com/github/stickerifier/stickerify/process/ProcessHelper.java
+++ b/src/main/java/com/github/stickerifier/stickerify/process/ProcessHelper.java
@@ -8,6 +8,7 @@
import java.io.IOException;
import java.io.UncheckedIOException;
+import java.util.List;
import java.util.StringJoiner;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
@@ -33,7 +34,7 @@ public final class ProcessHelper {
*
* @throws InterruptedException if the current thread is interrupted while waiting for the command to finish
*/
- public static String executeCommand(final String... command) throws ProcessException, InterruptedException {
+ public static String executeCommand(final List command) throws ProcessException, InterruptedException {
SEMAPHORE.acquire();
try (var process = new ProcessBuilder(command).start()) {
@@ -55,13 +56,15 @@ public static String executeCommand(final String... command) throws ProcessExcep
}
});
+ var commandName = command.getFirst();
+
var finished = process.waitFor(1, TimeUnit.MINUTES);
if (!finished) {
process.destroyForcibly();
outputThread.join();
errorThread.join();
- LOGGER.at(Level.WARN).log("The command {} timed out after 1m: {}", command[0], standardError.toString());
- throw new ProcessException("The command {} timed out after 1m", command[0]);
+ LOGGER.at(Level.WARN).log("The command {} timed out after 1m: {}", commandName, standardError.toString());
+ throw new ProcessException("The command {} timed out after 1m", commandName);
}
outputThread.join();
@@ -69,8 +72,8 @@ public static String executeCommand(final String... command) throws ProcessExcep
var exitCode = process.exitValue();
if (exitCode != 0) {
- LOGGER.at(Level.WARN).log("The command {} exited with code {}: {}", command[0], exitCode, standardError.toString());
- throw new ProcessException("The command {} exited with code {}", command[0], exitCode);
+ LOGGER.at(Level.WARN).log("The command {} exited with code {}: {}", commandName, exitCode, standardError.toString());
+ throw new ProcessException("The command {} exited with code {}", commandName, exitCode);
}
return standardOutput.toString();
diff --git a/src/main/resources/customUnixStartScript.txt b/src/main/resources/customUnixStartScript.txt
index 06739fe9..6f96e2e7 100644
--- a/src/main/resources/customUnixStartScript.txt
+++ b/src/main/resources/customUnixStartScript.txt
@@ -1,6 +1,6 @@
#!/bin/sh
-# from https://github.com/gradle/gradle/blob/68744f918ea82142a63c2904d346473799814be6/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# from https://github.com/gradle/gradle/blob/41203b0be36cabf983b0ebe9e402e1f37a67b0cc/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
#
# Copyright © 2015 the original authors.
@@ -59,7 +59,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
-# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# https://github.com/gradle/gradle/blob/${gitRef}/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#<% /*
# ... and if you're reading this, this IS the template just mentioned.
@@ -92,7 +92,7 @@
# (Template Engines) for details.
#
# (An example invocation of this template is from
-# https://github.com/gradle/gradle/blob/HEAD/subprojects/build-init/src/main/java/org/gradle/api/tasks/wrapper/Wrapper.java
+# https://github.com/gradle/gradle/blob/HEAD/platforms/software/build-init/src/main/java/org/gradle/api/tasks/wrapper/Wrapper.java
# within the Gradle project, which builds "gradlew".)
# */ %>
# You can find Gradle at https://github.com/gradle/gradle/.
diff --git a/src/main/resources/customWindowsStartScript.txt b/src/main/resources/customWindowsStartScript.txt
index 01c7751e..3e1884ee 100644
--- a/src/main/resources/customWindowsStartScript.txt
+++ b/src/main/resources/customWindowsStartScript.txt
@@ -1,4 +1,4 @@
-@rem from https://github.com/gradle/gradle/blob/68744f918ea82142a63c2904d346473799814be6/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/windowsStartScript.txt
+@rem from https://github.com/gradle/gradle/blob/41203b0be36cabf983b0ebe9e402e1f37a67b0cc/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/windowsStartScript.txt
@rem
@rem Copyright 2015 the original author or authors.
@@ -25,8 +25,8 @@
@rem
@rem ##########################################################################
-@rem Set local scope for the variables with windows NT shell
-if "%OS%"=="Windows_NT" setlocal
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.\
@@ -57,7 +57,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
-goto fail
+"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
@@ -71,7 +71,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
-goto fail
+"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@@ -82,21 +82,10 @@ set CLASSPATH=$classpath
<% if ( mainClassName.startsWith('--module ') ) { %>set MODULE_PATH=$modulePath<% } %>
@rem Execute ${applicationName}
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %${optsEnvironmentVar}% <% if ( appNameSystemProperty ) { %>"-D${appNameSystemProperty}=%APP_BASE_NAME%"<% } %><% if ( classpath ) {%> -classpath "%CLASSPATH%"<% } %> <% if ( mainClassName.startsWith('--module ') ) { %>--module-path "%MODULE_PATH%" <% } %>${mainClassName ?: entryPointArgs} %*
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %${optsEnvironmentVar}% <% if ( appNameSystemProperty ) { %>"-D${appNameSystemProperty}=%APP_BASE_NAME%"<% } %><% if ( classpath ) {%> -classpath "%CLASSPATH%"<% } %> <% if ( mainClassName.startsWith('--module ') ) { %>--module-path "%MODULE_PATH%" <% } %>${mainClassName ?: entryPointArgs} %* & call :exitWithErrorLevel
-:end
-@rem End local scope for the variables with windows NT shell
-if %ERRORLEVEL% equ 0 goto mainEnd
-
-:fail
-rem Set variable ${exitEnvironmentVar} if you need the _script_ return code instead of
-rem the _cmd.exe /c_ return code!
-set EXIT_CODE=%ERRORLEVEL%
-if %EXIT_CODE% equ 0 set EXIT_CODE=1
-if not ""=="%${exitEnvironmentVar}%" exit %EXIT_CODE%
-exit /b %EXIT_CODE%
-
-:mainEnd
-if "%OS%"=="Windows_NT" endlocal
-
-:omega
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/src/test/java/com/github/stickerifier/stickerify/media/MediaHelperTest.java b/src/test/java/com/github/stickerifier/stickerify/media/MediaHelperTest.java
index ebc53930..f625bd86 100644
--- a/src/test/java/com/github/stickerifier/stickerify/media/MediaHelperTest.java
+++ b/src/test/java/com/github/stickerifier/stickerify/media/MediaHelperTest.java
@@ -29,6 +29,7 @@
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import java.io.File;
+import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.IntStream;
@@ -143,7 +144,7 @@ void resizeSvgImage() throws Exception {
}
private static void assumeSvgSupport() throws Exception {
- var decoders = ProcessHelper.executeCommand("ffmpeg", "-v", "quiet", "-hide_banner", "-decoders");
+ var decoders = ProcessHelper.executeCommand(List.of("ffmpeg", "-v", "quiet", "-hide_banner", "-decoders"));
var supportsSvg = decoders.contains("(codec svg)");
assumeTrue(supportsSvg, "FFmpeg was not compiled with SVG support");
}
@@ -176,7 +177,7 @@ private static void assertVideoConsistency(@Nullable File video, int expectedWid
() -> assertThat("video's frame rate is not correct", videoInfo.frameRate(), is(equalTo(expectedFrameRate))),
() -> assertThat("video must be encoded with the VP9 codec", videoInfo.codec(), is(equalTo(VP9_CODEC))),
() -> assertThat("video's duration is not correct", formatInfo.duration(), is(equalTo(expectedDuration))),
- () -> assertThat("video's format must be matroska", formatInfo.format(), startsWith(MATROSKA_FORMAT)),
+ () -> assertThat("video's format must be Matroska", formatInfo.format(), startsWith(MATROSKA_FORMAT)),
() -> assertThat("video must have no audio stream", mediaInfo.audio(), is(nullValue())),
() -> assertThat("video size should not exceed 256 KB", formatInfo.size(), is(lessThanOrEqualTo(MAX_VIDEO_FILE_SIZE)))
);
@@ -272,6 +273,15 @@ void resizeAnimatedWebpVideo() {
assertThat(ex.getMessage(), equalTo("The file with image/webp MIME type is not supported"));
}
+ @Test
+ @Tag(Tags.VIDEO)
+ void resizeVideoWithHighlyAccurateFpsCount() throws Exception {
+ var mp4Video = loadResource("highly_accurate_fps_count.mp4");
+ var result = MediaHelper.convert(mp4Video);
+
+ assertVideoConsistency(result, 512, 434, 26F, 2.961F);
+ }
+
@Test
@Tag(Tags.ANIMATED_STICKER)
void noAnimatedStickerConversionNeeded() throws Exception {
diff --git a/src/test/resources/highly_accurate_fps_count.mp4 b/src/test/resources/highly_accurate_fps_count.mp4
new file mode 100644
index 00000000..3e4c54fb
Binary files /dev/null and b/src/test/resources/highly_accurate_fps_count.mp4 differ