From ff1887066676bf771f65a8bf702609d5bf72dc26 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Tue, 28 Jul 2026 00:17:09 +0200
Subject: [PATCH 1/4] Add starting point for VobSub support
---
subtle_gui/common.py | 6 +-
subtle_gui/core/media.py | 5 +-
subtle_gui/core/mediafile.py | 3 +-
subtle_gui/formats/pgssubs.py | 2 +
subtle_gui/formats/vobsub.py | 119 ++++++++++++++++++++++++++++++++++
5 files changed, 130 insertions(+), 5 deletions(-)
create mode 100644 subtle_gui/formats/vobsub.py
diff --git a/subtle_gui/common.py b/subtle_gui/common.py
index 3c7c3c0..5336e40 100644
--- a/subtle_gui/common.py
+++ b/subtle_gui/common.py
@@ -31,7 +31,7 @@
logger = logging.getLogger(__name__)
-T_Subs = Literal["SRT"] | Literal["SSA"]
+T_Subs = Literal["SRT", "SSA", "IDX"]
def simplified(text: str) -> str:
@@ -155,8 +155,8 @@ def formatTS(value: int) -> str:
def decodeTS(value: str | None, default: int = 0, fmt: T_Subs = "SRT") -> int:
"""Decode a SRT time stamp to milliseconds."""
if isinstance(value, str):
- if fmt == "SRT" and len(value) >= 12:
- if value[2] == ":" and value[5] == ":" and value[8] in ".,":
+ if fmt in ("SRT", "IDX") and len(value) >= 12:
+ if value[2] == ":" and value[5] == ":" and value[8] in ":.,":
try:
return 3600000 * int(value[0:2]) + 60000 * int(value[3:5]) + int(value[6:8] + value[9:12])
except Exception:
diff --git a/subtle_gui/core/media.py b/subtle_gui/core/media.py
index 82c5cff..b2732eb 100644
--- a/subtle_gui/core/media.py
+++ b/subtle_gui/core/media.py
@@ -34,6 +34,7 @@
from subtle_gui.formats.pgssubs import PGSSubs
from subtle_gui.formats.srtsubs import SRTSubs
from subtle_gui.formats.ssasubs import SSASubs
+from subtle_gui.formats.vobsub import VobSubs
if TYPE_CHECKING:
from collections.abc import Iterable
@@ -141,8 +142,10 @@ def __init__(self, media: MediaData, info: dict) -> None:
self._wrapper = SRTSubs()
elif codec_id == "S_TEXT/ASS" or codec_nm == "SubStationAlpha":
self._wrapper = SSASubs()
+ elif codec_id == "S_VOBSUB" or codec_nm == "VobSub":
+ self._wrapper = VobSubs()
else:
- logger.info("Unsupported subtitle format: %s", codec_id or codec_nm)
+ logger.info("Unsupported subtitle format: %s (%s)", codec_nm, codec_id)
##
# Properties
diff --git a/subtle_gui/core/mediafile.py b/subtle_gui/core/mediafile.py
index b4011cb..adccc08 100644
--- a/subtle_gui/core/mediafile.py
+++ b/subtle_gui/core/mediafile.py
@@ -65,6 +65,7 @@ class ContainerType(IntEnum):
ContainerType.SRT,
ContainerType.SSA_ASS,
ContainerType.PGSSUP,
+ ContainerType.VOBSUB,
)
@@ -104,7 +105,7 @@ def container(self) -> ContainerType:
def supported(self) -> bool:
"""True if the format is supported."""
try:
- return self._info["container"]["supported"]
+ return bool(self._info["container"]["supported"])
except Exception:
pass
return False
diff --git a/subtle_gui/formats/pgssubs.py b/subtle_gui/formats/pgssubs.py
index a8d9cf1..c678ac8 100644
--- a/subtle_gui/formats/pgssubs.py
+++ b/subtle_gui/formats/pgssubs.py
@@ -146,6 +146,8 @@ def _readData(self, path: Path) -> None:
class PGSFrame(FrameBase):
"""PGS Subtitle Frame Class."""
+ __slots__ = ("_ds",)
+
def __init__(self, index: int, ds: DisplaySet) -> None:
super().__init__(index=index)
self._ds = ds
diff --git a/subtle_gui/formats/vobsub.py b/subtle_gui/formats/vobsub.py
new file mode 100644
index 0000000..dabe30f
--- /dev/null
+++ b/subtle_gui/formats/vobsub.py
@@ -0,0 +1,119 @@
+"""
+Subtle - VobSub File Object
+===========================
+
+This file is a part of Subtle
+Copyright (C) Veronica Berglyd Olsen
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful, but
+WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+""" # noqa
+
+from __future__ import annotations
+
+import logging
+
+from typing import TYPE_CHECKING, NamedTuple
+
+from subtle_gui.common import decodeTS
+from subtle_gui.formats.base import FrameBase, SubtitlesBase
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from PyQt6.QtGui import QImage
+
+logger = logging.getLogger(__name__)
+
+
+class IdxEntry(NamedTuple):
+ """An entry in a VobSub IDX file."""
+
+ timestamp: int
+ filepos: int
+
+
+class VobSubs(SubtitlesBase):
+ """VobSub Subtitles."""
+
+ def __init__(self) -> None:
+ super().__init__()
+
+ # IDX Data
+ self._idx: list[IdxEntry] = []
+ self._forced: bool = False
+ self._palette: list[str] = []
+
+ def read(self, path: Path) -> None:
+ """Read a VobSub file."""
+ self._path = path.with_suffix(".sub")
+ try:
+ self._readIdxData(path.with_suffix(".idx"))
+ except Exception as exc:
+ logger.error("Could not read VobSub IDX file: %s", self._path, exc_info=exc)
+
+ def write(self, path: Path | None = None) -> None:
+ """Write a VobSub file."""
+ raise NotImplementedError("Cannot write VobSub files.")
+
+ def copyFrames(self, other: SubtitlesBase) -> None:
+ """Copy frames from another subtitle object."""
+ return super()._copyFrames(VobSubFrame, other)
+
+ ##
+ # Internal Functions
+ ##
+
+ def _readIdxData(self, path: Path) -> None:
+ """Read IDX data from file."""
+ self._idx = []
+ with open(path, mode="r", encoding="utf-8") as fo:
+ for line in fo:
+ if line.startswith("timestamp:"):
+ one, _, two = line.partition(",")
+ ts = decodeTS(one.partition(":")[2].strip(), fmt="IDX")
+ fp = int("0x" + two.partition(":")[2].strip(), 16)
+ self._idx.append(IdxEntry(timestamp=ts, filepos=fp))
+ elif line.startswith("forced subs:"):
+ self._forced = line.partition(":")[2].strip().lower() == "on"
+ elif line.startswith("palette:"):
+ self._palette = [p.strip() for p in line.partition(":")[2].split(",")]
+
+ print(self._idx[:10])
+
+ def _readSubData(self, path: Path) -> None:
+ """Read SUB data from file."""
+
+
+class VobSubFrame(FrameBase):
+ """VobSub Subtitle Frame."""
+
+ def __init__(self, index: int, start: int, end: int, text: list[str]) -> None:
+ super().__init__(index=index)
+ self._start = start
+ self._end = end
+ self._text = text
+
+ @classmethod
+ def fromFrame(cls, index: int, other: FrameBase) -> FrameBase:
+ """Populate from another frame."""
+ return cls(index, other.start, other.end, other.text)
+
+ @property
+ def imageBased(self) -> bool:
+ """VobSub frames are images."""
+ return True
+
+ def getImage(self) -> QImage:
+ """There is no image."""
+ raise NotImplementedError
From a9b11f8ca34208a0b551351db1152f4ed990c2cd Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Tue, 28 Jul 2026 21:42:12 +0200
Subject: [PATCH 2/4] PES/MPEG2 parsers in working order
---
subtle_gui/formats/vobsub.py | 209 ++++++++++++++++++++++++++++++++++-
1 file changed, 207 insertions(+), 2 deletions(-)
diff --git a/subtle_gui/formats/vobsub.py b/subtle_gui/formats/vobsub.py
index dabe30f..1058164 100644
--- a/subtle_gui/formats/vobsub.py
+++ b/subtle_gui/formats/vobsub.py
@@ -35,6 +35,9 @@
logger = logging.getLogger(__name__)
+HEADER_LEN = 6
+MPEG2_HEADER_LEN = 14
+
class IdxEntry(NamedTuple):
"""An entry in a VobSub IDX file."""
@@ -44,7 +47,10 @@ class IdxEntry(NamedTuple):
class VobSubs(SubtitlesBase):
- """VobSub Subtitles."""
+ """VobSub Subtitles.
+
+ Reference: https://github.com/SubtitleEdit/subtitleedit
+ """
def __init__(self) -> None:
super().__init__()
@@ -56,11 +62,15 @@ def __init__(self) -> None:
def read(self, path: Path) -> None:
"""Read a VobSub file."""
- self._path = path.with_suffix(".sub")
+ self._path = path
try:
self._readIdxData(path.with_suffix(".idx"))
except Exception as exc:
logger.error("Could not read VobSub IDX file: %s", self._path, exc_info=exc)
+ try:
+ self._readSubData(path.with_suffix(".sub"))
+ except Exception as exc:
+ logger.error("Could not read VobSub SUB file: %s", self._path, exc_info=exc)
def write(self, path: Path | None = None) -> None:
"""Write a VobSub file."""
@@ -93,6 +103,50 @@ def _readIdxData(self, path: Path) -> None:
def _readSubData(self, path: Path) -> None:
"""Read SUB data from file."""
+ with open(path, mode="rb") as fo:
+ for entry in self._idx[:10]:
+ fo.seek(entry.filepos)
+ buffer = fo.read(0x0800)
+ vsp = VobSubPack(buffer, entry) # noqa: F841
+
+
+def isMpeg2PackHeader(data: bytes) -> bool:
+ """Check if data is a Mpeg2 pack header."""
+ return len(data) >= 4 and int.from_bytes(data[0:4]) == 0x000001BA
+
+
+def isPrivateStream1(data: bytes, index: int) -> bool:
+ """Check if data is a private stream."""
+ return len(data) >= index + 4 and int.from_bytes(data[index + 3 : index + 4]) == 0xBD
+
+
+def isPrivateStream2(data: bytes, index: int) -> bool:
+ """Check if data is a private stream."""
+ return len(data) >= index + 4 and int.from_bytes(data[index + 3 : index + 4]) == 0xBF
+
+
+def isPaddingStream(data: bytes, index: int) -> bool:
+ """Check if data is a padding stream."""
+ return len(data) >= index + 4 and int.from_bytes(data[index + 3 : index + 4]) == 0xBE
+
+
+def isProgramEnd(data: bytes, index: int) -> bool:
+ """Check if data is a program end."""
+ return len(data) >= index + 4 and int.from_bytes(data[index + 3 : index + 4]) == 0xB9
+
+
+def isSubtitleStreamId(streamId: int) -> bool:
+ """Check if streamId is a subtitle stream."""
+ return 0x20 <= streamId <= 0x3F
+
+
+def isSubtitlePack(data: bytes) -> bool:
+ """Check if data is a subtitle pack."""
+ if isMpeg2PackHeader(data) and isPrivateStream1(data, MPEG2_HEADER_LEN):
+ length = int.from_bytes(data[MPEG2_HEADER_LEN + 8 : MPEG2_HEADER_LEN + 9])
+ offset = MPEG2_HEADER_LEN + 9 + length
+ return isSubtitleStreamId(int.from_bytes(data[offset : offset + 1]))
+ return False
class VobSubFrame(FrameBase):
@@ -117,3 +171,154 @@ def imageBased(self) -> bool:
def getImage(self) -> QImage:
"""There is no image."""
raise NotImplementedError
+
+
+class VobSubPack:
+ """A VobSub pack in a SUB file."""
+
+ __slots__ = ("_buffer", "_idx", "_mpeg2", "_pes")
+
+ def __init__(self, buffer: bytes, idxEntry: IdxEntry) -> None:
+ self._buffer = buffer
+ self._idx: IdxEntry = idxEntry
+ self._pes: PacketizedElementaryStream | None = None
+ self._mpeg2: Mpeg2Header | None = None
+
+ if isMpeg2PackHeader(buffer):
+ self._mpeg2 = Mpeg2Header(buffer)
+ self._pes = PacketizedElementaryStream(buffer, MPEG2_HEADER_LEN)
+ elif isPrivateStream1(buffer, 0):
+ self._pes = PacketizedElementaryStream(buffer, 0)
+
+ print(">", self._idx)
+ print(" ", self._mpeg2)
+ print(" ", self._pes)
+ if pes := self._pes:
+ print(f" PES Data: {int.from_bytes(pes.data[:8]):016X}... ({len(pes.data)} bytes)")
+
+
+class PacketizedElementaryStream:
+ """A packetized elementary stream (PES) in a VobSub file."""
+
+ __slots__ = (
+ "_buffer",
+ "_data",
+ "_decodeTimestamp",
+ "_flags6",
+ "_flags7",
+ "_headerDataLength",
+ "_index",
+ "_length",
+ "_presentationTimestamp",
+ "_startCode",
+ "_streamId",
+ "_subPictureStreamId",
+ "_valid",
+ )
+
+ def __init__(self, buffer: bytes, index: int) -> None:
+ self._buffer = buffer
+ self._data = b""
+ self._index = index
+ self._valid = len(buffer) >= index + 8
+
+ self._startCode = int.from_bytes(buffer[index : index + 3])
+ self._streamId = int.from_bytes(buffer[index + 3 : index + 4])
+ self._length = int.from_bytes(buffer[index + 4 : index + 6])
+ self._flags6 = int.from_bytes(buffer[index + 6 : index + 7])
+ self._flags7 = int.from_bytes(buffer[index + 7 : index + 8])
+ self._headerDataLength = int.from_bytes(buffer[index + 8 : index + 9])
+
+ # idx6 = buffer[index + 6]
+ # self._originalOrCopy = idx6 & 0b00000001
+ # self._copyright = idx6 & 0b00000010
+ # self._dataAlignmentIndicator = idx6 & 0b00000100
+ # self._priority = idx6 & 0b00001000
+ # self._scramblingControl = (idx6 & 0b00110000) >> 4
+
+ # idx7 = buffer[index + 7]
+ # self._extensionFlag = idx7 & 0b00000010
+ # self._additionalCopyInfoFlag = idx7 & 0b00000100
+ # self._crcFlag = idx7 & 0b00001000
+ # self._dsmTrickModeFlag = idx7 & 0b00001000
+ # self._esRateFlag = idx7 & 0b00010000
+ # self._elementaryStreamClockReferenceFlag = idx7 & 0b00100000
+ # self._presentationTimestampDecodeTimestampFlags = idx7 >> 6
+
+ self._subPictureStreamId: int | None = None
+ self._presentationTimestamp: int | None = None
+ self._decodeTimestamp: int | None = None
+
+ self._processData(index)
+
+ def __repr__(self) -> str:
+ """Return a string representation of the PES."""
+ return (
+ f""
+ )
+
+ @property
+ def data(self) -> bytes:
+ """Return the PES data."""
+ return self._data
+
+ ##
+ # Internal Functions
+ ##
+
+ def _processData(self, index: int) -> None:
+ """Process the PES data."""
+ buffer = self._buffer
+ length = len(buffer)
+
+ idOffset = index + 9 + self._headerDataLength
+ if length >= idOffset and self._streamId == 0xBD and 0x20 <= (subId := buffer[idOffset]) < 0x40:
+ self._subPictureStreamId = subId
+
+ tempIdx = index + 9
+ ptsDtsFlags = self._flags7 >> 6
+
+ if length >= tempIdx + 5 and ptsDtsFlags in (0b10, 0b11):
+ pts = buffer[tempIdx + 4] >> 1
+ pts += buffer[tempIdx + 3] << 7
+ pts += (buffer[tempIdx + 2] & 0b11111110) << 14
+ pts += buffer[tempIdx + 1] << 22
+ pts += (buffer[tempIdx] & 0b00001110) << 29
+ self._presentationTimestamp = pts
+ tempIdx += 5
+
+ if length >= tempIdx + 5 and ptsDtsFlags == 0b11:
+ dts = buffer[tempIdx + 4] >> 1
+ dts += buffer[tempIdx + 3] << 7
+ dts += (buffer[tempIdx + 2] & 0b11111110) << 14
+ dts += buffer[tempIdx + 1] << 22
+ dts += (buffer[tempIdx] & 0b00001110) << 29
+ self._decodeTimestamp = dts
+
+ dataOffset = idOffset + 1
+ dataLength = self._length - self._headerDataLength - 4
+ if dataLength > 0:
+ self._data = buffer[dataOffset : min(dataOffset + dataLength, length)]
+
+
+class Mpeg2Header:
+ """An Mpeg2 header in a VobSub file."""
+
+ __slots__ = ("_buffer", "_muxRate", "_packId", "_startCode", "_stuffingLength")
+
+ def __init__(self, buffer: bytes) -> None:
+ self._buffer = buffer
+ self._startCode = int.from_bytes(buffer[0:3])
+ self._packId = int.from_bytes(buffer[3:4])
+ self._muxRate = int.from_bytes(buffer[10:13]) >> 2
+ self._stuffingLength = int.from_bytes(buffer[13:14]) & 0b00000111
+
+ def __repr__(self) -> str:
+ """Return a string representation of the Mpeg2 header."""
+ return (
+ f""
+ )
From 29246b7d20ba0ce082503c6f12575a13b58f7560 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Tue, 28 Jul 2026 22:14:12 +0200
Subject: [PATCH 3/4] Update license info
---
LICENSE | 55 -----------------------------------
subtle_gui/formats/base.py | 2 +-
subtle_gui/formats/pgssubs.py | 4 +--
subtle_gui/formats/srtsubs.py | 4 +--
subtle_gui/formats/ssasubs.py | 2 +-
subtle_gui/formats/vobsub.py | 30 +++++++++++++++----
subtle_gui/guimain.py | 2 +-
7 files changed, 31 insertions(+), 68 deletions(-)
diff --git a/LICENSE b/LICENSE
index f288702..281d399 100644
--- a/LICENSE
+++ b/LICENSE
@@ -617,58 +617,3 @@ reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
-
- END OF TERMS AND CONDITIONS
-
- How to Apply These Terms to Your New Programs
-
- If you develop a new program, and you want it to be of the greatest
-possible use to the public, the best way to achieve this is to make it
-free software which everyone can redistribute and change under these terms.
-
- To do so, attach the following notices to the program. It is safest
-to attach them to the start of each source file to most effectively
-state the exclusion of warranty; and each file should have at least
-the "copyright" line and a pointer to where the full notice is found.
-
-
- Copyright (C)
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with this program. If not, see .
-
-Also add information on how to contact you by electronic and paper mail.
-
- If the program does terminal interaction, make it output a short
-notice like this when it starts in an interactive mode:
-
- Copyright (C)
- This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
- This is free software, and you are welcome to redistribute it
- under certain conditions; type `show c' for details.
-
-The hypothetical commands `show w' and `show c' should show the appropriate
-parts of the General Public License. Of course, your program's commands
-might be different; for a GUI interface, you would use an "about box".
-
- You should also get your employer (if you work as a programmer) or school,
-if any, to sign a "copyright disclaimer" for the program, if necessary.
-For more information on this, and how to apply and follow the GNU GPL, see
-.
-
- The GNU General Public License does not permit incorporating your program
-into proprietary programs. If your program is a subroutine library, you
-may consider it more useful to permit linking proprietary applications with
-the library. If this is what you want to do, use the GNU Lesser General
-Public License instead of this License. But first, please read
-.
diff --git a/subtle_gui/formats/base.py b/subtle_gui/formats/base.py
index 4816aec..924f357 100644
--- a/subtle_gui/formats/base.py
+++ b/subtle_gui/formats/base.py
@@ -1,5 +1,5 @@
"""
-Subtle – Subtitles Base
+Subtle - Subtitles Base
=======================
This file is a part of Subtle
diff --git a/subtle_gui/formats/pgssubs.py b/subtle_gui/formats/pgssubs.py
index c678ac8..6a480fe 100644
--- a/subtle_gui/formats/pgssubs.py
+++ b/subtle_gui/formats/pgssubs.py
@@ -1,6 +1,6 @@
"""
-Subtle – Core PGS Reader
-========================
+Subtle - PGS Reader
+===================
This file is a part of Subtle
Copyright (C) Veronica Berglyd Olsen
diff --git a/subtle_gui/formats/srtsubs.py b/subtle_gui/formats/srtsubs.py
index bedcf84..eae7848 100644
--- a/subtle_gui/formats/srtsubs.py
+++ b/subtle_gui/formats/srtsubs.py
@@ -1,6 +1,6 @@
"""
-Subtle – SRT File Object
-========================
+Subtle - SRT File Reader/Writer
+===============================
This file is a part of Subtle
Copyright (C) Veronica Berglyd Olsen
diff --git a/subtle_gui/formats/ssasubs.py b/subtle_gui/formats/ssasubs.py
index d072c59..86dab3d 100644
--- a/subtle_gui/formats/ssasubs.py
+++ b/subtle_gui/formats/ssasubs.py
@@ -1,5 +1,5 @@
"""
-Subtle – SSA File Object
+Subtle - SSA File Reader
========================
This file is a part of Subtle
diff --git a/subtle_gui/formats/vobsub.py b/subtle_gui/formats/vobsub.py
index 1058164..943d793 100644
--- a/subtle_gui/formats/vobsub.py
+++ b/subtle_gui/formats/vobsub.py
@@ -1,6 +1,6 @@
"""
-Subtle - VobSub File Object
-===========================
+Subtle - VobSub Reader
+======================
This file is a part of Subtle
Copyright (C) Veronica Berglyd Olsen
@@ -17,6 +17,27 @@
You should have received a copy of the GNU General Public License
along with this program. If not, see .
+
+----
+
+Much of the parser code in this file is based on the source code of
+SubtitleEdit, which is licensed under the MIT License.
+
+Source: https://github.com/SubtitleEdit/subtitleedit
+
+MIT License
+
+Copyright (C) 2026 Nikolaj Olsson
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
""" # noqa
from __future__ import annotations
@@ -47,10 +68,7 @@ class IdxEntry(NamedTuple):
class VobSubs(SubtitlesBase):
- """VobSub Subtitles.
-
- Reference: https://github.com/SubtitleEdit/subtitleedit
- """
+ """VobSub Subtitles."""
def __init__(self) -> None:
super().__init__()
diff --git a/subtle_gui/guimain.py b/subtle_gui/guimain.py
index 2acb0c7..e479af6 100644
--- a/subtle_gui/guimain.py
+++ b/subtle_gui/guimain.py
@@ -1,5 +1,5 @@
"""
-Subtle – GUI Main Window
+Subtle - GUI Main Window
========================
This file is a part of Subtle
From 0525769bb74d6cc28d565a343e136b79fccf3a69 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Wed, 29 Jul 2026 00:10:18 +0200
Subject: [PATCH 4/4] Extract all frames from sub file
---
subtle_gui/formats/vobsub.py | 108 ++++++++++++++++++++++++++++-------
1 file changed, 87 insertions(+), 21 deletions(-)
diff --git a/subtle_gui/formats/vobsub.py b/subtle_gui/formats/vobsub.py
index 943d793..b1947c1 100644
--- a/subtle_gui/formats/vobsub.py
+++ b/subtle_gui/formats/vobsub.py
@@ -43,16 +43,19 @@
from __future__ import annotations
import logging
+import os
+from itertools import pairwise
from typing import TYPE_CHECKING, NamedTuple
+from PyQt6.QtGui import QImage
+
from subtle_gui.common import decodeTS
from subtle_gui.formats.base import FrameBase, SubtitlesBase
if TYPE_CHECKING:
from pathlib import Path
- from PyQt6.QtGui import QImage
logger = logging.getLogger(__name__)
@@ -122,10 +125,38 @@ def _readIdxData(self, path: Path) -> None:
def _readSubData(self, path: Path) -> None:
"""Read SUB data from file."""
with open(path, mode="rb") as fo:
- for entry in self._idx[:10]:
- fo.seek(entry.filepos)
+ fo.seek(0, os.SEEK_END)
+ self._idx.append(IdxEntry(timestamp=-1, filepos=fo.tell()))
+ for i, (cEntry, nEntry) in enumerate(pairwise(self._idx)):
+ position = cEntry.filepos
+ fo.seek(position)
buffer = fo.read(0x0800)
- vsp = VobSubPack(buffer, entry) # noqa: F841
+
+ vsp = VobSubPack(buffer)
+ frame = VobSubFrame(index=i, idx=cEntry, vsp=vsp)
+ self._frames.append(frame)
+
+ position += 0x800
+ currentId = vsp.pes.subPictureStreamId if vsp.pes else None
+ while position < nEntry.filepos:
+ fo.seek(position)
+ buffer = fo.read(0x0800)
+
+ vsp = VobSubPack(buffer)
+ if vsp.pes and vsp.pes.subPictureStreamId == currentId:
+ frame.appendPack(vsp)
+ position += 0x800
+ else:
+ logger.warning(
+ "Found new stream ID %s at position %d, expected %s. Stopping frame read.",
+ vsp.pes.subPictureStreamId if vsp.pes else None,
+ position,
+ currentId,
+ )
+ break
+
+ if i < 10:
+ frame.debug()
def isMpeg2PackHeader(data: bytes) -> bool:
@@ -170,35 +201,56 @@ def isSubtitlePack(data: bytes) -> bool:
class VobSubFrame(FrameBase):
"""VobSub Subtitle Frame."""
- def __init__(self, index: int, start: int, end: int, text: list[str]) -> None:
+ __slots__ = ("_idx", "_packs")
+
+ def __init__(self, index: int, idx: IdxEntry, vsp: VobSubPack) -> None:
super().__init__(index=index)
- self._start = start
- self._end = end
- self._text = text
+ self._idx = idx
+ self._packs = [vsp]
+
+ vspTs = vsp.pes.presentationTimestamp if vsp.pes else -1
+ idxTs = idx.timestamp
+ if vspTs != idxTs:
+ logger.warning("VobSub frame timestamp mismatch: IDX=%d, PES=%s", idxTs, vspTs)
+
+ self._start = vspTs or idxTs
+ self._end = self._start + 2000
+
+ def debug(self) -> None:
+ """Print a debug string for the frame."""
+ print(">", self._idx)
+ for pack in self._packs:
+ print(" ", pack.mpeg2)
+ print(" ", pack.pes)
+ if pes := pack.pes:
+ print(f" PES Data: {int.from_bytes(pes.data[:8]):016X}... ({len(pes.data)} bytes)")
@classmethod
def fromFrame(cls, index: int, other: FrameBase) -> FrameBase:
- """Populate from another frame."""
- return cls(index, other.start, other.end, other.text)
+ """Not implemented."""
+ raise NotImplementedError
@property
def imageBased(self) -> bool:
- """VobSub frames are images."""
+ """Check if the frame is image based."""
return True
+ def appendPack(self, vsp: VobSubPack) -> None:
+ """Append a VobSub pack to the frame."""
+ self._packs.append(vsp)
+
def getImage(self) -> QImage:
- """There is no image."""
- raise NotImplementedError
+ """Return the rendered image."""
+ return QImage()
class VobSubPack:
"""A VobSub pack in a SUB file."""
- __slots__ = ("_buffer", "_idx", "_mpeg2", "_pes")
+ __slots__ = ("_buffer", "_mpeg2", "_pes")
- def __init__(self, buffer: bytes, idxEntry: IdxEntry) -> None:
+ def __init__(self, buffer: bytes) -> None:
self._buffer = buffer
- self._idx: IdxEntry = idxEntry
self._pes: PacketizedElementaryStream | None = None
self._mpeg2: Mpeg2Header | None = None
@@ -208,11 +260,15 @@ def __init__(self, buffer: bytes, idxEntry: IdxEntry) -> None:
elif isPrivateStream1(buffer, 0):
self._pes = PacketizedElementaryStream(buffer, 0)
- print(">", self._idx)
- print(" ", self._mpeg2)
- print(" ", self._pes)
- if pes := self._pes:
- print(f" PES Data: {int.from_bytes(pes.data[:8]):016X}... ({len(pes.data)} bytes)")
+ @property
+ def mpeg2(self) -> Mpeg2Header | None:
+ """Return the Mpeg2 header."""
+ return self._mpeg2
+
+ @property
+ def pes(self) -> PacketizedElementaryStream | None:
+ """Return the PES."""
+ return self._pes
class PacketizedElementaryStream:
@@ -278,6 +334,16 @@ def __repr__(self) -> str:
f"PresentationTimestamp={self._presentationTimestamp}, DecodeTimestamp={self._decodeTimestamp}>"
)
+ @property
+ def subPictureStreamId(self) -> int | None:
+ """Return the sub-picture stream ID."""
+ return self._subPictureStreamId
+
+ @property
+ def presentationTimestamp(self) -> int:
+ """Return the presentation timestamp."""
+ return (self._presentationTimestamp or -90) // 90
+
@property
def data(self) -> bytes:
"""Return the PES data."""