Skip to content

encode_utils_cli

Public accessible objects of that module.

chapt2bmqpyml

chapt2bmqpyml

chapt2bmqpyml(episodes: tuple[Path], fps: str, vid_info: bool, custom_layout: bool) -> None

Generate bookmarks and chapters YAML file from chapters text file.

Source code in src/encode_utils_cli/chapt2bmqpyml.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
@click.command()
@click.argument(
    "episodes",
    nargs=-1,
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
)
@click.option("-f", "--fps", type=str, default="24000/1001")
@click.option(
    "-v",
    "--vid-info",
    is_flag=True,
    default=False,
    help="Get corresponding videos info.",
)
@click.option(
    "-c",
    "--custom-layout",
    is_flag=True,
    default=False,
    help="Custom layout.",
)
def chapt2bmqpyml(
    episodes: tuple[Path],
    fps: str,
    vid_info: bool,
    custom_layout: bool,
) -> None:
    """Generate bookmarks and chapters YAML file from chapters text file."""
    for ep in episodes:
        chapters = ep.read_text()

        clip = source(Path(f"{ep.parents[1]}/{ep.stem}.mp4")) if vid_info else None
        fps_ = Fraction(fps if clip is None else clip.fps.numerator / clip.fps.denominator)

        names = [sub(r"[ ,]", "_", name) for name in findall(r"NAME=([^\n]+)", chapters)]
        frames = [ts2f(ts, fps_) for ts in findall(r"\d+=(\d+:\d+:\d+\.\d+)", chapters)]

        if custom_layout:
            bmk = Path(f"{ep.parents[2]}/{ep.stem}.vpy.bookmarks")
            qpf = Path(f"{ep.parents[2]}/in/{ep.stem}.qp")
            yml = Path(f"{ep.parents[2]}/chapters.yaml")
        else:
            bmk = Path(f"{ep.parent}/{ep.stem}.vpy.bookmarks")
            qpf = Path(f"{ep.parent}/{ep.stem}.qp")
            yml = Path(f"{ep.parent}/chapters.yaml")

        bmk.write_text(", ".join(f"{frame}" for frame in frames) + "\n")
        qpf.write_text("\n".join(f"{frame} I -1" for frame in frames) + "\n")

        chap = {ep.stem: dict(zip(names, frames, strict=True))}
        if clip is not None:
            chap[ep.stem]["EOF"] = clip.num_frames

        with yml.open("a") as stream:
            dump(data=chap, stream=stream, sort_keys=False)

frames_denum

frames_denum

frames_denum(frames: tuple[int], denum: float, copy: bool) -> None

Divide the frames by the specified divisor.

Example:

>>> frames_denum((16886, 26280), denum=2)
<<< "8443 13140"
>>> frames_denum((16886, 26280), denum=.5)
<<< "33772 52560"
Source code in src/encode_utils_cli/frames_denum.py
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@click.command()
@click.argument("frames", nargs=-1, required=True, type=int)
@click.option("-d", "--denum", type=float, default=2, help="Divisor.")
@click.option(
    "--copy/--no-copy",
    is_flag=True,
    default=True,
    help="Copy the result to the clipboard.",
)
def frames_denum(frames: tuple[int], denum: float, copy: bool) -> None:
    """Divide the frames by the specified divisor.

    \f
    Example:

        >>> frames_denum((16886, 26280), denum=2)
        <<< "8443 13140"
        >>> frames_denum((16886, 26280), denum=.5)
        <<< "33772 52560"
    """  # noqa: D301
    divided = " ".join(f"{int(frame // denum)}" for frame in sorted(frames, key=int))

    click.echo(divided)
    if copy:
        clipboard_copy(divided)

mpls2chap

mpls2chap

mpls2chap(mpls: Path, start_ep: int, out_dir: Path) -> None

Convert MPLS file to chapter files.

Source code in src/encode_utils_cli/mpls2chap.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@click.command()
@click.argument(
    "mpls",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
)
@click.option(
    "-s",
    "--start-ep",
    type=int,
    default=1,
    help="Number of first episode in mpls.",
)
@click.option(
    "-d",
    "--out-dir",
    type=click.Path(file_okay=False, path_type=Path),
    help="Custom out dir.",
)
def mpls2chap(mpls: Path, start_ep: int, out_dir: Path) -> None:
    """Convert MPLS file to chapter files."""
    out_dir = Path(mpls.parent) if out_dir is None else out_dir

    with mpls.open("rb") as data:
        chapterdata = load_mpls(data)[:-1]

    for number, ep in enumerate(chapterdata, start=start_ep):
        startpos = ep.times[0]
        chapters = [
            f"CHAPTER{index:02d}={seconds2ts((time - startpos) / 45000.0)}\n"
            f"CHAPTER{index:02d}NAME="
            for index, time in enumerate(ep.times)
        ]
        Path(f"{out_dir}/e{number}.txt").write_text("\n".join(chapters))

num_frames

num_frames

num_frames(vids: tuple[Path]) -> None

Calculate the number of frames in the given videos.

Source code in src/encode_utils_cli/num_frames.py
 8
 9
10
11
12
13
14
15
16
17
18
@click.command()
@click.argument(
    "vids",
    nargs=-1,
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
)
def num_frames(vids: tuple[Path]) -> None:
    """Calculate the number of frames in the given videos."""
    for vid in vids:
        click.echo(f"{vid.stem}.num_frames: {source(vid).num_frames}")

re_chapters

re_chapters

re_chapters(episodes: tuple[Path], config: Path) -> None

Replaces chapter names in episodes with names from a config file.

Source code in src/encode_utils_cli/re_chapters.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@click.command()
@click.argument(
    "episodes",
    nargs=-1,
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
)
@click.option(
    "-c",
    "--config",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Config in YAML format.",
)
def re_chapters(episodes: tuple[Path], config: Path) -> None:
    """Replaces chapter names in episodes with names from a config file."""
    names = safe_load(config.read_text())

    for ep in episodes:
        chapters = ep.read_text()
        chap_count = len(findall(r"(NAME=)", chapters))
        zero_chap = findall(r"(CHAPTER00NAME=)", chapters)
        pad = 0 if zero_chap else 1

        for i in range(chap_count):
            chapters = sub_chapter(
                chapters=chapters,
                num=i + pad,
                name=names[chap_count][i],
            )

        ep.write_text(chapters)

sub_chapter

sub_chapter(chapters: str, num: int, name: str) -> str

Replace chapter name.

Source code in src/encode_utils_cli/re_chapters.py
42
43
44
def sub_chapter(chapters: str, num: int, name: str) -> str:
    """Replace chapter name."""
    return sub(rf"(CHAPTER{num:02d}NAME=)(.*)", rf"\1{name}", chapters)

re_titles

re_titles

re_titles(config: Path, copy: bool) -> None

Reformat titles from AniDB.

Example:

>>> 1   The Prince`s New Clothes
<<< e1: EP1 «The Prince`s New Clothes»
Source code in src/encode_utils_cli/re_titles.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@click.command()
@click.option(
    "-c",
    "--config",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Config in TOML format.",
)
@click.option(
    "--copy/--no-copy",
    is_flag=True,
    default=True,
    help="Copy the result to the clipboard.",
)
def re_titles(config: Path, copy: bool) -> None:
    """Reformat titles from AniDB.

    \f
    Example:

        >>> 1 	The Prince`s New Clothes
        <<< e1: EP1 «The Prince`s New Clothes»
    """  # noqa: D301
    titles = loads(config.read_text())["titles"]

    titles = sub(r"	", r" ", titles)
    titles = sub(r"  (.+?)\n", r" «\1»\n", titles)
    titles = sub(r" »", r"»", titles)
    titles = sub(r"^(\d+) ", r"e\1: EP\1 ", titles, flags=MULTILINE)
    titles = sub(r"^OP(\d+)", r"op\1: OP\1", titles, flags=MULTILINE)
    titles = sub(r"^ED(\d+)", r"ed\1: ED\1", titles, flags=MULTILINE)
    titles = sub(r"^S(\d+) ", r"s\1: S\1 ", titles, flags=MULTILINE)

    click.echo(titles)
    if copy:
        clipboard_copy(titles)

screens2bm

screens2bm

screens2bm(screens: tuple[PurePath], fps: str, copy: bool) -> None

Convert hh:mm:ss.xxxx in screens filenames to bookmark frames.

Example:

>>> 00000 (00:12:34.34) 01.png
<<< 18086
>>> 00000 (00_00_03.34) 02.png
<<< 80
Source code in src/encode_utils_cli/screens2bm.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@click.command()
@click.argument(
    "screens",
    nargs=-1,
    required=True,
    type=click.Path(dir_okay=False, path_type=PurePath),
)
@click.option("-f", "--fps", type=str, default="24000/1001")
@click.option(
    "--copy/--no-copy",
    is_flag=True,
    default=True,
    help="Copy the result to the clipboard.",
)
def screens2bm(screens: tuple[PurePath], fps: str, copy: bool) -> None:
    """Convert `hh:mm:ss.xxxx` in screens filenames to bookmark frames.

    \f
    Example:

        >>> 00000 (00:12:34.34) 01.png
        <<< 18086
        >>> 00000 (00_00_03.34) 02.png
        <<< 80
    """  # noqa: D301
    frames = [
        f"{ts2f(ts=ts.group(1).replace('_', ':'), fps=Fraction(fps))}"
        for screen in screens
        if (ts := search(r"(\d+[:_]\d+[:_]\d+\.\d+)", screen.stem))
    ]
    bookmark = ", ".join(frames) + "\n"
    click.echo(bookmark)
    if copy:
        clipboard_copy(bookmark)

util

Public accessible objects of that module.

load_mpls

PlayList

Bases: NamedTuple

Represents a playlist.

Attributes:

Name Type Description
name str

The name of the playlist.

times list[int]

The list of times associated with the playlist.

load_mpls

load_mpls(f: BinaryIO, fix_overlap: bool = True) -> list[PlayList]

Load and parse an MPLS (Blu-ray playlist) file.

Parameters:

Name Type Description Default
f BinaryIO

The file object representing the MPLS file.

required
fix_overlap bool

Whether to fix overlapping timestamps. Defaults to True.

True

Returns:

Type Description
list[PlayList]

A list of PlayList objects representing the playlists in the MPLS file.

Examples:

>>> [
>>>     PlayList(name="00014", times=[189000000, 194469213, 225901239, 249525465, 253620806]),
>>>     PlayList(name="00015", times=[189000000, 200779267, 223110326, 249510450, 253620806]),
>>> ]
Source code in src/encode_utils_cli/util/load_mpls.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
def load_mpls(f: BinaryIO, fix_overlap: bool = True) -> list[PlayList]:
    """Load and parse an MPLS (Blu-ray playlist) file.

    Args:
        f: The file object representing the MPLS file.
        fix_overlap: Whether to fix overlapping timestamps. Defaults to True.

    Returns:
        A list of PlayList objects representing the playlists in the MPLS file.

    Examples:
        >>> [
        >>>     PlayList(name="00014", times=[189000000, 194469213, 225901239, 249525465, 253620806]),
        >>>     PlayList(name="00015", times=[189000000, 200779267, 223110326, 249510450, 253620806]),
        >>> ]
    """

    def int_be(data: bytes) -> int:
        funcs = {
            1: ord,
            2: lambda b: unpack(">H", b)[0],
            4: lambda b: unpack(">I", b)[0],
        }
        return funcs[len(data)](data)

    f.seek(8)
    addr_items, addr_marks = int_be(f.read(4)), int_be(f.read(4))
    f.seek(addr_items + 6)
    item_count = int_be(f.read(2))
    f.seek(2, SEEK_CUR)

    def read_item() -> PlayList:
        block_size = int_be(f.read(2))
        name = f.read(5).decode()
        f.seek(7, SEEK_CUR)
        times = [int_be(f.read(4)), int_be(f.read(4))]
        f.seek(block_size - 20, SEEK_CUR)
        return PlayList(name, times)

    items = [read_item() for _ in range(item_count)]

    f.seek(addr_marks + 4)
    mark_count = int_be(f.read(2))

    def read_mark() -> tuple[int, int]:
        f.seek(2, SEEK_CUR)
        index = int_be(f.read(2))
        time = int_be(f.read(4))
        f.seek(6, SEEK_CUR)
        return (index, time)

    for _ in range(mark_count):
        index, time = read_mark()
        if time > items[index].times[-2]:
            items[index].times.insert(-1, time)

    if fix_overlap:
        b = None
        for item in items:
            a, b = b, item.times
            if a and b[0] < a[-1] < b[-1]:
                a[-1] = b[0]
        if b is not None and len(b) > 1 and b[-1] - b[-2] < 90090:  # noqa: PLR2004
            b.pop()

    return items

source

source

source(video: Path) -> VideoNode

Load a video source using VapourSynth.

Parameters:

Name Type Description Default
video Path

The path to the video file.

required

Returns:

Name Type Description
VideoNode VideoNode

The loaded video source.

Source code in src/encode_utils_cli/util/source.py
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
def source(video: Path) -> VideoNode:
    """Load a video source using VapourSynth.

    Args:
        video: The path to the video file.

    Returns:
        VideoNode: The loaded video source.
    """
    return (
        core.lsmas.LibavSMASHSource(source=video)
        if video.suffix == ".mp4"
        else core.lsmas.LWLibavSource(source=video)
    )

timeconv

seconds2ts

seconds2ts(s: float) -> str

Convert seconds to timestamp in the format hh:mm:ss.xxx.

Source code in src/encode_utils_cli/util/timeconv.py
 4
 5
 6
 7
 8
 9
10
def seconds2ts(s: float) -> str:
    """Convert seconds to timestamp in the format `hh:mm:ss.xxx`."""
    m = s // 60
    s %= 60
    h = m // 60
    m %= 60
    return f"{h:02.0f}:{m:02.0f}:{s:06.3f}"

ts2seconds

ts2seconds(ts: str) -> float

Convert timestamp hh:mm:ss.xxxx to seconds.

Source code in src/encode_utils_cli/util/timeconv.py
13
14
15
16
def ts2seconds(ts: str) -> float:
    """Convert timestamp `hh:mm:ss.xxxx` to seconds."""
    h, m, s = map(float, ts.split(":"))
    return h * 3600 + m * 60 + s

seconds2f

seconds2f(s: float, fps: Fraction) -> int

Convert seconds to frames.

Source code in src/encode_utils_cli/util/timeconv.py
19
20
21
def seconds2f(s: float, fps: Fraction) -> int:
    """Convert seconds to frames."""
    return round(s * fps)

ts2f

ts2f(ts: str, fps: Fraction) -> int

Convert a timestamp hh:mm:ss.xxxx in number of frames.

Source code in src/encode_utils_cli/util/timeconv.py
24
25
26
def ts2f(ts: str, fps: Fraction) -> int:
    """Convert a timestamp `hh:mm:ss.xxxx` in number of frames."""
    return seconds2f(s=ts2seconds(ts), fps=fps)

vs_screens

vs_screens

vs_screens(vids: tuple[Path], out_dir: Path, frames: str, offset: int, crop: int, drop_prop: bool) -> None

Generate screen frames from videos.

Source code in src/encode_utils_cli/vs_screens.py
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@click.command()
@click.argument(
    "vids",
    nargs=-1,
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
)
@click.option(
    "-d",
    "--out-dir",
    type=click.Path(file_okay=False, path_type=Path),
    help="Custom out dir.",
)
@click.option("-f", "--frames", type=str, help='Frames. Format: "1 2 3".')
@click.option("-o", "--offset", type=int, default=0, help="Offset for clip.")
@click.option("-c", "--crop", type=int, default=0, help="CropRel args.")
@click.option("-p", "--drop-prop", is_flag=True, help="Delete frame prop.")
def vs_screens(
    vids: tuple[Path],
    out_dir: Path,
    frames: str,
    offset: int,
    crop: int,
    drop_prop: bool,
) -> None:
    """Generate screen frames from videos."""
    frames = frames or " ".join([f"{i}" for i in sample(range(100, 10000), k=5)])
    click.echo(f"Requesting frames: {frames!r}")

    for vid in vids:
        out_dir = Path(vid.parent) if out_dir is None else out_dir
        out_dir.mkdir(exist_ok=True)
        save_pattern = Path(f"{out_dir}/{vid.stem} %d.png")

        clip = open_clip(vid, drop_prop=drop_prop, offset=offset, crop=crop)
        writer = core.imwri.Write(clip, "png", save_pattern)

        for frame in frames.split():
            click.echo(f"Writing: '{save_pattern}' {frame}")
            writer.get_frame(int(frame))

open_clip

open_clip(video: Path, drop_prop: bool, offset: int, crop: int) -> VideoNode

Prepare clip.

Source code in src/encode_utils_cli/vs_screens.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
def open_clip(video: Path, drop_prop: bool, offset: int, crop: int) -> VideoNode:
    """Prepare clip."""
    clip = source(video)
    sd_height = 576

    if drop_prop:
        clip = (
            clip.std.Setframe_prop(prop="_Matrix", delete=True)
            .std.Setframe_prop(prop="_Transfer", delete=True)
            .std.Setframe_prop(prop="_Primaries", delete=True)
        )
    clip = clip.resize.Spline36(
        format=RGB24,
        matrix_in_s="709" if clip.height >= sd_height else "601",
    )
    if offset:
        clip = clip.std.Trim(offset, clip.num_frames - 1)
    if crop:
        clip = clip.std.CropRel(top=crop, bottom=crop)

    return clip

zones_validator

zones_validator

zones_validator(zones_config: Path) -> None

Validate x264/x265 zones configuration file.

Source code in src/encode_utils_cli/zones_validator.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@click.command()
@click.argument(
    "zones_config",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
)
def zones_validator(zones_config: Path) -> None:
    """Validate x264/x265 zones configuration file."""
    text = zones_config.read_text()

    targe_lines = [
        line
        for line in text.splitlines()
        if line and not line.isspace() and not line.lstrip().startswith("#")
    ]

    for line in targe_lines:
        if error_zones := [
            zone
            for zone in line.split(": ")[1].split("/")
            if not Schema(Regex(r"^\d+,\d+,b=\d\.\d+$")).is_valid(zone)
        ]:
            click.echo(f"{line} <- {error_zones}")