Skip to content

Commit 9582d6a

Browse files
GH-6: Fix the memory leaking issue (GH-16)
- Optimize the code - remove the redundant snippets - Replace lists with tuples - Optimize imports (scanned with cProfile) - Change the `interval` default to `1` - Optimize the memory usage by not keeping the frames in dynamic memory - Implement `compress` argument usage
1 parent e1c4980 commit 9582d6a

File tree

3 files changed

+95
-92
lines changed

3 files changed

+95
-92
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ thumbnails [options]
4444
|-------------|:-------:|-----------------------------------------------------------------------------------------------|
4545
| `parallel` | `false` | Process in parallel to make it faster. |
4646
| `overwrite` | `false` | Overwrite the existing files with new ones. |
47-
| `interval` | 0.5 | The interval between two thumbnails in seconds. |
47+
| `interval` | 1 | The interval between two thumbnails in seconds. |
4848
| `compress` | 1 | The image compression coefficient is a number from 0 to 1 where 1 means _no compression_. |
4949
| `as` | `vtt` | Output as either `VTT` or `JSON` format. |
5050
| `basepath` | - | In case the relative path does not work, the prefix of the thumbnails path can be customized. |

ffmpeg/ffmpeg.py

Lines changed: 84 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,54 @@
1+
import concurrent.futures
2+
import glob
3+
import math
14
import os
25
import re
36
import subprocess
4-
from functools import reduce
7+
from datetime import timedelta
8+
from tempfile import TemporaryDirectory
59

6-
import imageio_ffmpeg
7-
import numpy
810
from PIL import Image
911
from imageio.v3 import immeta
12+
from imageio_ffmpeg import get_ffmpeg_exe
1013

11-
FFMPEG_BINARY = imageio_ffmpeg.get_ffmpeg_exe()
14+
FFMPEG_BINARY = get_ffmpeg_exe()
1215

1316

1417
class FFMpeg:
1518
def __init__(self, filename):
16-
duration, size = self.parse_metadata(filename)
17-
self.area = reduce(int.__mul__, size)
18-
self.bytes = self.area * 4
19+
self.__compress = 1
20+
self.__interval = 1
21+
duration, size = self._parse_metadata(filename)
22+
self.tempdir = TemporaryDirectory()
1923
self.filename = filename
2024
self.duration = duration
2125
self.size = size
2226

27+
def get_compress(self):
28+
return self.__compress
29+
30+
def set_compress(self, compress):
31+
if type(compress) not in (int, float):
32+
raise TypeError("Compress must be a number.")
33+
self.__compress = compress
34+
35+
def get_interval(self):
36+
return self.__interval
37+
38+
def set_interval(self, interval):
39+
if not isinstance(interval, int):
40+
raise TypeError("Interval must be an integer.")
41+
self.__interval = interval
42+
2343
@staticmethod
24-
def cross_platform_popen_params(bufsize=100000):
44+
def calc_columns(frames_count, width, height):
45+
ratio = 16 / 9
46+
for col in range(1, frames_count):
47+
if (col * width) / (frames_count // col * height) > ratio:
48+
return col
49+
50+
@staticmethod
51+
def _cross_platform_popen_params(bufsize=100000):
2552
popen_params = {
2653
"bufsize": bufsize,
2754
"stdout": subprocess.PIPE,
@@ -36,7 +63,7 @@ def cross_platform_popen_params(bufsize=100000):
3663
def _parse_duration(stdout):
3764
duration_regex = r"duration[^\n]+([0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9])"
3865
time = re.search(duration_regex, stdout, re.M | re.I).group(1)
39-
time = [float(part.replace(",", ".")) for part in time.split(":")]
66+
time = (float(part.replace(",", ".")) for part in time.split(":"))
4067
return sum(mult * part for mult, part in zip((3600, 60, 1), time))
4168

4269
@staticmethod
@@ -45,67 +72,72 @@ def _parse_size(stdout):
4572
match_size = re.search(size_regex, stdout, re.M)
4673
return tuple(map(int, match_size.groups()))
4774

48-
def parse_metadata(self, filename):
75+
def _parse_metadata(self, filename):
4976
meta = immeta(filename)
5077
duration, size = meta.get("duration"), meta.get("size")
5178

52-
if not all([duration, size]):
53-
cmd = [FFMPEG_BINARY, "-hide_banner", "-i", filename]
79+
if not all((duration, size)):
80+
cmd = (FFMPEG_BINARY, "-hide_banner", "-i", filename)
5481

55-
popen_params = self.cross_platform_popen_params()
82+
popen_params = self._cross_platform_popen_params()
5683
process = subprocess.Popen(cmd, **popen_params)
5784
_, stderr = process.communicate()
5885
stdout = stderr.decode("utf8", errors="ignore")
5986

60-
process.terminate()
61-
del process
62-
6387
duration = self._parse_duration(stdout)
6488
size = self._parse_size(stdout)
6589

6690
return duration, size
6791

68-
@staticmethod
69-
def frame_to_buffer(image):
70-
image = image.astype("uint8")
71-
return Image.fromarray(image)
72-
73-
def get_frame(self, start_time):
74-
if start_time != 0:
75-
offset = min(1, start_time)
76-
i_arg = [
77-
"-ss", "%.06f" % (start_time - offset),
78-
"-i", self.filename,
79-
"-ss", "%.06f" % offset,
80-
]
81-
else:
82-
i_arg = ["-i", self.filename]
92+
def _extract_frame(self, start_time):
93+
_input_file = self.filename
94+
_output_file = "%s/%d-%s.png" % (self.tempdir.name, start_time, self.filename)
95+
_timestamp = str(timedelta(seconds=start_time))
8396

8497
cmd = (
85-
[FFMPEG_BINARY]
86-
+ i_arg
87-
+ [
88-
"-loglevel", "error",
89-
"-f", "image2pipe",
90-
"-vf", "scale=%d:%d" % tuple(self.size),
91-
"-sws_flags", "bicubic",
92-
"-pix_fmt", "rgba",
93-
"-vcodec", "rawvideo", "-",
94-
]
98+
FFMPEG_BINARY,
99+
"-ss", _timestamp,
100+
"-i", _input_file,
101+
"-loglevel", "error",
102+
"-vframes", "1",
103+
_output_file,
104+
"-y",
95105
)
96106

97-
popen_params = self.cross_platform_popen_params(self.bytes + 100)
98-
process = subprocess.Popen(cmd, **popen_params)
99-
buffer = process.stdout.read(self.bytes)
107+
subprocess.Popen(cmd).wait()
108+
109+
def extract_frames(self):
110+
_intervals = range(0, int(self.duration), self.get_interval())
111+
with concurrent.futures.ThreadPoolExecutor() as executor:
112+
executor.map(self._extract_frame, _intervals)
113+
114+
def join_frames(self):
115+
width, height = self.size
116+
117+
_min_width = 300
118+
_min_height = math.ceil(_min_width * height / width)
119+
120+
width = max(_min_width, width * self.__compress / 10)
121+
height = max(_min_height, height * self.__compress / 10)
122+
123+
line, column = 0, 0
124+
frames = sorted(glob.glob(self.tempdir.name + os.sep + "*.png"))
125+
frames_count = len(range(0, int(self.duration), self.get_interval()))
126+
columns = self.calc_columns(frames_count, width, height)
127+
master_height = height * int(math.ceil(float(frames_count) / columns))
128+
master = Image.new(mode="RGBA", size=(width * columns, master_height))
100129

101-
process.terminate()
102-
del process
130+
for frame in frames:
131+
with Image.open(frame) as image:
132+
x, y = width * column, height * line
133+
image = image.resize((width, height), Image.ANTIALIAS)
134+
master.paste(image, (x, y))
103135

104-
if hasattr(numpy, "frombuffer"):
105-
result = numpy.frombuffer(buffer, dtype="uint8")
106-
else:
107-
result = numpy.fromstring(buffer, dtype="uint8")
136+
column += 1
108137

109-
result.shape = (*self.size[::-1], len(buffer) // self.area)
138+
if column == columns:
139+
line += 1
140+
column = 0
110141

111-
return result
142+
master.save(self.filename + ".png")
143+
self.tempdir.cleanup()

thumbnails.py

Lines changed: 10 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,26 @@
1-
import asyncio
21
import concurrent.futures
3-
import math
4-
5-
from PIL import Image
62

73
from ffmpeg import FFMpeg
84

9-
width, height = 300, 200
5+
# Read from the program arguments.
6+
compress = 1
107
interval = 20
11-
columns = 3
128

139
files = ["valerian-1080p.avi", "valerian-1080p.mkv", "valerian-1080p.mov", "valerian-1080p.mp4",
1410
"valerian-1080p.webm", "valerian-1080p.wmv", "valerian-1080p.mpeg", "valerian-1080p.mpg", "valerian-1080p.ogv"]
1511

1612

1713
def worker(video):
18-
line, column = 0, 0
19-
frames_count = len(range(0, int(video.duration), interval))
20-
master_width = width * columns
21-
master_height = height * int(math.ceil(float(frames_count) / columns))
22-
23-
try:
24-
master = Image.new(mode="RGBA", size=(master_width, master_height))
25-
except IOError:
26-
master = Image.new(mode="RGB", size=(master_width, master_height))
27-
28-
with concurrent.futures.ThreadPoolExecutor() as executor:
29-
frames = executor.map(video.get_frame, range(0, int(video.duration), interval))
30-
31-
for frame in frames:
32-
x, y = width * column, height * line
33-
image = video.frame_to_buffer(frame)
34-
image = image.resize((width, height), Image.ANTIALIAS)
35-
master.paste(image, (x, y))
36-
37-
column += 1
38-
39-
if column == columns:
40-
line += 1
41-
column = 0
42-
43-
master.save(video.filename + ".png")
44-
14+
video.set_compress(compress)
15+
video.set_interval(interval)
16+
video.extract_frames()
17+
video.join_frames()
4518

46-
async def main():
47-
fps = [FFMpeg(fp) for fp in files]
4819

20+
def main():
4921
with concurrent.futures.ProcessPoolExecutor() as executor:
50-
executor.map(worker, fps)
22+
executor.map(worker, map(FFMpeg, files))
5123

5224

53-
loop = asyncio.get_event_loop()
54-
loop.run_until_complete(main())
55-
loop.close()
25+
if __name__ == "__main__":
26+
main()

0 commit comments

Comments
 (0)