Skip to content

chapters

Chapters

Source code in muxtools/misc/chapters.py
class Chapters:
    chapters: list[Chapter] = []
    fps: Fraction | PathLike

    def __init__(
        self, chapter_source: PathLike | GlobSearch | Chapter | list[Chapter], fps: Fraction | PathLike = Fraction(24000, 1001), _print: bool = True
    ) -> None:
        """
        Convenience class for chapters

        :param chapter_source:      Input either txt with ogm chapters, xml or (a list of) self defined chapters.
        :param fps:                 Needed for timestamp convertion. Assumes 24000/1001 by default. Also accepts a timecode (v2) file.
        :param _print:              Prints chapters after parsing and after trimming.
        """
        self.fps = fps
        if isinstance(chapter_source, tuple):
            self.chapters = [chapter_source]
        elif isinstance(chapter_source, list):
            self.chapters = chapter_source
        else:
            # Handle both OGM .txt files and xml files
            if isinstance(chapter_source, GlobSearch):
                chapter_source = chapter_source.paths[0] if isinstance(chapter_source.paths, list) else chapter_source.paths
            chapter_source = chapter_source if isinstance(chapter_source, Path) else Path(chapter_source)

            self.chapters = parse_xml(chapter_source) if chapter_source.suffix.lower() == ".xml" else parse_ogm(chapter_source)
            if _print:
                self.print()

        # Convert all framenumbers to timedeltas
        chapters = []
        for ch in self.chapters:
            if isinstance(ch[0], int):
                current = list(ch)
                current[0] = frame_to_timedelta(current[0], self.fps)
                chapters.append(tuple(current))
            else:
                chapters.append(ch)
        self.chapters = chapters

    def trim(self: ChaptersSelf, trim_start: int = 0, trim_end: int = 0, num_frames: int = 0) -> ChaptersSelf:
        """
        Trims the chapters
        """
        if trim_start > 0:
            chapters: list[Chapter] = []
            for chapter in self.chapters:
                if timedelta_to_frame(chapter[0]) == 0:
                    chapters.append(chapter)
                    continue
                if timedelta_to_frame(chapter[0]) - trim_start < 0:
                    continue
                current = list(chapter)
                current[0] = current[0] - frame_to_timedelta(trim_start, self.fps)
                if num_frames:
                    if current[0] > frame_to_timedelta(num_frames - 1, self.fps):
                        continue
                chapters.append(tuple(current))

            self.chapters = chapters
        if trim_end != 0:
            if trim_end > 0:
                chapters: list[Chapter] = []
                for chapter in self.chapters:
                    if timedelta_to_frame(chapter[0], self.fps) < trim_end:
                        chapters.append(chapter)
                self.chapters = chapters

        return self

    def set_names(self: ChaptersSelf, names: list[str | None]) -> ChaptersSelf:
        """
        Renames the chapters

        :param names:   List of names
        """
        old: list[str] = [chapter[1] for chapter in self.chapters]
        if len(names) > len(old):
            self.print()
            raise error("Chapters: too many names!", self)
        if len(names) < len(old):
            names += [None] * (len(old) - len(names))

        chapters: list[Chapter] = []
        for i, name in enumerate(names):
            current = list(self.chapters[i])
            current[1] = name
            chapters.append(tuple(current))

        self.chapters = chapters
        return self

    def add(self: ChaptersSelf, chapters: Chapter | list[Chapter], index: int = 0) -> ChaptersSelf:
        """
        Adds a chapter at the specified index
        """
        if isinstance(chapters, tuple):
            chapters = [chapters]
        else:
            chapters = chapters

        converted = []
        for ch in chapters:
            if isinstance(ch[0], int):
                current = list(ch)
                current[0] = frame_to_timedelta(current[0], self.fps)
                converted.append(tuple(current))
            else:
                converted.append(ch)

        for ch in converted:
            self.chapters.insert(index, ch)
            index += 1
        return self

    def shift_chapter(self: ChaptersSelf, chapter: int = 0, shift_amount: int = 0) -> ChaptersSelf:
        """
        Used to shift a single chapter by x frames

        :param chapter:         Chapter number (starting at 0)
        :param shift_amount:    Frames to shift by
        """
        ch = list(self.chapters[chapter])
        shift_delta = frame_to_timedelta(abs(shift_amount), self.fps)
        if shift_amount < 0:
            shifted_frame = ch[0] - shift_delta
        else:
            shifted_frame = ch[0] + shift_delta

        if shifted_frame.total_seconds() > 0:
            ch[0] = shifted_frame
        else:
            ch[0] = timedelta(seconds=0)
        self.chapters[chapter] = tuple(ch)
        return self

    def shift(self: ChaptersSelf, shift_amount: int) -> ChaptersSelf:
        """
        Shifts all chapters by x frames.

        :param shift_amount:    Frames to shift by
        """
        return [self.shift_chapter(i, shift_amount) for i, _ in enumerate(self.chapters)][-1]

    def print(self: ChaptersSelf) -> ChaptersSelf:
        """
        Prettier print for these because default timedelta formatting sucks
        """
        info("Chapters:")
        for time, name in self.chapters:
            print(f"{name}: {format_timedelta(time)} | {timedelta_to_frame(time, self.fps)}")
        print("", end="\n")
        return self

    def to_file(self: ChaptersSelf, out: PathLike | None = None) -> str:
        """
        Outputs the chapters to an OGM file

        :param out:     Can be either a directory or a full file path
        """
        if not out:
            out = get_workdir()
        out = ensure_path(out, self)
        if out.is_dir():
            out_file = os.path.join(out, "chapters.txt")
        else:
            out_file = out
        with open(out_file, "w", encoding="UTF-8") as f:
            f.writelines(
                [
                    f"CHAPTER{i:02d}={format_timedelta(chapter[0])}\nCHAPTER{i:02d}NAME=" f'{chapter[1] if chapter[1] else ""}\n'
                    for i, chapter in enumerate(self.chapters)
                ]
            )
        return out_file

    @staticmethod
    def from_sub(
        file: PathLike | SubFile, fps: Fraction | PathLike = Fraction(24000, 1001), _print: bool = True, encoding: str = "utf_8_sig"
    ) -> "Chapters":
        """
        Extract chapters from an ass file or a SubFile.

        :param file:            Input ass file or SubFile
        :param fps:             FPS passed to the chapter class for further operations. Also accepts a timecode (v2) file.
        :param _print:          Prints the chapters after parsing
        :param encoding:        Encoding used to read the ass file if need be
        """
        from ass import parse_file, Comment

        if isinstance(file, SubFile):
            doc = file._read_doc()
        else:
            file = ensure_path_exists(file, "Chapters")
            with open(file if not file else file, "r", encoding=encoding) as reader:
                doc = parse_file(reader)

        pattern = re.compile(r"\{([^\\].+?)\}")
        chapters = list[Chapter]()
        for line in doc.events:
            effect = str(line.effect).lower()
            if "chapter" in effect or "chptr" in effect:
                match = pattern.search(line.text)
                if match:
                    chapters.append((line.start, match.group(1)))
                elif isinstance(line, Comment) and line.text:
                    chapters.append((line.start, str(line.text).strip()))
                else:
                    warn(f"Chapter {(len(chapters) + 1):02.0f} does not have a name!", "Chapters")
                    chapters.append((line.start, ""))

        if not chapters:
            warn("Could not find any chapters in subtitle!", "Chapters")
        ch = Chapters(chapters, fps)
        if _print and chapters:
            ch.print()
        return ch

    @staticmethod
    def from_mkv(file: PathLike, fps: Fraction | PathLike = Fraction(24000, 1001), _print: bool = True, quiet: bool = True) -> "Chapters":
        """
        Extract chapters from mkv.

        :param file:            Input mkv file
        :param fps:             FPS passed to the chapter class for further operations. Also accepts a timecode (v2) file.
        :param _print:          Prints the chapters after parsing
        """
        caller = "Chapters.from_mkv"
        file = ensure_path_exists(file, caller)

        mkvextract = get_executable("mkvextract")
        out = Path(get_temp_workdir(), f"{file.stem}_chapters.txt")
        args = [mkvextract, str(file), "chapters", "-s", str(out)]
        if run_commandline(args, quiet):
            raise error("Failed to extract chapters!", caller)
        chapters = Chapters(out, fps, _print)
        clean_temp_files()
        return chapters

__init__(chapter_source, fps=Fraction(24000, 1001), _print=True)

Convenience class for chapters

Parameters:

Name Type Description Default
chapter_source PathLike | GlobSearch | Chapter | list[Chapter]

Input either txt with ogm chapters, xml or (a list of) self defined chapters.

required
fps Fraction | PathLike

Needed for timestamp convertion. Assumes 24000/1001 by default. Also accepts a timecode (v2) file.

Fraction(24000, 1001)
_print bool

Prints chapters after parsing and after trimming.

True
Source code in muxtools/misc/chapters.py
def __init__(
    self, chapter_source: PathLike | GlobSearch | Chapter | list[Chapter], fps: Fraction | PathLike = Fraction(24000, 1001), _print: bool = True
) -> None:
    """
    Convenience class for chapters

    :param chapter_source:      Input either txt with ogm chapters, xml or (a list of) self defined chapters.
    :param fps:                 Needed for timestamp convertion. Assumes 24000/1001 by default. Also accepts a timecode (v2) file.
    :param _print:              Prints chapters after parsing and after trimming.
    """
    self.fps = fps
    if isinstance(chapter_source, tuple):
        self.chapters = [chapter_source]
    elif isinstance(chapter_source, list):
        self.chapters = chapter_source
    else:
        # Handle both OGM .txt files and xml files
        if isinstance(chapter_source, GlobSearch):
            chapter_source = chapter_source.paths[0] if isinstance(chapter_source.paths, list) else chapter_source.paths
        chapter_source = chapter_source if isinstance(chapter_source, Path) else Path(chapter_source)

        self.chapters = parse_xml(chapter_source) if chapter_source.suffix.lower() == ".xml" else parse_ogm(chapter_source)
        if _print:
            self.print()

    # Convert all framenumbers to timedeltas
    chapters = []
    for ch in self.chapters:
        if isinstance(ch[0], int):
            current = list(ch)
            current[0] = frame_to_timedelta(current[0], self.fps)
            chapters.append(tuple(current))
        else:
            chapters.append(ch)
    self.chapters = chapters

add(chapters, index=0)

Adds a chapter at the specified index

Source code in muxtools/misc/chapters.py
def add(self: ChaptersSelf, chapters: Chapter | list[Chapter], index: int = 0) -> ChaptersSelf:
    """
    Adds a chapter at the specified index
    """
    if isinstance(chapters, tuple):
        chapters = [chapters]
    else:
        chapters = chapters

    converted = []
    for ch in chapters:
        if isinstance(ch[0], int):
            current = list(ch)
            current[0] = frame_to_timedelta(current[0], self.fps)
            converted.append(tuple(current))
        else:
            converted.append(ch)

    for ch in converted:
        self.chapters.insert(index, ch)
        index += 1
    return self

from_mkv(file, fps=Fraction(24000, 1001), _print=True, quiet=True) staticmethod

Extract chapters from mkv.

Parameters:

Name Type Description Default
file PathLike

Input mkv file

required
fps Fraction | PathLike

FPS passed to the chapter class for further operations. Also accepts a timecode (v2) file.

Fraction(24000, 1001)
_print bool

Prints the chapters after parsing

True
Source code in muxtools/misc/chapters.py
@staticmethod
def from_mkv(file: PathLike, fps: Fraction | PathLike = Fraction(24000, 1001), _print: bool = True, quiet: bool = True) -> "Chapters":
    """
    Extract chapters from mkv.

    :param file:            Input mkv file
    :param fps:             FPS passed to the chapter class for further operations. Also accepts a timecode (v2) file.
    :param _print:          Prints the chapters after parsing
    """
    caller = "Chapters.from_mkv"
    file = ensure_path_exists(file, caller)

    mkvextract = get_executable("mkvextract")
    out = Path(get_temp_workdir(), f"{file.stem}_chapters.txt")
    args = [mkvextract, str(file), "chapters", "-s", str(out)]
    if run_commandline(args, quiet):
        raise error("Failed to extract chapters!", caller)
    chapters = Chapters(out, fps, _print)
    clean_temp_files()
    return chapters

from_sub(file, fps=Fraction(24000, 1001), _print=True, encoding='utf_8_sig') staticmethod

Extract chapters from an ass file or a SubFile.

Parameters:

Name Type Description Default
file PathLike | SubFile

Input ass file or SubFile

required
fps Fraction | PathLike

FPS passed to the chapter class for further operations. Also accepts a timecode (v2) file.

Fraction(24000, 1001)
_print bool

Prints the chapters after parsing

True
encoding str

Encoding used to read the ass file if need be

'utf_8_sig'
Source code in muxtools/misc/chapters.py
@staticmethod
def from_sub(
    file: PathLike | SubFile, fps: Fraction | PathLike = Fraction(24000, 1001), _print: bool = True, encoding: str = "utf_8_sig"
) -> "Chapters":
    """
    Extract chapters from an ass file or a SubFile.

    :param file:            Input ass file or SubFile
    :param fps:             FPS passed to the chapter class for further operations. Also accepts a timecode (v2) file.
    :param _print:          Prints the chapters after parsing
    :param encoding:        Encoding used to read the ass file if need be
    """
    from ass import parse_file, Comment

    if isinstance(file, SubFile):
        doc = file._read_doc()
    else:
        file = ensure_path_exists(file, "Chapters")
        with open(file if not file else file, "r", encoding=encoding) as reader:
            doc = parse_file(reader)

    pattern = re.compile(r"\{([^\\].+?)\}")
    chapters = list[Chapter]()
    for line in doc.events:
        effect = str(line.effect).lower()
        if "chapter" in effect or "chptr" in effect:
            match = pattern.search(line.text)
            if match:
                chapters.append((line.start, match.group(1)))
            elif isinstance(line, Comment) and line.text:
                chapters.append((line.start, str(line.text).strip()))
            else:
                warn(f"Chapter {(len(chapters) + 1):02.0f} does not have a name!", "Chapters")
                chapters.append((line.start, ""))

    if not chapters:
        warn("Could not find any chapters in subtitle!", "Chapters")
    ch = Chapters(chapters, fps)
    if _print and chapters:
        ch.print()
    return ch

print()

Prettier print for these because default timedelta formatting sucks

Source code in muxtools/misc/chapters.py
def print(self: ChaptersSelf) -> ChaptersSelf:
    """
    Prettier print for these because default timedelta formatting sucks
    """
    info("Chapters:")
    for time, name in self.chapters:
        print(f"{name}: {format_timedelta(time)} | {timedelta_to_frame(time, self.fps)}")
    print("", end="\n")
    return self

set_names(names)

Renames the chapters

Parameters:

Name Type Description Default
names list[str | None]

List of names

required
Source code in muxtools/misc/chapters.py
def set_names(self: ChaptersSelf, names: list[str | None]) -> ChaptersSelf:
    """
    Renames the chapters

    :param names:   List of names
    """
    old: list[str] = [chapter[1] for chapter in self.chapters]
    if len(names) > len(old):
        self.print()
        raise error("Chapters: too many names!", self)
    if len(names) < len(old):
        names += [None] * (len(old) - len(names))

    chapters: list[Chapter] = []
    for i, name in enumerate(names):
        current = list(self.chapters[i])
        current[1] = name
        chapters.append(tuple(current))

    self.chapters = chapters
    return self

shift(shift_amount)

Shifts all chapters by x frames.

Parameters:

Name Type Description Default
shift_amount int

Frames to shift by

required
Source code in muxtools/misc/chapters.py
def shift(self: ChaptersSelf, shift_amount: int) -> ChaptersSelf:
    """
    Shifts all chapters by x frames.

    :param shift_amount:    Frames to shift by
    """
    return [self.shift_chapter(i, shift_amount) for i, _ in enumerate(self.chapters)][-1]

shift_chapter(chapter=0, shift_amount=0)

Used to shift a single chapter by x frames

Parameters:

Name Type Description Default
chapter int

Chapter number (starting at 0)

0
shift_amount int

Frames to shift by

0
Source code in muxtools/misc/chapters.py
def shift_chapter(self: ChaptersSelf, chapter: int = 0, shift_amount: int = 0) -> ChaptersSelf:
    """
    Used to shift a single chapter by x frames

    :param chapter:         Chapter number (starting at 0)
    :param shift_amount:    Frames to shift by
    """
    ch = list(self.chapters[chapter])
    shift_delta = frame_to_timedelta(abs(shift_amount), self.fps)
    if shift_amount < 0:
        shifted_frame = ch[0] - shift_delta
    else:
        shifted_frame = ch[0] + shift_delta

    if shifted_frame.total_seconds() > 0:
        ch[0] = shifted_frame
    else:
        ch[0] = timedelta(seconds=0)
    self.chapters[chapter] = tuple(ch)
    return self

to_file(out=None)

Outputs the chapters to an OGM file

Parameters:

Name Type Description Default
out PathLike | None

Can be either a directory or a full file path

None
Source code in muxtools/misc/chapters.py
def to_file(self: ChaptersSelf, out: PathLike | None = None) -> str:
    """
    Outputs the chapters to an OGM file

    :param out:     Can be either a directory or a full file path
    """
    if not out:
        out = get_workdir()
    out = ensure_path(out, self)
    if out.is_dir():
        out_file = os.path.join(out, "chapters.txt")
    else:
        out_file = out
    with open(out_file, "w", encoding="UTF-8") as f:
        f.writelines(
            [
                f"CHAPTER{i:02d}={format_timedelta(chapter[0])}\nCHAPTER{i:02d}NAME=" f'{chapter[1] if chapter[1] else ""}\n'
                for i, chapter in enumerate(self.chapters)
            ]
        )
    return out_file

trim(trim_start=0, trim_end=0, num_frames=0)

Trims the chapters

Source code in muxtools/misc/chapters.py
def trim(self: ChaptersSelf, trim_start: int = 0, trim_end: int = 0, num_frames: int = 0) -> ChaptersSelf:
    """
    Trims the chapters
    """
    if trim_start > 0:
        chapters: list[Chapter] = []
        for chapter in self.chapters:
            if timedelta_to_frame(chapter[0]) == 0:
                chapters.append(chapter)
                continue
            if timedelta_to_frame(chapter[0]) - trim_start < 0:
                continue
            current = list(chapter)
            current[0] = current[0] - frame_to_timedelta(trim_start, self.fps)
            if num_frames:
                if current[0] > frame_to_timedelta(num_frames - 1, self.fps):
                    continue
            chapters.append(tuple(current))

        self.chapters = chapters
    if trim_end != 0:
        if trim_end > 0:
            chapters: list[Chapter] = []
            for chapter in self.chapters:
                if timedelta_to_frame(chapter[0], self.fps) < trim_end:
                    chapters.append(chapter)
            self.chapters = chapters

    return self