Skip to content

Commit 6310092

Browse files
GH-26: Handle all possible inputs and outputs (GH-27)
* Fix the names to correspond to the functionality * Group CLI-related stuff together * Convert inputs into one format * Implement output directory and metadata file path calculators * Handle file existence and implement skip/overwrite logic
1 parent dc93f30 commit 6310092

File tree

8 files changed

+327
-251
lines changed

8 files changed

+327
-251
lines changed

thumbnails/__init__.py

Lines changed: 14 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,111 +1,18 @@
1-
import json
2-
import os
3-
import shutil
4-
from datetime import timedelta
5-
from distutils.dir_util import copy_tree
6-
7-
from PIL import Image
8-
9-
from .formatter import FileFormatter
10-
from .formatter import FormatterFactory
11-
from .thumbnails import Thumbnails
12-
13-
DEFAULT_AS = "vtt"
14-
DEFAULT_COMPRESS = 1.
15-
DEFAULT_INTERVAL = 1.
16-
DEFAULT_BASEPATH = ""
17-
18-
19-
def register_formatter(typename):
20-
"""Register a new thumbnail formatter to the factory."""
21-
22-
def _register_factory(cls):
23-
if not issubclass(cls, FileFormatter):
24-
raise ValueError("The formatter must implement the FileFormatter interface.")
25-
26-
cls.extension = typename
27-
FormatterFactory.thumbnails[typename] = cls
28-
return cls
29-
30-
return _register_factory
31-
32-
33-
@register_formatter("vtt")
34-
class VTTFormatter(FileFormatter):
35-
"""Implements the methods for generating thumbnails in the WebVTT format."""
36-
37-
def __init__(self, video):
38-
super().__init__(video)
39-
self._master_name = self.filename + ".png"
40-
41-
def prepare_thumbnails(self):
42-
_thumbnails = self.thumbnails(True)
43-
master = Image.new(mode="RGBA", size=next(_thumbnails))
44-
45-
for frame, start, end, x, y in self.thumbnails():
46-
with Image.open(frame) as image:
47-
image = image.resize((self.width, self.height), Image.ANTIALIAS)
48-
master.paste(image, (x, y))
49-
50-
master.save(self._master_name)
51-
self.tempdir.cleanup()
52-
53-
def generate(self):
54-
def _format_time(secs):
55-
delta = timedelta(seconds=secs)
56-
return ("0%s.000" % delta)[:12]
57-
58-
_lines = ["WEBVTT\n\n"]
59-
_img_src = self.basepath + self._master_name
60-
61-
for frame, start, end, x, y in self.thumbnails():
62-
_thumbnail = "%s --> %s\n%s#xywh=%d,%d,%d,%d\n\n" % (
63-
_format_time(start), _format_time(end),
64-
_img_src, x, y, self.width, self.height
65-
)
66-
_lines.append(_thumbnail)
67-
68-
with open(self.thumbnail_file, "w") as fp:
69-
fp.writelines(_lines)
70-
71-
72-
@register_formatter("json")
73-
class JSONFormatter(FileFormatter):
74-
"""Implements the methods for generating thumbnails in the JSON format."""
75-
76-
def __init__(self, video):
77-
super().__init__(video)
78-
self._outdir = "outdir" # temp dirname
79-
80-
def prepare_thumbnails(self):
81-
if os.path.isdir(self._outdir):
82-
shutil.rmtree(self._outdir)
83-
copy_tree(self.tempdir.name, self._outdir)
84-
self.tempdir.cleanup()
85-
86-
def generate(self):
87-
_content = {}
88-
89-
for frame, start, end, x, y in self.thumbnails():
90-
frame = self._outdir + os.sep + os.path.split(frame)[1]
91-
with Image.open(frame) as image:
92-
image.resize((self.width, self.height), Image.ANTIALIAS).save(frame)
93-
_thumbnail = {
94-
"src": self.basepath + frame,
95-
"width": "%spx" % self.width,
96-
}
97-
_content[int(start)] = _thumbnail
98-
99-
with open(self.thumbnail_file, "w") as fp:
100-
json.dump(_content, fp, indent=2)
101-
1+
from .thumbnail import Thumbnail
2+
from .thumbnail import ThumbnailExistsError
3+
from .thumbnail import ThumbnailFactory
4+
from .thumbnail import ThumbnailJSON
5+
from .thumbnail import ThumbnailVTT
6+
from .thumbnail import register_thumbnail
7+
from .video import Video
1028

1039
__version__ = "v1.0"
10410
__all__ = (
105-
"register_formatter",
106-
"FormatterFactory",
107-
"FileFormatter",
108-
"JSONFormatter",
109-
"VTTFormatter",
110-
"Thumbnails",
11+
"Thumbnail",
12+
"ThumbnailExistsError",
13+
"ThumbnailFactory",
14+
"ThumbnailJSON",
15+
"ThumbnailVTT",
16+
"register_thumbnail",
17+
"Video",
11118
)

thumbnails/__main__.py

Lines changed: 44 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,72 @@
11
import concurrent.futures
22
import functools
3+
import itertools
34
import os
45

56
import click
67

7-
from . import DEFAULT_AS
8-
from . import DEFAULT_BASEPATH
9-
from . import DEFAULT_COMPRESS
10-
from . import DEFAULT_INTERVAL
11-
from . import FormatterFactory
12-
from . import Thumbnails
13-
from . import __version__
8+
from . import Thumbnail
9+
from . import ThumbnailExistsError
10+
from . import ThumbnailFactory
11+
from . import Video
12+
from .cli import cli
1413

1514

16-
def worker(video, as_):
15+
def worker(video, fmt, base, skip, output):
1716
"""Generate thumbnails for a single video."""
18-
video.extract_frames()
19-
formatter = FormatterFactory.create_formatter(as_, video)
20-
formatter.prepare_thumbnails()
21-
formatter.generate()
17+
try:
18+
thumbnail = ThumbnailFactory.create_thumbnail(fmt, video, base, skip, output)
19+
except ThumbnailExistsError:
20+
return print("Skipping '%s'" % video.filepath)
21+
thumbnail.prepare_frames()
22+
thumbnail.generate()
2223

2324

24-
class _ThumbnailsCLI(click.Command):
25-
"""This class overrides the usages section of the help message."""
26-
27-
def format_usage(self, ctx, formatter):
28-
usages = (
29-
"[OPTIONS] INPUT_DIR OUTPUT_DIR",
30-
"[OPTIONS] INPUT_FILE OUTPUT_FILE",
31-
"[OPTIONS] INPUT_FILES... OUTPUT_DIR",
32-
)
33-
formatter.write_usage(ctx.command_path, "\n\t\t\t".join(usages), prefix="Usages: ")
25+
@cli
26+
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."""
3428

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))
3534

36-
# This defines a set of supported values for the particular option of the CLI.
37-
_type = click.Choice(FormatterFactory.thumbnails.keys(), case_sensitive=False)
35+
if all(map(os.path.isfile, inputs)):
36+
inputs = set(map(os.path.abspath, inputs))
37+
elif all(map(os.path.isdir, inputs)):
38+
inputs = set(itertools.chain(*map(listdir, inputs)))
39+
else:
40+
exit("Inputs must be all files or all directories.")
3841

42+
fmt = kwargs.pop("format")
43+
inputs = dict(zip(map(lambda i: Thumbnail.metadata_path(i, output, fmt), inputs), inputs))
3944

40-
@click.command(cls=_ThumbnailsCLI)
41-
@click.option("--as", "-F", default=DEFAULT_AS, type=_type, help="Output format. Default is %s." % DEFAULT_AS)
42-
@click.option("--compress", "-C", default=DEFAULT_COMPRESS, help="The image scale coefficient. A number from 0 to 1.")
43-
@click.option("--interval", "-I", default=DEFAULT_INTERVAL, help="The interval between neighbor thumbnails in seconds.")
44-
@click.option("--basepath", "-B", default=DEFAULT_BASEPATH, help="The prefix of the thumbnails path can be customized.")
45-
@click.argument("inputs", required=True, type=click.Path(), nargs=-1)
46-
@click.argument("output", required=True, type=click.Path(), nargs=1)
47-
@click.version_option(__version__)
48-
def thumbnails_cli(compress, interval, basepath, inputs, output, **kwargs):
49-
"""TODO: This section will be completed after fixing the issue #26."""
50-
as_ = kwargs.pop("as")
51-
output_is_directory = all((len(inputs) > 1, *map(os.path.isfile, inputs))) or os.path.isdir(inputs[0])
45+
if not skip and any(map(os.path.exists, inputs.keys())):
46+
skip = not click.confirm("Do you want to overwrite already existing files?")
5247

5348
with concurrent.futures.ThreadPoolExecutor() as executor:
5449
videos = executor.map(
5550
functools.partial(
56-
Thumbnails,
51+
Video,
5752
compress=compress,
5853
interval=interval,
59-
basepath=basepath
6054
),
61-
inputs,
55+
inputs.values(),
6256
)
6357

6458
with concurrent.futures.ProcessPoolExecutor() as executor:
65-
executor.map(functools.partial(worker, as_=as_), videos)
59+
executor.map(
60+
functools.partial(
61+
worker,
62+
fmt=fmt,
63+
base=base,
64+
skip=skip,
65+
output=output,
66+
),
67+
videos,
68+
)
6669

6770

6871
if __name__ == "__main__":
69-
thumbnails_cli()
72+
main()

thumbnails/cli.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import functools
2+
3+
import click
4+
5+
from . import ThumbnailFactory
6+
from . import __version__
7+
8+
# Default values of the particular option of the CLI.
9+
DEFAULT_BASE = ""
10+
DEFAULT_SKIP = False
11+
DEFAULT_OUTPUT = None
12+
DEFAULT_FORMAT = "vtt"
13+
DEFAULT_COMPRESS = 1.0
14+
DEFAULT_INTERVAL = 1.0
15+
16+
# Help messages of the particular option of the CLI.
17+
HELP_BASE = "The prefix of the thumbnails path can be customized."
18+
HELP_SKIP = "Skip the existing thumbnails. Default is not set."
19+
HELP_OUTPUT = "The output directory. Default is the current directory."
20+
HELP_FORMAT = "Output format. Default is %s." % DEFAULT_FORMAT
21+
HELP_COMPRESS = "The image scale coefficient. A number from 0 to 1."
22+
HELP_INTERVAL = "The interval between neighbor thumbnails in seconds."
23+
24+
# This defines a choice of supported values for the '--format' option of the CLI.
25+
format_choice = click.Choice(ThumbnailFactory.thumbnails.keys(), case_sensitive=False)
26+
27+
28+
def cli(func):
29+
@click.command()
30+
@click.option("--compress", "-C", default=DEFAULT_COMPRESS, help=HELP_COMPRESS)
31+
@click.option("--interval", "-I", default=DEFAULT_INTERVAL, help=HELP_INTERVAL)
32+
@click.option("--base", "-B", default=DEFAULT_BASE, help=HELP_BASE)
33+
@click.option("--skip", "-S", default=DEFAULT_SKIP, help=HELP_SKIP, is_flag=True)
34+
@click.option("--output", "-O", default=DEFAULT_OUTPUT, type=click.Path(), help=HELP_OUTPUT)
35+
@click.option("--format", "-F", default=DEFAULT_FORMAT, type=format_choice, help=HELP_FORMAT)
36+
@click.argument("inputs", required=True, type=click.Path(), nargs=-1)
37+
@click.version_option(__version__, "-V", "--version")
38+
@click.help_option("-h", "--help")
39+
@functools.wraps(func)
40+
def wrapper(*args, **kwargs):
41+
return func(*args, **kwargs)
42+
43+
return wrapper

thumbnails/ffmpeg.py

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,10 @@
1111
class _FFMpeg:
1212
"""This class is used to parse the metadata of a video file."""
1313

14-
def __init__(self, filename):
15-
duration, self.size = self._parse_metadata(filename)
14+
def __init__(self, filepath):
15+
duration, self.size = self._parse_metadata(filepath)
1616
self.duration = int(duration + 1)
1717

18-
@staticmethod
19-
def _cross_platform_popen_params(bufsize=100000):
20-
popen_params = {
21-
"bufsize": bufsize,
22-
"stdout": subprocess.PIPE,
23-
"stderr": subprocess.PIPE,
24-
"stdin": subprocess.DEVNULL,
25-
}
26-
if os.name == "nt":
27-
popen_params["creationflags"] = 0x08000000
28-
return popen_params
29-
3018
@staticmethod
3119
def _parse_duration(stdout):
3220
"""Parse the duration of a video from stdout."""
@@ -42,21 +30,25 @@ def _parse_size(stdout):
4230
match_size = re.search(size_regex, stdout, re.M)
4331
return tuple(map(int, match_size.groups()))
4432

45-
def _parse_metadata(self, filename):
33+
def _parse_metadata(self, filepath):
4634
"""Parse the metadata of a video file."""
47-
meta = immeta(filename)
35+
meta = immeta(filepath)
4836
duration, size = meta.get("duration"), meta.get("size")
4937

5038
if not all((duration, size)):
5139
# Parse the metadata of the video formats
5240
# that are not supported by imageio.
5341

54-
cmd = (ffmpeg_bin, "-hide_banner", "-i", filename)
55-
56-
popen_params = self._cross_platform_popen_params()
57-
process = subprocess.Popen(cmd, **popen_params)
42+
process = subprocess.Popen(
43+
(ffmpeg_bin, "-hide_banner", "-i", filepath),
44+
bufsize=100000,
45+
stdout=subprocess.PIPE,
46+
stderr=subprocess.PIPE,
47+
stdin=subprocess.DEVNULL,
48+
creationflags=0x08000000 if os.name == "nt" else 0,
49+
)
5850
_, stderr = process.communicate()
59-
stdout = stderr.decode("utf8", errors="ignore")
51+
stdout = (stderr or b"").decode("utf8", errors="ignore")
6052

6153
duration = self._parse_duration(stdout)
6254
size = self._parse_size(stdout)

thumbnails/formatter.py

Lines changed: 0 additions & 41 deletions
This file was deleted.

0 commit comments

Comments
 (0)