Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -183,15 +184,15 @@ 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",
"-print_format", "json",
"-show_format",
"-show_streams",
file.getAbsolutePath()
};
);

try {
var output = ProcessHelper.executeCommand(command);
Expand Down Expand Up @@ -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",
Expand All @@ -397,7 +398,7 @@ private static File convertToWebp(File file) throws MediaException, InterruptedE
"-lossless", "1",
"-compression_level", "6",
webpImage.getAbsolutePath()
};
);

try {
ProcessHelper.executeCommand(command);
Expand Down Expand Up @@ -466,26 +467,80 @@ 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"
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* 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 <a href="https://core.telegram.org/stickers/webm-vp9-encoding">Telegram documentation</a>.
*
* @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<String> 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",
"-v", "error",
"-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);
Expand All @@ -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<String> buildFfmpegCommand(final List<String>... commands) {
var command = new ArrayList<String>();
for (List<String> cmd : commands) {
command.addAll(cmd);
}

return commands;
return command;
}

private MediaHelper() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -33,7 +34,7 @@ public final class ProcessHelper {
* </ul>
* @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<String> command) throws ProcessException, InterruptedException {
SEMAPHORE.acquire();

try (var process = new ProcessBuilder(command).start()) {
Expand All @@ -55,22 +56,24 @@ 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();
errorThread.join();

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();
Expand Down
6 changes: 3 additions & 3 deletions src/main/resources/customUnixStartScript.txt
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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/.
Expand Down
33 changes: 11 additions & 22 deletions src/main/resources/customWindowsStartScript.txt
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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=.\
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
Expand All @@ -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
Expand All @@ -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%
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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)))
);
Expand Down Expand Up @@ -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 {
Expand Down
Binary file added src/test/resources/highly_accurate_fps_count.mp4
Binary file not shown.
Loading