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/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/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 a8d9cf1..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 @@ -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/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 new file mode 100644 index 0000000..b1947c1 --- /dev/null +++ b/subtle_gui/formats/vobsub.py @@ -0,0 +1,408 @@ +""" +Subtle - VobSub Reader +====================== + +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 . + +---- + +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 + +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 + + +logger = logging.getLogger(__name__) + +HEADER_LEN = 6 +MPEG2_HEADER_LEN = 14 + + +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 + 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.""" + 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.""" + with open(path, mode="rb") as fo: + 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) + 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: + """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): + """VobSub Subtitle Frame.""" + + __slots__ = ("_idx", "_packs") + + def __init__(self, index: int, idx: IdxEntry, vsp: VobSubPack) -> None: + super().__init__(index=index) + 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: + """Not implemented.""" + raise NotImplementedError + + @property + def imageBased(self) -> bool: + """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: + """Return the rendered image.""" + return QImage() + + +class VobSubPack: + """A VobSub pack in a SUB file.""" + + __slots__ = ("_buffer", "_mpeg2", "_pes") + + def __init__(self, buffer: bytes) -> None: + self._buffer = buffer + 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) + + @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: + """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 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.""" + 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"" + ) 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