diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 36a7b87..1dc662a 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -9,7 +9,6 @@ java { } application { - mainModule = "dev.quoije.umd.cli" mainClass = "dev.quoije.umd.cli.Main" } diff --git a/cli/src/main/java/dev/quoije/umd/cli/Main.java b/cli/src/main/java/dev/quoije/umd/cli/Main.java index 3800f69..b4c224a 100644 --- a/cli/src/main/java/dev/quoije/umd/cli/Main.java +++ b/cli/src/main/java/dev/quoije/umd/cli/Main.java @@ -3,10 +3,12 @@ package dev.quoije.umd.cli; import dev.quoije.umd.iso.UmdIsoFile; import dev.quoije.umd.iso.UmdIsoReader; +import dev.quoije.umd.media.VideoDecoder; import dev.quoije.umd.mps.MpsDemuxer; import dev.quoije.umd.playlist.Playlist; import dev.quoije.umd.sfo.Sfo; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Path; @@ -15,7 +17,7 @@ import java.util.Map; /** Command-line front-end for the UMD player library. */ public final class Main { - private static final String USAGE = "usage: umdplayer dump \n umdplayer mps "; + private static final String USAGE = "usage: umdplayer dump \n umdplayer mps \n umdplayer decode "; private Main() { } @@ -31,6 +33,7 @@ public final class Main { exitCode = switch (args[0]) { case "dump" -> dump(requireArg(args, 1)); case "mps" -> mps(requireArg(args, 1), requireArg(args, 2)); + case "decode" -> decode(requireArg(args, 1), requireArg(args, 2)); case "help", "--help", "-h" -> { System.out.println(USAGE); yield 0; @@ -186,6 +189,52 @@ public final class Main { return 0; } + private static int decode(String imagePath, String streamName) throws IOException { + try (UmdIsoReader iso = new UmdIsoReader(Path.of(imagePath)); + MpsDemuxer demuxer = MpsDemuxer.open(iso, streamName)) { + + ByteArrayOutputStream video = new ByteArrayOutputStream(); + int packets = 0; + int videoPackets = 0; + while (packets < 300) { + MpsDemuxer.Packet pkt = demuxer.nextPacket(); + if (pkt == null) { + break; + } + packets++; + if (!pkt.isVideo()) { + continue; + } + video.write(pkt.payload(), 0, pkt.payload().length); + videoPackets++; + } + + byte[] annexB = video.toByteArray(); + + try (VideoDecoder decoder = new VideoDecoder()) { + decoder.feedAnnexB(annexB); + decoder.flush(); + + int frames = 0; + VideoDecoder.Frame frame; + while ((frame = decoder.receive()) != null) { + frames++; + if (frames <= 8) { + long checksum = 0; + for (byte b : frame.yPlane()) { + checksum = checksum * 31 + (b & 0xFF); + } + System.out.printf("frame %d: %dx%d pixfmt=%d checksum=%d%n", + frames, frame.width(), frame.height(), frame.pixelFormat(), checksum); + } + } + System.out.printf("Decoded %d frames from %d video packets (%d total packets)%n", + frames, videoPackets, packets); + } + } + return 0; + } + private static String hexPrefix(byte[] data, int max) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < Math.min(data.length, max); i++) { diff --git a/cli/src/main/java/module-info.java b/cli/src/main/java/module-info.java deleted file mode 100644 index 96df579..0000000 --- a/cli/src/main/java/module-info.java +++ /dev/null @@ -1,4 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -module dev.quoije.umd.cli { - requires dev.quoije.umd.core; -} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 9545544..08d015e 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -9,6 +9,9 @@ java { } dependencies { + api(libs.ffmpeg) + runtimeOnly("org.bytedeco:ffmpeg:${libs.versions.ffmpeg.get()}:linux-x86_64") + testImplementation(platform(libs.junit.bom)) testImplementation(libs.junit.jupiter) testRuntimeOnly(libs.junit.platform.launcher) diff --git a/core/src/main/java/dev/quoije/umd/media/VideoDecoder.java b/core/src/main/java/dev/quoije/umd/media/VideoDecoder.java new file mode 100644 index 0000000..86b310c --- /dev/null +++ b/core/src/main/java/dev/quoije/umd/media/VideoDecoder.java @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.quoije.umd.media; + +import org.bytedeco.ffmpeg.avcodec.AVCodec; +import org.bytedeco.ffmpeg.avcodec.AVCodecContext; +import org.bytedeco.ffmpeg.avcodec.AVPacket; +import org.bytedeco.ffmpeg.avutil.AVFrame; +import org.bytedeco.ffmpeg.global.avcodec; +import org.bytedeco.ffmpeg.global.avutil; +import org.bytedeco.javacpp.BytePointer; +import org.bytedeco.javacpp.PointerPointer; + +import java.util.ArrayDeque; +import java.util.Arrays; + +/** H.264 (Annex B) video decoder backed by FFmpeg. */ +public final class VideoDecoder implements AutoCloseable { + + private final AVCodecContext context; + private final AVPacket packet; + private final AVFrame frame; + private final ArrayDeque frames = new ArrayDeque<>(); + + public VideoDecoder() { + AVCodec codec = avcodec.avcodec_find_decoder(avcodec.AV_CODEC_ID_H264); + if (codec == null || codec.isNull()) { + throw new IllegalStateException("H.264 decoder not available"); + } + + context = avcodec.avcodec_alloc_context3(codec); + if (context == null || context.isNull()) { + throw new IllegalStateException("Could not allocate codec context"); + } + + int ret = avcodec.avcodec_open2(context, codec, (PointerPointer) null); + if (ret < 0) { + throw new IllegalStateException("avcodec_open2 failed: " + ret); + } + + packet = avcodec.av_packet_alloc(); + frame = avutil.av_frame_alloc(); + } + + /** + * Feeds Annex-B H.264 data, splitting it into access units: each emitted + * packet ends at the end of a slice NAL (type 1 or 5), so the decoder + * produces one frame per packet. + */ + public void feedAnnexB(byte[] annexB) { + int accessUnitStart = 0; + int i = 0; + while (i < annexB.length) { + int startCodeLength = startCodeLengthAt(annexB, i); + if (startCodeLength > 0) { + int nalu = i + startCodeLength; + if (nalu < annexB.length) { + int type = annexB[nalu] & 0x1F; + if (type == 1 || type == 5) { + int end = findNextStartCode(annexB, nalu); + feedOne(Arrays.copyOfRange(annexB, accessUnitStart, end)); + accessUnitStart = end; + i = end; + continue; + } + } + i = nalu; + } else { + i++; + } + } + + if (accessUnitStart < annexB.length) { + feedOne(Arrays.copyOfRange(annexB, accessUnitStart, annexB.length)); + } + } + + private static int startCodeLengthAt(byte[] b, int i) { + if (i + 2 < b.length && b[i] == 0 && b[i + 1] == 0 && b[i + 2] == 1) { + return 3; + } + if (i + 3 < b.length && b[i] == 0 && b[i + 1] == 0 && b[i + 2] == 0 && b[i + 3] == 1) { + return 4; + } + return -1; + } + + private static int findNextStartCode(byte[] b, int from) { + int j = from; + while (j < b.length) { + if (startCodeLengthAt(b, j) > 0) { + return j; + } + j++; + } + return b.length; + } + + private void feedOne(byte[] data) { + if (avcodec.av_new_packet(packet, data.length) < 0) { + throw new IllegalStateException("av_new_packet failed"); + } + packet.data().put(data); + + int ret = avcodec.avcodec_send_packet(context, packet); + avcodec.av_packet_unref(packet); + if (ret < 0) { + throw new IllegalStateException("avcodec_send_packet failed: " + ret); + } + drain(); + } + + /** Flushes the decoder and signals end of stream. */ + public void flush() { + avcodec.avcodec_send_packet(context, null); + drain(); + } + + private void drain() { + Frame f; + while ((f = receiveInternal()) != null) { + frames.add(f); + } + } + + private Frame receiveInternal() { + int ret = avcodec.avcodec_receive_frame(context, frame); + if (ret < 0) { + return null; // EAGAIN or EOF + } + + int width = frame.width(); + int height = frame.height(); + BytePointer y = frame.data(0); + int stride = frame.linesize(0); + byte[] yPlane = new byte[height * stride]; + y.position(0).get(yPlane); + + return new Frame(width, height, frame.format(), yPlane, stride); + } + + /** A decoded video frame. */ + public record Frame(int width, int height, int pixelFormat, byte[] yPlane, int yStride) { + } + + /** Returns the next decoded frame, or {@code null} if none is buffered. */ + public Frame receive() { + return frames.poll(); + } + + @Override + public void close() { + avutil.av_frame_free(frame); + avcodec.av_packet_free(packet); + avcodec.avcodec_free_context(context); + } +} diff --git a/core/src/main/java/module-info.java b/core/src/main/java/module-info.java deleted file mode 100644 index d81bc3f..0000000 --- a/core/src/main/java/module-info.java +++ /dev/null @@ -1,7 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -module dev.quoije.umd.core { - exports dev.quoije.umd.iso; - exports dev.quoije.umd.sfo; - exports dev.quoije.umd.playlist; - exports dev.quoije.umd.mps; -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 71d5353..a7832e0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ assertj = "3.27.3" slf4j = "2.0.16" logback = "1.5.16" javafx = "21.0.5" -ffmpeg = "7.1.1-1.5.11" +ffmpeg = "8.1.2-1.5.14" [libraries] junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" } @@ -15,4 +15,5 @@ assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertj" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } javafx-controls = { module = "org.openjfx:javafx-controls", version.ref = "javafx" } +ffmpeg = { module = "org.bytedeco:ffmpeg", version.ref = "ffmpeg" } ffmpeg-platform = { module = "org.bytedeco:ffmpeg-platform", version.ref = "ffmpeg" }