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