diff --git a/server/src/main/java/xyz/e3ndr/athena/Config.java b/server/src/main/java/xyz/e3ndr/athena/Config.java index f85c0d6..813408c 100644 --- a/server/src/main/java/xyz/e3ndr/athena/Config.java +++ b/server/src/main/java/xyz/e3ndr/athena/Config.java @@ -3,6 +3,7 @@ import co.casterlabs.rakurai.json.annotating.JsonClass; import co.casterlabs.rakurai.json.annotating.JsonField; import lombok.ToString; +import xyz.e3ndr.athena.transcoding.accelerator.TranscodeAcceleration; @ToString @JsonClass(exposeAll = true) @@ -67,14 +68,6 @@ public static class SessionConfig { public static class TranscodeConfig { public boolean enable = true; public TranscodeAcceleration acceleration = TranscodeAcceleration.SOFTWARE_ONLY; - - public static enum TranscodeAcceleration { - SOFTWARE_ONLY, - NVIDIA_PREFERRED, - // TODO AMD & Intel encoders. - // TODO Implement hardware decoding to speed up the transcode pipeline. - // https://trac.ffmpeg.org/wiki/HWAccelIntro - } } } diff --git a/server/src/main/java/xyz/e3ndr/athena/Launcher.java b/server/src/main/java/xyz/e3ndr/athena/Launcher.java index 7423d34..1bd74a9 100644 --- a/server/src/main/java/xyz/e3ndr/athena/Launcher.java +++ b/server/src/main/java/xyz/e3ndr/athena/Launcher.java @@ -10,6 +10,7 @@ import xyz.e3ndr.athena.service.ftp.AthenaFtpService; import xyz.e3ndr.athena.service.http.AthenaHttpService; import xyz.e3ndr.athena.service.simple_ui.AthenaSimpleUIService; +import xyz.e3ndr.athena.transcoding.accelerator.TranscodeAcceleration; import xyz.e3ndr.fastloggingframework.FastLoggingFramework; import xyz.e3ndr.fastloggingframework.logging.FastLogger; import xyz.e3ndr.fastloggingframework.logging.LogLevel; @@ -46,6 +47,9 @@ public static void main(String[] args) throws Exception { // debug FastLoggingFramework.setDefaultLevel(Athena.config.console.debug ? LogLevel.DEBUG : LogLevel.INFO); + // Wait for all of these to finish. + TranscodeAcceleration.runTests(); + // Go! if (Athena.config.services.http.enable) { AsyncTask.createNonDaemon(() -> new AthenaHttpService().init()); diff --git a/server/src/main/java/xyz/e3ndr/athena/transcoding/CommandBuilder.java b/server/src/main/java/xyz/e3ndr/athena/transcoding/CommandBuilder.java new file mode 100644 index 0000000..c7af0eb --- /dev/null +++ b/server/src/main/java/xyz/e3ndr/athena/transcoding/CommandBuilder.java @@ -0,0 +1,27 @@ +package xyz.e3ndr.athena.transcoding; + +import java.util.LinkedList; +import java.util.List; + +import lombok.NonNull; + +public class CommandBuilder { + private List args = new LinkedList<>(); + + public CommandBuilder add(@NonNull String... args) { + for (String a : args) { + this.args.add(a); + } + return this; + } + + public CommandBuilder add(@NonNull List args) { + this.args.addAll(args); + return this; + } + + public List asList() { + return this.args; + } + +} diff --git a/server/src/main/java/xyz/e3ndr/athena/transcoding/FFMpegArgs.java b/server/src/main/java/xyz/e3ndr/athena/transcoding/FFMpegArgs.java index 242099c..f770856 100644 --- a/server/src/main/java/xyz/e3ndr/athena/transcoding/FFMpegArgs.java +++ b/server/src/main/java/xyz/e3ndr/athena/transcoding/FFMpegArgs.java @@ -1,101 +1,60 @@ package xyz.e3ndr.athena.transcoding; import java.util.Arrays; -import java.util.LinkedList; +import java.util.Collections; import java.util.List; -import xyz.e3ndr.athena.Config.TranscodeConfig.TranscodeAcceleration; +import lombok.NonNull; +import xyz.e3ndr.athena.Athena; +import xyz.e3ndr.athena.transcoding.accelerator.TranscodeAcceleration; import xyz.e3ndr.athena.types.AudioCodec; import xyz.e3ndr.athena.types.VideoCodec; import xyz.e3ndr.athena.types.VideoQuality; public class FFMpegArgs { - public static List a_getFF(AudioCodec codec) { + public static @NonNull List acc_getFF() { + List args = Athena.config.transcoding.acceleration.acc_getFF(); + return args == null ? Collections.emptyList() : args; + } + + public static @NonNull List a_getFF(@NonNull AudioCodec codec) { switch (codec) { case SOURCE: - return Arrays.asList("-c:a", "copy"); + return Arrays.asList( + "-c:a", "copy" + ); case OPUS: - return Arrays.asList("-c:a", "libopus", "-b:a", "320k"); + return Arrays.asList( + "-c:a", "libopus", // Match channel layout + "-b:a", "320k" + ); case AAC: - return Arrays.asList("-c:a", "aac", "-ac", "2", "-b:a", "156k"); + return Arrays.asList( + "-c:a", "aac", + "-ac", "2", // Limit to 2 channels. + "-b:a", "156k" + ); case MP3: - return Arrays.asList("-c:a", "mp3", "-ac", "2", "-b:a", "128k"); + return Arrays.asList( + "-c:a", "mp3", + "-ac", "2", // Limit to 2 channels. + "-b:a", "128k" + ); } - return null; + throw new IllegalArgumentException("Unhandled enum: " + codec); } - public static List v_getFF(VideoCodec codec, VideoQuality quality, TranscodeAcceleration acceleration) { - // TODO the more advanced parameters for HEVC and AV1 - - switch (codec) { - case SOURCE: - return Arrays.asList("-c:v", "copy"); - - case H264_BASELINE: - case H264_HIGH: - return getH264Args(codec, quality, acceleration); - - case HEVC: - switch (acceleration) { - case NVIDIA_PREFERRED: - return Arrays.asList("-c:v", "hevc_nvenc"); - - case SOFTWARE_ONLY: - return Arrays.asList("-c:v", "hevc"); - } - - case AV1: - return Arrays.asList("-c:v", "libsvtav1"); - - case SPARK: - return Arrays.asList("-c:v", "flv1"); - } - return null; - } - - private static List getH264Args(VideoCodec codec, VideoQuality quality, TranscodeAcceleration acceleration) { - List args = new LinkedList<>(); - - switch (acceleration) { - case NVIDIA_PREFERRED: - args.add("-c:v"); - args.add("h264_nvenc"); - break; - - case SOFTWARE_ONLY: - args.add("-c:v"); - args.add("h264"); - break; - } - - switch (codec) { - case H264_BASELINE: - args.add("-profile:v"); - args.add("baseline"); -// args.add("-level:v"); -// args.add("1.0"); - break; - - case H264_HIGH: - args.add("-profile:v"); - args.add("high"); - args.add("-level"); - args.add("5.0"); - if (acceleration != TranscodeAcceleration.NVIDIA_PREFERRED) { - // NVIDIA does not support tune. - args.add("-tune"); - args.add("film"); - } - args.add("-preset"); - args.add("slow"); - break; + public static @NonNull List v_getFF(@NonNull VideoCodec codec, @NonNull VideoQuality quality) { + List args = Athena.config.transcoding.acceleration.v_getFF(codec, quality); - default: - break; + if (args == null) { + // For some reason the accelerator produced no result. + // Let's fallback to software. + args = TranscodeAcceleration.SOFTWARE_ONLY.v_getFF(codec, quality); } return args; diff --git a/server/src/main/java/xyz/e3ndr/athena/transcoding/TranscodeSession.java b/server/src/main/java/xyz/e3ndr/athena/transcoding/TranscodeSession.java index dc23f62..a5ef7db 100644 --- a/server/src/main/java/xyz/e3ndr/athena/transcoding/TranscodeSession.java +++ b/server/src/main/java/xyz/e3ndr/athena/transcoding/TranscodeSession.java @@ -87,7 +87,9 @@ public void processStatistic(String ffmpegLine) { String speedStr = ffmpegLine.split("speed= *")[1].split(" ")[0]; // "1.07x" speedStr = speedStr.substring(0, speedStr.length() - "x".length()); - this.encodingSpeed = Double.parseDouble(speedStr); + if (!speedStr.startsWith("N/")) { + this.encodingSpeed = Double.parseDouble(speedStr); + } } } diff --git a/server/src/main/java/xyz/e3ndr/athena/transcoding/Transcoder.java b/server/src/main/java/xyz/e3ndr/athena/transcoding/Transcoder.java index 9805023..77296d7 100644 --- a/server/src/main/java/xyz/e3ndr/athena/transcoding/Transcoder.java +++ b/server/src/main/java/xyz/e3ndr/athena/transcoding/Transcoder.java @@ -46,57 +46,51 @@ public class Transcoder { return null; } - List command = new LinkedList<>(); + CommandBuilder command = new CommandBuilder(); command.add(FFMPEG_EXEC); command.add("-hide_banner"); -// command.add("-v"); -// command.add("error"); +// command.add("-v", "error"); + + command.add(FFMpegArgs.acc_getFF()); /* ---- Streams/Input ---- */ for (int streamId : streamIds) { - command.add("-i"); - command.add(media.getStreamFile(streamId).getCanonicalPath()); + command.add("-i", media.getStreamFile(streamId).getCanonicalPath()); } /* ---- Audio ---- */ - command.addAll(FFMpegArgs.a_getFF(desiredACodec)); + command.add(FFMpegArgs.a_getFF(desiredACodec)); if (desiredACodec != AudioCodec.SOURCE) { - command.add("-ar"); - command.add("48000"); + command.add("-ar", "48000"); } /* ---- Video ---- */ - command.addAll(FFMpegArgs.v_getFF(desiredVCodec, desiredQuality, Athena.config.transcoding.acceleration)); + command.add(FFMpegArgs.v_getFF(desiredVCodec, desiredQuality)); if (desiredVCodec != VideoCodec.SOURCE) { - command.add("-b:v"); - command.add(String.format("%dK", desiredQuality.bitrate)); + command.add("-b:v", String.format("%dK", desiredQuality.bitrate)); // https://trac.ffmpeg.org/wiki/Scaling - command.add("-vf"); - command.add(String.format("scale='min(%d,iw)':-1, pad=ceil(iw/2)*2:ceil(ih/2)*2", desiredQuality.max)); + command.add("-vf", String.format("scale='min(%d,iw)':-1, pad=ceil(iw/2)*2:ceil(ih/2)*2", desiredQuality.max)); } /* ---- Format & Output ---- */ - command.add("-bufsize"); - command.add(String.valueOf(Athena.TRANSCODING_BUFFER_SIZE)); + command.add("-bufsize", String.valueOf(Athena.TRANSCODING_BUFFER_SIZE)); - command.addAll(desiredContainer.flags); + command.add(desiredContainer.flags); if (desiredContainer == ContainerFormat.HLS) { targetFile.mkdir(); // Make it a directory. - command.add("-vf"); - command.add("fps=fps=" + HLS_RATE); + command.add("-vf", "fps=fps=" + HLS_RATE); command.add(new File(targetFile, "media.m3u8").getCanonicalPath()); } else { - command.add("-f"); - command.add(desiredContainer.ff); + command.add("-f", desiredContainer.ff); command.add("pipe:1"); } final Process proc = new ProcessBuilder() - .command(command) + .command(command.asList()) .redirectError(Redirect.PIPE) .redirectInput(Redirect.PIPE) .redirectOutput(Redirect.PIPE) @@ -132,6 +126,7 @@ public class Transcoder { session.processStatistic(line); session.logger.debug(session); } else if (initInfoBuilder != null) { + session.logger.debug(line); // This gets set to null after the video starts. initInfoBuilder.add(line); } diff --git a/server/src/main/java/xyz/e3ndr/athena/transcoding/accelerator/Accelerator.java b/server/src/main/java/xyz/e3ndr/athena/transcoding/accelerator/Accelerator.java new file mode 100644 index 0000000..0cbeb9a --- /dev/null +++ b/server/src/main/java/xyz/e3ndr/athena/transcoding/accelerator/Accelerator.java @@ -0,0 +1,17 @@ +package xyz.e3ndr.athena.transcoding.accelerator; + +import java.util.List; + +import org.jetbrains.annotations.Nullable; + +import lombok.NonNull; +import xyz.e3ndr.athena.types.VideoCodec; +import xyz.e3ndr.athena.types.VideoQuality; + +public interface Accelerator { + + public @Nullable List acc_getFF(); + + public @Nullable List v_getFF(@NonNull VideoCodec codec, @NonNull VideoQuality quality); + +} diff --git a/server/src/main/java/xyz/e3ndr/athena/transcoding/accelerator/NvidiaPreferred.java b/server/src/main/java/xyz/e3ndr/athena/transcoding/accelerator/NvidiaPreferred.java new file mode 100644 index 0000000..8bea518 --- /dev/null +++ b/server/src/main/java/xyz/e3ndr/athena/transcoding/accelerator/NvidiaPreferred.java @@ -0,0 +1,58 @@ +package xyz.e3ndr.athena.transcoding.accelerator; + +import java.util.Arrays; +import java.util.List; + +import org.jetbrains.annotations.Nullable; + +import lombok.NonNull; +import xyz.e3ndr.athena.types.VideoCodec; +import xyz.e3ndr.athena.types.VideoQuality; + +class NvidiaPreferred implements Accelerator { + + @Override + public @Nullable List acc_getFF() { + return null; // TODO some good logic for this so we can accelerate decoding. +// return Arrays.asList( +// "-hwaccel", "cuda", +// "-hwaccel_output_format", "cuda" +// ); + } + + @Override + public @Nullable List v_getFF(@NonNull VideoCodec codec, @NonNull VideoQuality quality) { + switch (codec) { + case H264_BASELINE: + return Arrays.asList( + "-c:v", "h264_nvenc", + "-profile:v", "baseline", + "-pix_fmt", "yuv420p" + ); + + case H264_HIGH: + return Arrays.asList( + "-c:v", "h264_nvenc", + "-profile:v", "high", + "-pix_fmt", "yuv420p", + "-level", "5.0", + "-preset", "slow" + ); + + // TODO the more advanced parameters for HEVC and AV1 + case HEVC: + return Arrays.asList( + "-c:v", "hevc_nvenc" + ); + + case AV1: + return Arrays.asList( + "-c:v", "av1_nvenc" + ); + + default: + return null; + } + } + +} diff --git a/server/src/main/java/xyz/e3ndr/athena/transcoding/accelerator/SoftwareOnly.java b/server/src/main/java/xyz/e3ndr/athena/transcoding/accelerator/SoftwareOnly.java new file mode 100644 index 0000000..2ee0a8c --- /dev/null +++ b/server/src/main/java/xyz/e3ndr/athena/transcoding/accelerator/SoftwareOnly.java @@ -0,0 +1,63 @@ +package xyz.e3ndr.athena.transcoding.accelerator; + +import java.util.Arrays; +import java.util.List; + +import org.jetbrains.annotations.Nullable; + +import lombok.NonNull; +import xyz.e3ndr.athena.types.VideoCodec; +import xyz.e3ndr.athena.types.VideoQuality; + +class SoftwareOnly implements Accelerator { + + @Override + public @Nullable List acc_getFF() { + return null; + } + + @Override + public @Nullable List v_getFF(@NonNull VideoCodec codec, @NonNull VideoQuality quality) { + switch (codec) { + case SOURCE: + return Arrays.asList( + "-c:v", "copy" + ); + + case H264_BASELINE: + return Arrays.asList( + "-c:v", "libx264", + "-profile:v", "baseline", + "-pix_fmt", "yuv420p" + ); + + case H264_HIGH: + return Arrays.asList( + "-c:v", "libx264", + "-profile:v", "high", + "-pix_fmt", "yuv420p", + "-level", "5.0", + "-tune", "film", + "-preset", "slow" + ); + + // TODO the more advanced parameters for HEVC and AV1 + case HEVC: + return Arrays.asList( + "-c:v", "libx265" + ); + + case AV1: + return Arrays.asList( + "-c:v", "libsvtav1" + ); + + case SPARK: + return Arrays.asList( + "-c:v", "flv1" + ); + } + throw new IllegalArgumentException("Unhandled enum: " + codec); + } + +} diff --git a/server/src/main/java/xyz/e3ndr/athena/transcoding/accelerator/TranscodeAcceleration.java b/server/src/main/java/xyz/e3ndr/athena/transcoding/accelerator/TranscodeAcceleration.java new file mode 100644 index 0000000..1b9dfdf --- /dev/null +++ b/server/src/main/java/xyz/e3ndr/athena/transcoding/accelerator/TranscodeAcceleration.java @@ -0,0 +1,115 @@ +package xyz.e3ndr.athena.transcoding.accelerator; + +import java.lang.ProcessBuilder.Redirect; +import java.util.LinkedList; +import java.util.List; +import java.util.Scanner; + +import org.jetbrains.annotations.Nullable; + +import co.casterlabs.commons.async.Promise; +import lombok.NonNull; +import xyz.e3ndr.athena.transcoding.CommandBuilder; +import xyz.e3ndr.athena.transcoding.Transcoder; +import xyz.e3ndr.athena.types.VideoCodec; +import xyz.e3ndr.athena.types.VideoQuality; +import xyz.e3ndr.fastloggingframework.logging.FastLogger; +import xyz.e3ndr.fastloggingframework.logging.LogLevel; + +public enum TranscodeAcceleration { + SOFTWARE_ONLY(new SoftwareOnly()), + NVIDIA_PREFERRED(new NvidiaPreferred()), + // TODO AMD & Intel encoders. + // TODO Implement hardware decoding to speed up the transcode pipeline. + // https://trac.ffmpeg.org/wiki/HWAccelIntro + ; + + // Forward to the actual IMPL. + private final Accelerator instance; + private final List supported = new LinkedList<>(); + + private TranscodeAcceleration(@NonNull Accelerator instance) { + this.instance = instance; + } + + public @Nullable List acc_getFF() { + return this.instance.acc_getFF(); + } + + public @Nullable List v_getFF(@NonNull VideoCodec codec, @NonNull VideoQuality quality) { + if (this.supported.contains(codec)) { + return this.instance.v_getFF(codec, quality); + } else { + return null; // Not supported! + } + } + + @SuppressWarnings({ + "unchecked", + "rawtypes" + }) + public static void runTests() { + List> promises = new LinkedList<>(); + for (TranscodeAcceleration a : values()) { + for (VideoCodec codec : VideoCodec.values()) { + promises.add( + new Promise(() -> { + a.tryCodec(codec); + return null; + }) + ); + } + } + for (Promise p : promises) { + try { + p.await(); + } catch (Throwable ignored) {} + } + } + + private void tryCodec(VideoCodec codec) { + FastLogger.logStatic(LogLevel.INFO, "[Acceleration=%s] Detecting support for VideoCodec %s...", this.name(), codec); + + CommandBuilder command = new CommandBuilder(); + command.add(Transcoder.FFMPEG_EXEC); + command.add("-hide_banner"); + command.add("-v", "warning"); + + command.add("-f", "lavfi"); + command.add("-i", "testsrc=duration=1:size=1280x720:rate=30"); + + List vCodecArgs = this.instance.v_getFF(codec, VideoQuality.FHD); + if (vCodecArgs == null) { + FastLogger.logStatic(LogLevel.DEBUG, "[Acceleration=%s] VideoCodec %s is NOT supported! (No args) This is probably fine...", this.name(), codec); + return; + } + command.add(vCodecArgs); + + command.add("-f", "null"); + command.add("pipe:1"); + + try { + final Process proc = new ProcessBuilder() + .command(command.asList()) + .redirectError(Redirect.PIPE) + .redirectInput(Redirect.PIPE) + .redirectOutput(Redirect.DISCARD) + .start(); + + if (proc.waitFor() == 0) { + FastLogger.logStatic(LogLevel.INFO, "[Acceleration=%s] Success! VideoCodec %s is supported!", this.name(), codec); + this.supported.add(codec); + } else { + FastLogger.logStatic(LogLevel.WARNING, "[Acceleration=%s] VideoCodec %s is NOT supported! This is probably fine...", this.name(), codec); + + Scanner output = new Scanner(proc.getErrorStream()); + while (output.hasNext()) { + FastLogger.logStatic(LogLevel.DEBUG, "[Acceleration=%s] %s", this.name(), output.nextLine()); + } + } + } catch (Exception e) { + FastLogger.logStatic(LogLevel.SEVERE, "[Acceleration=%s] An error occurred whilst detecting support for VideoCodec %s.\n%s", this.name(), codec, e); + } + } + +}