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 {
|
application {
|
||||||
mainModule = "dev.quoije.umd.cli"
|
|
||||||
mainClass = "dev.quoije.umd.cli.Main"
|
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.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++) {
|
||||||
|
|||||||
@@ -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 {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user