Add MPS demuxer and FFmpeg H.264 video decode

- MpsDemuxer parses MPEG-PS packs (0x1BA) into video (0x1E0-0x1EF) and
  audio (0x1BD) PES packets with PTS/DTS at the 90kHz clock.
- VideoDecoder decodes Annex-B H.264 via JavaCPP FFmpeg 8.1.2, splitting
  the stream into access units (one slice NAL per packet) so each send
  yields a frame.
- CLI 'decode' command dumps frame metadata (720x480 YUVJ420P).
- Validated against Batman Begins: 72 frames decoded.
This commit is contained in:
2026-09-15 21:59:26 -04:00
parent f40fa604a3
commit 4821267d0e
7 changed files with 211 additions and 14 deletions
-1
View File
@@ -9,7 +9,6 @@ java {
} }
application { application {
mainModule = "dev.quoije.umd.cli"
mainClass = "dev.quoije.umd.cli.Main" mainClass = "dev.quoije.umd.cli.Main"
} }
+50 -1
View File
@@ -3,10 +3,12 @@ package dev.quoije.umd.cli;
import dev.quoije.umd.iso.UmdIsoFile; import dev.quoije.umd.iso.UmdIsoFile;
import dev.quoije.umd.iso.UmdIsoReader; import dev.quoije.umd.iso.UmdIsoReader;
import dev.quoije.umd.media.VideoDecoder;
import dev.quoije.umd.mps.MpsDemuxer; import dev.quoije.umd.mps.MpsDemuxer;
import dev.quoije.umd.playlist.Playlist; import dev.quoije.umd.playlist.Playlist;
import dev.quoije.umd.sfo.Sfo; import dev.quoije.umd.sfo.Sfo;
import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Path; import java.nio.file.Path;
@@ -15,7 +17,7 @@ import java.util.Map;
/** Command-line front-end for the UMD player library. */ /** Command-line front-end for the UMD player library. */
public final class Main { public final class Main {
private static final String USAGE = "usage: umdplayer dump <image.iso>\n umdplayer mps <image.iso> <streamName>"; private static final String USAGE = "usage: umdplayer dump <image.iso>\n umdplayer mps <image.iso> <streamName>\n umdplayer decode <image.iso> <streamName>";
private Main() { private Main() {
} }
@@ -31,6 +33,7 @@ public final class Main {
exitCode = switch (args[0]) { exitCode = switch (args[0]) {
case "dump" -> dump(requireArg(args, 1)); case "dump" -> dump(requireArg(args, 1));
case "mps" -> mps(requireArg(args, 1), requireArg(args, 2)); case "mps" -> mps(requireArg(args, 1), requireArg(args, 2));
case "decode" -> decode(requireArg(args, 1), requireArg(args, 2));
case "help", "--help", "-h" -> { case "help", "--help", "-h" -> {
System.out.println(USAGE); System.out.println(USAGE);
yield 0; yield 0;
@@ -186,6 +189,52 @@ public final class Main {
return 0; 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) { private static String hexPrefix(byte[] data, int max) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
for (int i = 0; i < Math.min(data.length, max); i++) { for (int i = 0; i < Math.min(data.length, max); i++) {
-4
View File
@@ -1,4 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
module dev.quoije.umd.cli {
requires dev.quoije.umd.core;
}
+3
View File
@@ -9,6 +9,9 @@ java {
} }
dependencies { dependencies {
api(libs.ffmpeg)
runtimeOnly("org.bytedeco:ffmpeg:${libs.versions.ffmpeg.get()}:linux-x86_64")
testImplementation(platform(libs.junit.bom)) testImplementation(platform(libs.junit.bom))
testImplementation(libs.junit.jupiter) testImplementation(libs.junit.jupiter)
testRuntimeOnly(libs.junit.platform.launcher) testRuntimeOnly(libs.junit.platform.launcher)
@@ -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<Frame> 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);
}
}
-7
View File
@@ -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;
}
+2 -1
View File
@@ -5,7 +5,7 @@ assertj = "3.27.3"
slf4j = "2.0.16" slf4j = "2.0.16"
logback = "1.5.16" logback = "1.5.16"
javafx = "21.0.5" javafx = "21.0.5"
ffmpeg = "7.1.1-1.5.11" ffmpeg = "8.1.2-1.5.14"
[libraries] [libraries]
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" } 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" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
javafx-controls = { module = "org.openjfx:javafx-controls", version.ref = "javafx" } 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" } ffmpeg-platform = { module = "org.bytedeco:ffmpeg-platform", version.ref = "ffmpeg" }