Skip to content

Commit a391002

Browse files
GH-34: Fix the route prefix calculation by --base (GH-35)
* Optimize the `thumbnail_dir` calculation and usage * Optimize the code with pathtools * Add a short CLI description * Fix FFmpeg input path value
1 parent 6310092 commit a391002

File tree

6 files changed

+110
-79
lines changed

6 files changed

+110
-79
lines changed

thumbnails/__init__.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
"""
2+
Copyright 2023 Artyom Vancyan
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
"""
16+
117
from .thumbnail import Thumbnail
218
from .thumbnail import ThumbnailExistsError
319
from .thumbnail import ThumbnailFactory
@@ -6,7 +22,7 @@
622
from .thumbnail import register_thumbnail
723
from .video import Video
824

9-
__version__ = "v1.0"
25+
__version__ = "0.0.1"
1026
__all__ = (
1127
"Thumbnail",
1228
"ThumbnailExistsError",

thumbnails/__main__.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,28 @@
55

66
import click
77

8-
from . import Thumbnail
98
from . import ThumbnailExistsError
109
from . import ThumbnailFactory
1110
from . import Video
1211
from .cli import cli
12+
from .pathtools import listdir
13+
from .pathtools import metadata_path
1314

1415

1516
def worker(video, fmt, base, skip, output):
16-
"""Generate thumbnails for a single video."""
17+
"""Executes the required workflows for generating a thumbnail(s)."""
1718
try:
1819
thumbnail = ThumbnailFactory.create_thumbnail(fmt, video, base, skip, output)
1920
except ThumbnailExistsError:
20-
return print("Skipping '%s'" % video.filepath)
21+
return print("Skipping '%s'" % os.path.relpath(video.filepath))
2122
thumbnail.prepare_frames()
2223
thumbnail.generate()
2324

2425

2526
@cli
2627
def main(compress=None, interval=None, base=None, inputs=None, output=None, skip=None, **kwargs):
27-
"""TODO: This section will be completed after fixing the issue #26."""
28-
29-
def listdir(directory):
30-
"""Lists all files in the given directory with absolute paths."""
31-
for basedir, _, files in os.walk(directory):
32-
for file in filter(os.path.isfile, files):
33-
yield os.path.abspath(os.path.join(basedir, file))
28+
"""This command delegates the functionality of the `thumbnails` Python package
29+
to the CLI. Read more at https://github.com/pysnippet/thumbnails#readme."""
3430

3531
if all(map(os.path.isfile, inputs)):
3632
inputs = set(map(os.path.abspath, inputs))
@@ -40,7 +36,7 @@ def listdir(directory):
4036
exit("Inputs must be all files or all directories.")
4137

4238
fmt = kwargs.pop("format")
43-
inputs = dict(zip(map(lambda i: Thumbnail.metadata_path(i, output, fmt), inputs), inputs))
39+
inputs = dict(zip(map(lambda i: metadata_path(i, output, fmt), inputs), inputs))
4440

4541
if not skip and any(map(os.path.exists, inputs.keys())):
4642
skip = not click.confirm("Do you want to overwrite already existing files?")

thumbnails/frame.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44

55
class _Frame:
6-
"""This mixin class is used to optimally calculate the size of a thumbnail frame."""
6+
"""This class is used to calculate the optimal size of a thumbnail frame."""
77

88
def __init__(self, size):
99
width, height = size
@@ -22,10 +22,10 @@ def compress(self):
2222

2323
@functools.cached_property
2424
def width(self):
25-
"""Calculates and caches the width."""
25+
"""Calculates and caches the frame width."""
2626
return max(self._min_width, self._width * self.compress)
2727

2828
@functools.cached_property
2929
def height(self):
30-
"""Calculates and caches the height."""
30+
"""Calculates and caches the frame height."""
3131
return max(self._min_height, self._height * self.compress)

thumbnails/pathtools.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import functools
2+
import os
3+
from distutils.dir_util import create_tree
4+
5+
6+
def listdir(directory):
7+
"""Lists all files in the given directory with absolute paths."""
8+
for basedir, _, files in os.walk(directory):
9+
for file in filter(os.path.isfile, files):
10+
yield os.path.abspath(os.path.join(basedir, file))
11+
12+
13+
@functools.cache
14+
def metadata_path(path, out, fmt):
15+
"""Calculates the thumbnail metadata output path."""
16+
out = os.path.abspath(out or os.path.dirname(path))
17+
return os.path.join(out, "%s.%s" % (extract_name(path), fmt))
18+
19+
20+
def extract_name(path):
21+
"""Extracts the name of the file from the path."""
22+
return os.path.splitext(os.path.basename(path))[0]
23+
24+
25+
def ensure_tree(basedir, isdir=False, *args, **kwargs):
26+
"""Ensures the existence of basedir and returns."""
27+
basedir, file = os.path.abspath(basedir), ""
28+
if not isdir:
29+
basedir, file = os.path.split(basedir)
30+
create_tree(basedir, [file], *args, **kwargs)
31+
return basedir

thumbnails/thumbnail.py

Lines changed: 40 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
1-
import functools
21
import json
32
import os
43
from abc import ABCMeta
54
from abc import abstractmethod
65
from datetime import timedelta
76
from distutils.dir_util import copy_tree
8-
from distutils.dir_util import create_tree
97
from distutils.dir_util import remove_tree
108

119
from PIL import Image
1210

11+
from .pathtools import ensure_tree
12+
from .pathtools import extract_name
13+
from .pathtools import metadata_path
14+
1315

1416
def register_thumbnail(typename):
15-
"""Register a new thumbnail formatter to the factory."""
17+
"""Register a new type of thumbnail generator into the factory."""
1618

1719
def _register_factory(cls):
1820
if not issubclass(cls, Thumbnail):
19-
raise ValueError("The formatter must implement the Thumbnail interface.")
21+
raise ValueError("%s should be a Thumbnail." % cls.__name__)
2022

2123
cls.extension = typename
2224
ThumbnailFactory.thumbnails[typename] = cls
@@ -30,7 +32,7 @@ class ThumbnailExistsError(Exception):
3032

3133

3234
class Thumbnail(metaclass=ABCMeta):
33-
"""Any thumbnail describing format should implement the base Formatter."""
35+
"""Any thumbnail describing format should implement the base Thumbnail."""
3436

3537
extension = None
3638

@@ -39,73 +41,63 @@ def __init__(self, video, base, skip, output):
3941
self.base = base
4042
self.skip = skip
4143
self.output = output
44+
self.thumbnail_dir = self.calc_thumbnail_dir()
45+
self.metadata_path = self._get_metadata_path()
4246
self._perform_skip()
4347
self.extract_frames()
4448

49+
def _get_metadata_path(self):
50+
"""Initiates the name of the thumbnail metadata file."""
51+
return metadata_path(self.filepath, self.output, self.extension)
52+
4553
def _perform_skip(self):
46-
"""Raises ThumbnailExistsError to skip."""
47-
if os.path.exists(self.get_metadata_path()) and self.skip:
54+
"""Checks the file existence and decide whether to skip or not."""
55+
if os.path.exists(self.metadata_path) and self.skip:
4856
raise ThumbnailExistsError
49-
basedir, file = os.path.split(self.get_metadata_path())
50-
create_tree(basedir, [file])
57+
ensure_tree(self.metadata_path)
5158

5259
def __getattr__(self, item):
53-
"""Delegate all other attributes to the video."""
60+
"""Delegates all other attributes to the video."""
5461
return getattr(self.video, item)
5562

56-
def get_metadata_path(self):
57-
"""Return the name of the thumbnail file."""
58-
return self.metadata_path(self.filepath, self.output, self.extension)
59-
60-
@staticmethod
61-
@functools.cache
62-
def metadata_path(path, out, fmt):
63-
"""Calculate the thumbnail metadata output path."""
64-
out = os.path.abspath(out or os.path.dirname(path))
65-
filename = os.path.splitext(os.path.basename(path))[0]
66-
return os.path.join(out, "%s.%s" % (filename, fmt))
67-
6863
@abstractmethod
69-
def thumbnail_dir(self):
70-
"""Creates and returns the thumbnail's output directory."""
64+
def calc_thumbnail_dir(self):
65+
"""Calculates and returns the thumbnail's output directory."""
7166

7267
@abstractmethod
7368
def prepare_frames(self):
74-
"""Prepare the thumbnails before generating the output."""
69+
"""Prepares the thumbnail frames before generating the output."""
7570

7671
@abstractmethod
7772
def generate(self):
78-
"""Generate the thumbnails for the given video."""
73+
"""Generates the thumbnail metadata for the given video."""
7974

8075

8176
class ThumbnailFactory:
82-
"""A factory for creating thumbnail formatter."""
77+
"""A factory for creating a thumbnail for a particular format."""
8378

8479
thumbnails = {}
8580

8681
@classmethod
8782
def create_thumbnail(cls, typename, *args, **kwargs) -> Thumbnail:
88-
"""Create a new thumbnail formatter by the given typename."""
83+
"""Create a Thumbnail instance by the given typename."""
8984
try:
9085
return cls.thumbnails[typename](*args, **kwargs)
9186
except KeyError:
92-
raise ValueError("The formatter type '%s' is not registered." % typename)
87+
raise ValueError("The thumbnail type '%s' is not registered." % typename)
9388

9489

9590
@register_thumbnail("vtt")
9691
class ThumbnailVTT(Thumbnail):
9792
"""Implements the methods for generating thumbnails in the WebVTT format."""
9893

99-
def thumbnail_dir(self):
100-
basedir = self.output or os.path.dirname(self.filepath)
101-
create_tree(os.path.abspath(basedir), [self.filepath])
102-
return os.path.abspath(basedir)
94+
def calc_thumbnail_dir(self):
95+
return ensure_tree(self.output or os.path.dirname(self.filepath), True)
10396

10497
def prepare_frames(self):
10598
thumbnails = self.thumbnails(True)
10699
master = Image.new(mode="RGBA", size=next(thumbnails))
107-
master_name = os.path.splitext(os.path.basename(self.filepath))[0]
108-
master_path = os.path.join(self.thumbnail_dir(), master_name + ".png")
100+
master_path = os.path.join(self.thumbnail_dir, extract_name(self.filepath) + ".png")
109101

110102
for frame, *_, x, y in self.thumbnails():
111103
with Image.open(frame) as image:
@@ -121,8 +113,8 @@ def format_time(secs):
121113
return ("0%s.000" % delta)[:12]
122114

123115
metadata = ["WEBVTT\n\n"]
124-
prefix = self.base or os.path.relpath(self.thumbnail_dir())
125-
route = os.path.join(prefix, os.path.basename(self.filepath))
116+
prefix = self.base or os.path.relpath(self.thumbnail_dir)
117+
route = os.path.join(prefix, extract_name(self.filepath) + ".png")
126118

127119
for _, start, end, x, y in self.thumbnails():
128120
thumbnail_data = "%s --> %s\n%s#xywh=%d,%d,%d,%d\n\n" % (
@@ -131,40 +123,38 @@ def format_time(secs):
131123
)
132124
metadata.append(thumbnail_data)
133125

134-
with open(self.get_metadata_path(), "w") as fp:
126+
with open(self.metadata_path, "w") as fp:
135127
fp.writelines(metadata)
136128

137129

138130
@register_thumbnail("json")
139131
class ThumbnailJSON(Thumbnail):
140132
"""Implements the methods for generating thumbnails in the JSON format."""
141133

142-
def thumbnail_dir(self):
134+
def calc_thumbnail_dir(self):
143135
basedir = os.path.abspath(self.output or os.path.dirname(self.filepath))
144-
subdir = os.path.splitext(os.path.basename(self.filepath))[0]
145-
basedir = os.path.join(basedir, subdir)
146-
create_tree(basedir, [self.filepath])
147-
return basedir
136+
return ensure_tree(os.path.join(basedir, extract_name(self.filepath)), True)
148137

149138
def prepare_frames(self):
150-
thumbnail_dir = self.thumbnail_dir()
151-
if os.path.exists(thumbnail_dir):
152-
remove_tree(thumbnail_dir)
153-
copy_tree(self.tempdir.name, thumbnail_dir)
139+
if os.path.exists(self.thumbnail_dir):
140+
remove_tree(self.thumbnail_dir)
141+
copy_tree(self.tempdir.name, self.thumbnail_dir)
154142
self.tempdir.cleanup()
155143

156144
def generate(self):
157145
metadata = {}
158146

159147
for frame, start, *_ in self.thumbnails():
160-
frame = os.path.join(self.thumbnail_dir(), os.path.basename(frame))
148+
frame = os.path.join(self.thumbnail_dir, os.path.basename(frame))
161149
with Image.open(frame) as image:
162150
image.resize((self.width, self.height), Image.ANTIALIAS).save(frame)
151+
prefix = self.base or os.path.relpath(self.thumbnail_dir)
152+
route = os.path.join(prefix, os.path.basename(frame))
163153
thumbnail_data = {
164-
"src": self.base + frame,
154+
"src": route,
165155
"width": "%spx" % self.width,
166156
}
167157
metadata[int(start)] = thumbnail_data
168158

169-
with open(self.get_metadata_path(), "w") as fp:
159+
with open(self.metadata_path, "w") as fp:
170160
json.dump(metadata, fp, indent=2)

thumbnails/video.py

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def _generator():
2929

3030

3131
class Video(_FFMpeg, _Frame):
32-
"""The main class for processing the thumbnail generation of a video."""
32+
"""This class gives methods to extract the thumbnail frames of a video."""
3333

3434
def __init__(self, filepath, compress, interval):
3535
self.__filepath = filepath
@@ -65,35 +65,33 @@ def calc_columns(frames_count, width, height):
6565
return col
6666

6767
def _extract_frame(self, start_time):
68-
"""Extracts a single frame from the video by the given time."""
69-
_input_file = os.path.basename(self.filepath)
70-
_timestamp = str(timedelta(seconds=start_time))
71-
_output_file = "%s/%s-%s.png" % (self.tempdir.name, _timestamp, _input_file)
68+
"""Extracts a single frame from the video by the offset."""
69+
offset = str(timedelta(seconds=start_time))
70+
output = "%s/%s.png" % (self.tempdir.name, offset)
7271

7372
cmd = (
7473
ffmpeg_bin,
75-
"-ss", _timestamp,
76-
"-i", _input_file,
74+
"-ss", offset,
75+
"-i", self.filepath,
7776
"-loglevel", "error",
7877
"-vframes", "1",
79-
_output_file,
78+
output,
8079
"-y",
8180
)
8281

8382
subprocess.Popen(cmd).wait()
8483

8584
def extract_frames(self):
8685
"""Extracts the frames from the video by given intervals."""
87-
_intervals = arange(0, self.duration, self.interval)
8886
with concurrent.futures.ThreadPoolExecutor() as executor:
89-
executor.map(self._extract_frame, _intervals)
87+
executor.map(self._extract_frame, arange(0, self.duration, self.interval))
9088

9189
def thumbnails(self, master_size=False):
92-
"""This generator function yields a thumbnail on each iteration.
90+
"""This generator function yields a thumbnail data on each iteration.
9391
94-
A thumbnail is a tuple of data describing the current frame.
95-
The thumbnail structure is (frame, start, end, x, y) where:
96-
- frame: Filename of the current frame in temp-files.
92+
The thumbnail data is a tuple of fields describing the current frame.
93+
The structure of the thumbnail data is (frame, start, end, x, y).
94+
- frame: The filename of the current frame (usually in temp-files).
9795
- start: The start point of the time range the frame belongs to.
9896
- end: The end point of the time range the frame belongs to.
9997
- x: The X coordinate of the frame in the final image.

0 commit comments

Comments
 (0)