Add MPS/MPEG-PS demuxer (H.264 video + ATRAC3+ audio packets)

- Parse PS packets, PES headers and PTS/DTS timestamps
- 'mps' CLI command samples packets from a stream
- Validated on Batman Begins 00001.MPS (Annex-B H.264 + 2-channel ATRAC3+)
This commit is contained in:
2026-09-15 21:06:32 -04:00
parent b09e23208a
commit f40fa604a3
3 changed files with 294 additions and 1 deletions
@@ -0,0 +1,249 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package dev.quoije.umd.mps;
import dev.quoije.umd.iso.UmdIsoFile;
import dev.quoije.umd.iso.UmdIsoReader;
import java.io.EOFException;
import java.io.IOException;
/**
* Demuxer for the MPEG program stream inside a UMD {@code .MPS} file.
*
* <p>The stream starts with a PSMF header (magic {@code "PSMF"}) followed by
* MPEG program stream packets: video (stream id {@code 0x1E0}-{@code 0x1EF},
* H.264) and audio (stream id {@code 0x1BD}, ATRAC3plus).</p>
*/
public final class MpsDemuxer implements AutoCloseable {
public static final int PACK_START_CODE = 0x1BA;
public static final int SYSTEM_HEADER_START_CODE = 0x1BB;
public static final int PRIVATE_STREAM_1 = 0x1BD;
public static final int PADDING_STREAM = 0x1BE;
public static final int PRIVATE_STREAM_2 = 0x1BF;
public static final long UNKNOWN_TIMESTAMP = -1L;
private final UmdIsoFile file;
private MpsDemuxer(UmdIsoFile file) {
this.file = file;
}
/** A single PES packet extracted from the stream. */
public record Packet(int streamId, long pts, long dts, int channel, byte[] payload) {
public boolean isVideo() {
return streamId >= 0x1E0 && streamId <= 0x1EF;
}
public boolean isAudio() {
return streamId == PRIVATE_STREAM_1;
}
}
public static MpsDemuxer open(UmdIsoReader iso, String streamName) throws IOException {
UmdIsoFile file = iso.getFile("UMD_VIDEO/STREAM/" + streamName + ".MPS");
return new MpsDemuxer(file);
}
/**
* Reads the next media packet, or {@code null} at end of stream.
* Non-media packets (pack headers, padding, private stream 2) are skipped.
*/
public Packet nextPacket() throws IOException {
while (true) {
int startCode = read32();
if (startCode == -1) {
return null;
}
switch (startCode) {
case PACK_START_CODE -> skip(10);
case SYSTEM_HEADER_START_CODE -> skip(14);
case PADDING_STREAM, PRIVATE_STREAM_2 -> {
int len = read16();
if (len < 0) {
return null;
}
skip(len);
}
case PRIVATE_STREAM_1 -> {
int codeLength = read16();
if (codeLength < 0) {
return null;
}
PesHeader pes = readPesHeader(startCode);
int payloadLength = codeLength - pes.headerLength();
byte[] payload = readPayload(payloadLength);
return new Packet(startCode, pes.pts(), pes.dts(), pes.channel(), payload);
}
default -> {
if (startCode >= 0x1E0 && startCode <= 0x1EF) {
int codeLength = read16();
if (codeLength < 0) {
return null;
}
PesHeader pes = readPesHeader(startCode);
int payloadLength = codeLength - pes.headerLength();
byte[] payload = readPayload(payloadLength);
return new Packet(startCode, pes.pts(), pes.dts(), -1, payload);
}
throw new IOException(String.format("Unknown MPEG-PS start code 0x%08X", startCode));
}
}
}
}
private byte[] readPayload(int length) throws IOException {
if (length <= 0) {
return new byte[0];
}
byte[] data = new byte[length];
file.readFully(data);
return data;
}
private record PesHeader(long pts, long dts, int channel, int headerLength) {
}
private PesHeader readPesHeader(int startCode) throws IOException {
int pesLength = 0;
int c = read8();
pesLength++;
while (c == 0xFF) {
c = read8();
pesLength++;
}
if ((c & 0xC0) == 0x40) {
skip(1);
c = read8();
pesLength += 2;
}
long pts = UNKNOWN_TIMESTAMP;
long dts = UNKNOWN_TIMESTAMP;
if ((c & 0xE0) == 0x20) {
long first = readPts(c);
pesLength += 4;
dts = first;
pts = first;
if ((c & 0x10) != 0) {
pts = readPts();
pesLength += 5;
}
} else if ((c & 0xC0) == 0x80) {
int flags = read8();
int headerLength = read8();
pesLength += 2 + headerLength;
if ((flags & 0x80) != 0) {
pts = readPts();
dts = pts;
headerLength -= 5;
if ((flags & 0x40) != 0) {
dts = readPts();
headerLength -= 5;
}
}
if ((flags & 0x3F) != 0 && headerLength == 0) {
flags &= 0xC0;
}
if ((flags & 0x01) != 0) {
int pesExt = read8();
headerLength--;
int skipLen = (pesExt >> 4) & 0x0B;
skipLen += skipLen & 0x09;
if ((pesExt & 0x40) != 0 || skipLen > headerLength) {
pesExt = 0;
skipLen = 0;
}
skip(skipLen);
headerLength -= skipLen;
if ((pesExt & 0x01) != 0) {
int ext2Length = read8();
headerLength--;
if ((ext2Length & 0x7F) != 0) {
int idExt = read8();
headerLength--;
if ((idExt & 0x80) == 0) {
startCode = ((startCode & 0xFF) << 8) | idExt;
}
}
}
}
skip(headerLength);
}
int channel = -1;
if (startCode == PRIVATE_STREAM_1) {
channel = read8();
pesLength++;
if (channel >= 0x80 && channel <= 0xCF) {
skip(3);
pesLength += 3;
if (channel >= 0xB0 && channel <= 0xBF) {
skip(1);
pesLength++;
}
} else {
skip(3);
pesLength += 3;
}
}
return new PesHeader(pts, dts, channel, pesLength);
}
private long readPts(int c) throws IOException {
return (((long) (c & 0x0E)) << 29)
| ((long) (read16() >> 1) << 15)
| (read16() >> 1);
}
private long readPts() throws IOException {
return readPts(read8());
}
private int read8() throws IOException {
try {
return file.read();
} catch (EOFException e) {
return -1;
}
}
private int read16() throws IOException {
int a = read8();
if (a < 0) {
return -1;
}
int b = read8();
if (b < 0) {
return -1;
}
return (a << 8) | b;
}
private int read32() throws IOException {
int a = read16();
if (a < 0) {
return -1;
}
int b = read16();
if (b < 0) {
return -1;
}
return (a << 16) | (b & 0xFFFF);
}
private void skip(int n) throws IOException {
if (n > 0) {
file.skip(n);
}
}
@Override
public void close() {
file.close();
}
}
+1
View File
@@ -3,4 +3,5 @@ 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;
}