Parse PLAYLIST.UMD: streams, chapters and timestamps
- Add little-endian readers to UmdIsoFile - Playlist parser (streams + chapter markers) ported from JPCSP - dump command now lists streams, durations and chapter markers - Unit test for chapter/timestamp mapping
This commit is contained in:
@@ -3,6 +3,7 @@ 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.playlist.Playlist;
|
||||||
import dev.quoije.umd.sfo.Sfo;
|
import dev.quoije.umd.sfo.Sfo;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -65,6 +66,8 @@ public final class Main {
|
|||||||
printSfo(iso);
|
printSfo(iso);
|
||||||
System.out.println();
|
System.out.println();
|
||||||
printTree(iso);
|
printTree(iso);
|
||||||
|
System.out.println();
|
||||||
|
printPlaylist(iso);
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -120,6 +123,34 @@ public final class Main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void printPlaylist(UmdIsoReader iso) {
|
||||||
|
try {
|
||||||
|
Playlist playlist = Playlist.parse(iso);
|
||||||
|
System.out.println("Playlist (" + playlist.streams().size() + " streams):");
|
||||||
|
for (Playlist.Stream stream : playlist.streams()) {
|
||||||
|
System.out.printf(" %s %dx%d %s .. %s (%d chapters)%n",
|
||||||
|
stream.name(), stream.width(), stream.height(),
|
||||||
|
formatTimestamp(stream.firstTimestamp()),
|
||||||
|
formatTimestamp(stream.lastTimestamp()),
|
||||||
|
stream.markers().size());
|
||||||
|
for (Playlist.Marker marker : stream.markers()) {
|
||||||
|
System.out.printf(" %s @ %s%n", marker.name(), formatTimestamp(marker.timestamp()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("Playlist: error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String formatTimestamp(long timestamp) {
|
||||||
|
long seconds = timestamp / 90000; // 90 kHz MPEG clock
|
||||||
|
long hundredth = (timestamp - seconds * 90000) * 100 / 90000;
|
||||||
|
long hours = seconds / 3600;
|
||||||
|
long minutes = (seconds % 3600) / 60;
|
||||||
|
long secs = seconds % 60;
|
||||||
|
return String.format("%02d:%02d:%02d.%02d", hours, minutes, secs, hundredth);
|
||||||
|
}
|
||||||
|
|
||||||
private static byte[] readFile(UmdIsoReader iso, String path) throws IOException {
|
private static byte[] readFile(UmdIsoReader iso, String path) throws IOException {
|
||||||
if (!iso.hasFile(path)) {
|
if (!iso.hasFile(path)) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -108,6 +108,35 @@ public final class UmdIsoFile implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int read(byte[] b) throws IOException {
|
||||||
|
return read(b, 0, b.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int readUnsignedByte() throws IOException {
|
||||||
|
return read();
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte readByte() throws IOException {
|
||||||
|
return (byte) read();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads a 16-bit little-endian value, as used by the PSP. */
|
||||||
|
public short readShort() throws IOException {
|
||||||
|
return (short) (readUnsignedByte() | (readUnsignedByte() << 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads a 32-bit little-endian value, as used by the PSP. */
|
||||||
|
public int readInt() throws IOException {
|
||||||
|
return readUnsignedByte()
|
||||||
|
| (readUnsignedByte() << 8)
|
||||||
|
| (readUnsignedByte() << 16)
|
||||||
|
| (readUnsignedByte() << 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int skipBytes(int bytes) throws IOException {
|
||||||
|
return (int) skip(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
public void seek(long offset) throws IOException {
|
public void seek(long offset) throws IOException {
|
||||||
if (offset < 0) {
|
if (offset < 0) {
|
||||||
throw new IOException("Negative seek offset: " + offset);
|
throw new IOException("Negative seek offset: " + offset);
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
package dev.quoije.umd.playlist;
|
||||||
|
|
||||||
|
import dev.quoije.umd.iso.UmdIsoFile;
|
||||||
|
import dev.quoije.umd.iso.UmdIsoReader;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed contents of {@code UMD_VIDEO/PLAYLIST.UMD}: the list of MPS streams
|
||||||
|
* (playlist items) and their chapter markers.
|
||||||
|
*/
|
||||||
|
public final class Playlist {
|
||||||
|
|
||||||
|
public static final int UMDV_MAGIC = 0x56444D55; // "UMDV"
|
||||||
|
|
||||||
|
private final List<Stream> streams;
|
||||||
|
|
||||||
|
private Playlist(List<Stream> streams) {
|
||||||
|
this.streams = List.copyOf(streams);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Stream> streams() {
|
||||||
|
return streams;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single MPS stream (playlist item). */
|
||||||
|
public record Stream(
|
||||||
|
String name,
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
int firstTimestamp,
|
||||||
|
int lastTimestamp,
|
||||||
|
List<Marker> markers,
|
||||||
|
int playListNumber) {
|
||||||
|
|
||||||
|
/** Returns the 0-based chapter index for a 90 kHz timestamp. */
|
||||||
|
public int getChapterNumber(long timestamp) {
|
||||||
|
int marker = -1;
|
||||||
|
for (int i = 0; i < markers.size(); i++) {
|
||||||
|
if (markers.get(i).timestamp() <= timestamp) {
|
||||||
|
marker = i;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return marker;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A chapter marker within a stream. */
|
||||||
|
public record Marker(String name, long timestamp) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Playlist parse(UmdIsoReader iso) throws IOException {
|
||||||
|
List<Stream> streams = new ArrayList<>();
|
||||||
|
|
||||||
|
try (UmdIsoFile file = iso.getFile("UMD_VIDEO/PLAYLIST.UMD")) {
|
||||||
|
int umdvMagic = file.readInt();
|
||||||
|
file.readInt(); // version
|
||||||
|
int globalDataOffset = endianSwap32(file.readInt());
|
||||||
|
|
||||||
|
file.seek(globalDataOffset);
|
||||||
|
|
||||||
|
file.readInt(); // playlist size (big-endian)
|
||||||
|
int playListTracksNum = endianSwap16(file.readShort());
|
||||||
|
file.skipBytes(2); // NULL
|
||||||
|
|
||||||
|
if (umdvMagic != UMDV_MAGIC) {
|
||||||
|
throw new IOException(String.format("Invalid PLAYLIST.UMD magic 0x%08X", umdvMagic));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < playListTracksNum; i++) {
|
||||||
|
file.skipBytes(2); // 0x035C
|
||||||
|
file.skipBytes(2); // 0x0310
|
||||||
|
file.skipBytes(2); // 0x0332
|
||||||
|
file.skipBytes(30); // NULL
|
||||||
|
file.skipBytes(2); // 0x02E8
|
||||||
|
file.readShort(); // unknown
|
||||||
|
readByteHexTo10(file); // release date (year, high digits)
|
||||||
|
readByteHexTo10(file); // release date (year, low digits)
|
||||||
|
file.readUnsignedByte(); // release date day
|
||||||
|
file.readUnsignedByte(); // release date month
|
||||||
|
file.skipBytes(4); // NULL
|
||||||
|
file.skipBytes(4); // unknown (0x00000900)
|
||||||
|
|
||||||
|
int nameLength = file.readUnsignedByte();
|
||||||
|
byte[] nameBuffer = new byte[nameLength];
|
||||||
|
file.read(nameBuffer);
|
||||||
|
file.skipBytes(732 - nameLength); // NULL area (0x2DC)
|
||||||
|
|
||||||
|
int streamHeight = file.readUnsignedByte() * 0x10;
|
||||||
|
file.skipBytes(2); // NULL
|
||||||
|
file.skipBytes(4); // 0x00010000
|
||||||
|
file.skipBytes(1); // NULL
|
||||||
|
int streamWidth = file.readUnsignedByte() * 0x10;
|
||||||
|
file.skipBytes(1); // NULL
|
||||||
|
|
||||||
|
int streamNameCharsNum = file.readUnsignedByte();
|
||||||
|
byte[] stringBuf = new byte[streamNameCharsNum];
|
||||||
|
file.read(stringBuf);
|
||||||
|
String streamName = new String(stringBuf, StandardCharsets.US_ASCII);
|
||||||
|
file.skipBytes(8 - streamNameCharsNum); // NULL chars
|
||||||
|
|
||||||
|
file.skipBytes(2); // NULL
|
||||||
|
int streamFirstTimestamp = endianSwap32(file.readInt());
|
||||||
|
file.skipBytes(2); // NULL
|
||||||
|
int streamLastTimestamp = endianSwap32(file.readInt());
|
||||||
|
file.skipBytes(2); // NULL
|
||||||
|
|
||||||
|
int streamMarkerDataLength = endianSwap16(file.readShort());
|
||||||
|
List<Marker> markers = new ArrayList<>();
|
||||||
|
if (streamMarkerDataLength > 0) {
|
||||||
|
int streamMarkersNum = endianSwap16(file.readShort());
|
||||||
|
for (int j = 0; j < streamMarkersNum; j++) {
|
||||||
|
file.skipBytes(1); // 0x05
|
||||||
|
int markerCharsNum = file.readUnsignedByte();
|
||||||
|
file.skipBytes(4); // NULL
|
||||||
|
long markerTimestamp = endianSwap32(file.readInt()) & 0xFFFFFFFFL;
|
||||||
|
file.skipBytes(2); // NULL
|
||||||
|
file.skipBytes(4); // NULL
|
||||||
|
|
||||||
|
byte[] markerBuf = new byte[markerCharsNum];
|
||||||
|
file.read(markerBuf);
|
||||||
|
String markerName = new String(markerBuf, StandardCharsets.US_ASCII);
|
||||||
|
file.skipBytes(24 - markerCharsNum);
|
||||||
|
|
||||||
|
markers.add(new Marker(markerName, markerTimestamp));
|
||||||
|
}
|
||||||
|
file.skip(2); // NULL
|
||||||
|
}
|
||||||
|
|
||||||
|
streams.add(new Stream(streamName, streamWidth, streamHeight,
|
||||||
|
streamFirstTimestamp, streamLastTimestamp, List.copyOf(markers), i + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Playlist(streams);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int readByteHexTo10(UmdIsoFile file) throws IOException {
|
||||||
|
int hex = file.readUnsignedByte();
|
||||||
|
return (hex >> 4) * 10 + (hex & 0x0F);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int endianSwap16(int value) {
|
||||||
|
return ((value & 0xFF) << 8) | ((value >>> 8) & 0xFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int endianSwap32(int value) {
|
||||||
|
return Integer.reverseBytes(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,4 +2,5 @@
|
|||||||
module dev.quoije.umd.core {
|
module dev.quoije.umd.core {
|
||||||
exports dev.quoije.umd.iso;
|
exports dev.quoije.umd.iso;
|
||||||
exports dev.quoije.umd.sfo;
|
exports dev.quoije.umd.sfo;
|
||||||
|
exports dev.quoije.umd.playlist;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
package dev.quoije.umd.playlist;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class PlaylistTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void chapterNumberMapsTimestampToMarker() {
|
||||||
|
Playlist.Stream stream = new Playlist.Stream(
|
||||||
|
"00001", 352, 480, 90000, 200000,
|
||||||
|
List.of(new Playlist.Marker("A", 90000), new Playlist.Marker("B", 150000)),
|
||||||
|
1);
|
||||||
|
|
||||||
|
assertThat(stream.getChapterNumber(0)).isEqualTo(-1);
|
||||||
|
assertThat(stream.getChapterNumber(90000)).isEqualTo(0);
|
||||||
|
assertThat(stream.getChapterNumber(149999)).isEqualTo(0);
|
||||||
|
assertThat(stream.getChapterNumber(150000)).isEqualTo(1);
|
||||||
|
assertThat(stream.getChapterNumber(999999)).isEqualTo(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user