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:
@@ -9,7 +9,6 @@ java {
|
||||
}
|
||||
|
||||
application {
|
||||
mainModule = "dev.quoije.umd.cli"
|
||||
mainClass = "dev.quoije.umd.cli.Main"
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <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() {
|
||||
}
|
||||
@@ -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++) {
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
module dev.quoije.umd.cli {
|
||||
requires dev.quoije.umd.core;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user