Skip to content

Commit 7f9cfad

Browse files
GH-23: Add support for JSON thumbnail generation (GH-24)
* Fix the time format at VTT for floating intervals * Remove options' setters and provide through the constructor * Implement Factory for creating `ThumbnailFormat` * Implement the strategy for `VTT` generation * Implement the strategy for `JSON` generation * Change output file format to fix the order on sort * Update mock data of the JSON demo
1 parent 01de956 commit 7f9cfad

File tree

7 files changed

+623
-77
lines changed

7 files changed

+623
-77
lines changed

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
click>=8.0.3
12
imageio-ffmpeg>=0.4.7
23
imageio>=2.23.0
34
pillow>=8.4.0

thumbnails.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,19 @@
77
interval = .5
88
basepath = "/stc/"
99

10-
files = ["valerian-1080p.avi", "valerian-1080p.mkv", "valerian-1080p.mov", "valerian-1080p.mp4",
11-
"valerian-1080p.webm", "valerian-1080p.wmv", "valerian-1080p.mpeg", "valerian-1080p.mpg", "valerian-1080p.ogv"]
10+
files = ["valerian-1080p.avi"] #, "valerian-1080p.mkv", "valerian-1080p.mov", "valerian-1080p.mp4",
11+
# "valerian-1080p.webm", "valerian-1080p.wmv", "valerian-1080p.mpeg", "valerian-1080p.mpg", "valerian-1080p.ogv"]
1212

1313

1414
def worker(video):
15-
video.compress = compress
16-
video.interval = interval
17-
video.basepath = basepath
1815
video.extract_frames()
1916
video.join_frames()
2017
video.to_vtt()
2118

2219

2320
def main():
2421
with concurrent.futures.ProcessPoolExecutor() as executor:
25-
executor.map(worker, map(Thumbnails, files))
22+
executor.map(worker, (Thumbnails(file, compress, interval, basepath) for file in files))
2623

2724

2825
if __name__ == "__main__":

thumbnails/__init__.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,95 @@
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 ThumbnailFactory
10+
from .formatter import ThumbnailFormat
111
from .thumbnails import Thumbnails
12+
from .thumbnails import arange
13+
14+
DEFAULT_AS = "vtt"
15+
DEFAULT_COMPRESS = 1.
16+
DEFAULT_INTERVAL = 1.
17+
DEFAULT_BASEPATH = ""
18+
19+
20+
def register_format(typename):
21+
def _registrator(cls: ThumbnailFormat):
22+
cls.extension = typename
23+
ThumbnailFactory.thumbnails[typename] = cls
24+
25+
return _registrator
26+
27+
28+
@register_format("vtt")
29+
class VTT(ThumbnailFormat):
30+
def __init__(self, video):
31+
super().__init__(video)
32+
self._master_name = self.filename + ".png"
33+
34+
def prepare_thumbnails(self):
35+
_thumbnails = self.video.thumbnails(True)
36+
master = Image.new(mode="RGBA", size=next(_thumbnails))
37+
38+
for frame, start, end, x, y in self.video.thumbnails():
39+
with Image.open(frame) as image:
40+
image = image.resize((self.width, self.height), Image.ANTIALIAS)
41+
master.paste(image, (x, y))
42+
43+
master.save(self._master_name)
44+
self.tempdir.cleanup()
45+
46+
def generate(self):
47+
def _format_time(secs):
48+
delta = timedelta(seconds=secs)
49+
return ("0%s.000" % delta)[:12]
50+
51+
_lines = ["WEBVTT\n\n"]
52+
_img_src = self.basepath + self._master_name
53+
54+
for frame, start, end, x, y in self.video.thumbnails():
55+
_thumbnail = "%s --> %s\n%s#xywh=%d,%d,%d,%d\n\n" % (
56+
_format_time(start), _format_time(end),
57+
_img_src, x, y, self.width, self.height
58+
)
59+
_lines.append(_thumbnail)
60+
61+
with open(self.output_format, "w") as fp:
62+
fp.writelines(_lines)
63+
64+
65+
@register_format("json")
66+
class JSON(ThumbnailFormat):
67+
def __init__(self, video):
68+
super().__init__(video)
69+
self._outdir = "outdir" # temp dirname
70+
71+
def prepare_thumbnails(self):
72+
if os.path.isdir(self._outdir):
73+
shutil.rmtree(self._outdir)
74+
copy_tree(self.tempdir.name, self._outdir)
75+
self.tempdir.cleanup()
76+
77+
def generate(self):
78+
_content = {}
79+
80+
for frame, start, end, x, y in self.video.thumbnails():
81+
frame = self._outdir + os.sep + os.path.split(frame)[1]
82+
with Image.open(frame) as image:
83+
image.resize((self.width, self.height), Image.ANTIALIAS).save(frame)
84+
_thumbnail = {
85+
"src": self.basepath + frame,
86+
"width": "%spx" % self.width,
87+
}
88+
_content[int(start)] = _thumbnail
89+
90+
with open(self.output_format, "w") as fp:
91+
json.dump(_content, fp, indent=2)
92+
93+
94+
__version__ = "v1.0"
95+
__all__ = (Thumbnails,)

thumbnails/__main__.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import concurrent.futures
2+
import functools
3+
import os
4+
5+
import click
6+
7+
from . import DEFAULT_AS
8+
from . import DEFAULT_BASEPATH
9+
from . import DEFAULT_COMPRESS
10+
from . import DEFAULT_INTERVAL
11+
from . import ThumbnailFactory
12+
from . import Thumbnails
13+
from . import __version__
14+
15+
16+
def worker(video, as_):
17+
video.extract_frames()
18+
formatter = ThumbnailFactory.get_thumbnail(as_, video)
19+
formatter.prepare_thumbnails()
20+
formatter.generate()
21+
22+
23+
class _ThumbnailsCLI(click.Command):
24+
def format_usage(self, ctx, formatter):
25+
usages = (
26+
"[OPTIONS] INPUT_DIR OUTPUT_DIR",
27+
"[OPTIONS] INPUT_FILE OUTPUT_FILE",
28+
"[OPTIONS] INPUT_FILES... OUTPUT_DIR",
29+
)
30+
formatter.write_usage(ctx.command_path, "\n\t\t\t".join(usages), prefix="Usages: ")
31+
32+
33+
@click.command(cls=_ThumbnailsCLI)
34+
@click.option("--as", "-F", default=DEFAULT_AS, help="Output format. Default is %s." % DEFAULT_AS,
35+
type=click.Choice(ThumbnailFactory.thumbnails.keys(), case_sensitive=False))
36+
@click.option("--compress", "-C", default=DEFAULT_COMPRESS, help="The image scale coefficient. A number from 0 to 1.")
37+
@click.option("--interval", "-I", default=DEFAULT_INTERVAL, help="The interval between neighbor thumbnails in seconds.")
38+
@click.option("--basepath", "-B", default=DEFAULT_BASEPATH, help="The prefix of the thumbnails path can be customized.")
39+
@click.argument("inputs", required=True, type=click.Path(), nargs=-1)
40+
@click.argument("output", required=True, type=click.Path(), nargs=1)
41+
@click.version_option(__version__)
42+
def thumbnails_cli(compress, interval, basepath, inputs, output, **kwargs):
43+
"""TODO: Add more description about particular usages."""
44+
as_ = kwargs.pop("as")
45+
output_is_directory = all((len(inputs) > 1, *map(os.path.isfile, inputs))) or os.path.isdir(inputs[0])
46+
47+
with concurrent.futures.ThreadPoolExecutor() as executor:
48+
videos = executor.map(
49+
functools.partial(
50+
Thumbnails,
51+
compress=compress,
52+
interval=interval,
53+
basepath=basepath
54+
),
55+
inputs,
56+
)
57+
58+
with concurrent.futures.ProcessPoolExecutor() as executor:
59+
executor.map(functools.partial(worker, as_=as_), videos)
60+
61+
62+
# @click.confirmation_option("--overwrite", "-y", prompt="Are you sure you want to overwrite the existing output files?")
63+
# def overwrite():
64+
# print("overwritten")
65+
66+
67+
if __name__ == "__main__":
68+
thumbnails_cli()

thumbnails/formatter.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
class ThumbnailFormat:
2+
extension = None
3+
4+
def __init__(self, video):
5+
self.video = video
6+
7+
def __getattr__(self, item):
8+
return getattr(self.video, item)
9+
10+
@property
11+
def output_format(self):
12+
return "%s.%s" % (self.filename, self.extension)
13+
14+
def prepare_thumbnails(self):
15+
"""Prepare the thumbnails before generating the output."""
16+
raise NotImplementedError
17+
18+
def generate(self):
19+
"""Generate the thumbnails for the given video."""
20+
raise NotImplementedError
21+
22+
23+
class ThumbnailFactory:
24+
thumbnails = {}
25+
26+
@classmethod
27+
def get_thumbnail(cls, typename, *args, **kwargs) -> ThumbnailFormat:
28+
try:
29+
return cls.thumbnails[typename](*args, **kwargs)
30+
except KeyError:
31+
raise ValueError("Thumbnail type '%s' is not supported." % typename)

thumbnails/thumbnails.py

Lines changed: 24 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from datetime import timedelta
77
from tempfile import TemporaryDirectory
88

9-
from PIL import Image
109
from imageio_ffmpeg import get_ffmpeg_exe
1110

1211
from .ffmpeg import _FFMpeg
@@ -56,15 +55,16 @@ def height(self):
5655

5756

5857
class Thumbnails(_ThumbnailMixin, _FFMpeg):
59-
def __init__(self, filename):
60-
self.__compress = 1.
61-
self.__interval = 1.
62-
self.__basepath = ""
63-
self.thumbnails = []
64-
self.tempdir = TemporaryDirectory()
58+
def __init__(self, filename, compress, interval, basepath):
59+
self.__compress = float(compress)
60+
self.__interval = float(interval)
61+
self.__basepath = basepath
62+
63+
if self.__compress <= 0 or self.__compress > 1:
64+
raise ValueError("Compress must be between 0 and 1.")
65+
6566
self.filename = filename
66-
self._vtt_name = filename + ".vtt"
67-
self._image_name = filename + ".png"
67+
self.tempdir = TemporaryDirectory()
6868

6969
_FFMpeg.__init__(self, filename)
7070
_ThumbnailMixin.__init__(self, self.size)
@@ -73,43 +73,25 @@ def __init__(self, filename):
7373
def compress(self):
7474
return self.__compress
7575

76-
@compress.setter
77-
def compress(self, value):
78-
try:
79-
self.__compress = float(value)
80-
except ValueError:
81-
raise ValueError("Compress must be a number.")
82-
8376
@property
8477
def interval(self):
8578
return self.__interval
8679

87-
@interval.setter
88-
def interval(self, value):
89-
try:
90-
self.__interval = float(value)
91-
except ValueError:
92-
raise ValueError("Interval must be a number.")
93-
9480
@property
9581
def basepath(self):
9682
return self.__basepath
9783

98-
@basepath.setter
99-
def basepath(self, value):
100-
self.__basepath = value
101-
10284
@staticmethod
103-
def _calc_columns(frames_count, width, height):
85+
def calc_columns(frames_count, width, height):
10486
ratio = 16 / 9
10587
for col in range(1, frames_count):
10688
if (col * width) / (frames_count // col * height) > ratio:
10789
return col
10890

10991
def _extract_frame(self, start_time):
11092
_input_file = self.filename
111-
_output_file = "%s/%s-%s.png" % (self.tempdir.name, start_time, self.filename)
11293
_timestamp = str(timedelta(seconds=start_time))
94+
_output_file = "%s/%s-%s.png" % (self.tempdir.name, _timestamp, self.filename)
11395

11496
cmd = (
11597
ffmpeg_bin,
@@ -128,48 +110,24 @@ def extract_frames(self):
128110
with concurrent.futures.ThreadPoolExecutor() as executor:
129111
executor.map(self._extract_frame, _intervals)
130112

131-
def join_frames(self):
113+
def thumbnails(self, master_size=False):
132114
line, column = 0, 0
133115
frames = sorted(glob.glob(self.tempdir.name + os.sep + "*.png"))
134116
frames_count = len(arange(0, self.duration, self.interval))
135-
columns = self._calc_columns(frames_count, self.width, self.height)
136-
master_height = self.height * math.ceil(frames_count / columns)
137-
master = Image.new(mode="RGBA", size=(self.width * columns, master_height))
117+
columns = self.calc_columns(frames_count, self.width, self.height)
138118

139-
for n, frame in enumerate(frames):
140-
with Image.open(frame) as image:
141-
x, y = self.width * column, self.height * line
142-
143-
start = n * self.interval
144-
end = (n + 1) * self.interval
145-
self.thumbnails.append((start, end, x, y))
146-
147-
image = image.resize((self.width, self.height), Image.ANTIALIAS)
148-
master.paste(image, (x, y))
149-
150-
column += 1
119+
if master_size:
120+
yield self.width * columns, self.height * math.ceil(frames_count / columns)
151121

152-
if column == columns:
153-
line += 1
154-
column = 0
155-
156-
master.save(self._image_name)
157-
self.tempdir.cleanup()
158-
159-
def to_vtt(self):
160-
def _format_time(secs):
161-
delta = timedelta(seconds=secs)
162-
return ("0%s.000" % delta)[:12]
122+
for n, frame in enumerate(frames):
123+
x, y = self.width * column, self.height * line
163124

164-
_lines = ["WEBVTT\n\n"]
165-
_img_src = self.basepath + self._image_name
125+
start = n * self.interval
126+
end = (n + 1) * self.interval
127+
yield frame, start, end, x, y
166128

167-
for start, end, x, y in self.thumbnails:
168-
_thumbnail = "%s --> %s\n%s#xywh=%d,%d,%d,%d\n\n" % (
169-
_format_time(start), _format_time(end),
170-
_img_src, x, y, self.width, self.height
171-
)
172-
_lines.append(_thumbnail)
129+
column += 1
173130

174-
with open(self._vtt_name, "w") as vtt:
175-
vtt.writelines(_lines)
131+
if column == columns:
132+
line += 1
133+
column = 0

0 commit comments

Comments
 (0)