Skip to content

Commit

Permalink
Optimizations for animated images
Browse files Browse the repository at this point in the history
- Fix: `image.n_frames` is now computed on-demand i.e the value is not computed until it's required.
  - It's computed only the first time the property is invoked and subsequent invocations simply return the previously computed value.
  - Eliminates delay during image initialization.
- Change: Image animation (except in the TUI) is no longer dependent on `image.n_frames`.
  - `image.n_frames` might also be computed in the course image animation, as an optimization.
- Change: Updated related tests.
  • Loading branch information
AnonymouX47 committed Feb 19, 2022
1 parent bc36925 commit 968e6e2
Show file tree
Hide file tree
Showing 3 changed files with 62 additions and 29 deletions.
81 changes: 56 additions & 25 deletions term_img/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ def __init__(
if self._is_animated:
self._frame_duration = 0.1
self._seek_position = 0
self._n_frames = image.n_frames
self._n_frames = None

# Recognized advanced sizing options.
# These are initialized here only to avoid `AttributeError`s in case `_size` is
Expand Down Expand Up @@ -217,10 +217,24 @@ def frame_duration(self, value: float) -> None:
lambda self: self._original_size, doc="Original image size"
)

n_frames = property(
lambda self: self._n_frames if self._is_animated else 1,
doc="The number of frames in the image",
)
@property
def n_frames(self) -> int:
"""The number of frames in the image
NOTE: The first invocation of this property might take a while for images
with large number of frames but subsequent invocations won't.
"""
if not self._is_animated:
return 1

if not self._n_frames:
self._n_frames = (
Image.open(self._source)
if isinstance(self._source, str)
else self._source
).n_frames

return self._n_frames

rendered_height = property(
lambda self: ceil(
Expand Down Expand Up @@ -444,7 +458,7 @@ def draw(
NOTE:
* Animations, if not disabled, are infinitely looped but can be terminated
with ``Ctrl-C`` (``SIGINT`` or "KeyboardInterrupt").
with ``Ctrl-C`` (``SIGINT``), raising ``KeyboardInterrupt``.
* If :py:meth:`set_size()` was previously used to set the
:term:`render size` (directly or not), the last values of its
*check_height*, *h_allow* and *v_allow* parameters are taken into
Expand Down Expand Up @@ -628,12 +642,13 @@ def seek(self, pos: int) -> None:
appropriate type.
Frame numbers start from 0 (zero).
NOTE: `image.n_frames` will have to be computed if it hasn't already been.
"""
if not isinstance(pos, int):
raise TypeError(f"Invalid seek position type (got: {type(pos).__name__})")
if not 0 <= pos < self._n_frames if self._is_animated else pos:
if not 0 <= pos < self.n_frames if self._is_animated else pos:
raise ValueError(
f"Invalid frame number (got: {pos}, n_frames={self.n_frames})"
f"Invalid frame number (got: {pos}, n_frames={self._n_frames})"
)
if self._is_animated:
self._seek_position = pos
Expand Down Expand Up @@ -848,43 +863,59 @@ def _display_animated(
) -> None:
"""Displays an animated GIF image in the terminal.
This is done infinitely but can be terminated with ``Ctrl-C``.
NOTE:
- This is done indefinitely but can be terminated with ``Ctrl-C``, thereby
raising ``KeyboardInterrupt``.
- ``image.n_frames`` might also be computed in the course image animation,
as an optimization.
"""
lines = max(
(fmt or (None,))[-1] or get_terminal_size()[1] - self._v_allow,
self.rendered_height,
)
cache = [None] * self._n_frames
cache = []
prev_seek_pos = self._seek_position
try:
# By implication, the first frame is repeated once at the start :D
self.seek(0)
cache[0] = frame = self._format_render(
self._render_image(image, alpha), *fmt
)
caching = True
duration = self._frame_duration
for n in cycle(range(self._n_frames)):
print(frame, end="", flush=True) # Current frame
self._seek_position = 0
cache.append(self._format_render(self._render_image(image, alpha), *fmt))
while caching:
print(cache[-1], end="", flush=True) # Current frame

# Render next frame during current frame's duration
start = time.time()
self._seek_position += 1
self._buffer.truncate() # Clear buffer
self.seek(n)
if cache[n]:
frame = cache[n]
else:
cache[n] = frame = self._format_render(
self._render_image(image, alpha),
*fmt,
try:
cache.append(
self._format_render(self._render_image(image, alpha), *fmt)
)
except EOFError:
self._n_frames = self._seek_position
caching = False

# Move cursor up to the begining of the first line of the image
# Not flushed until the next frame is printed
print("\r\033[%dA" % (lines - 1), end="")

# Left-over of current frame's duration
time.sleep(max(0, duration - (time.time() - start)))

for n in cycle(range(self._n_frames)):
print(cache[n], end="", flush=True) # Current frame

# Render next frame during current frame's duration
start = time.time()

# Move cursor up to the begining of the first line of the image
# Not flushed until the next frame is printed
print("\r\033[%dA" % (lines - 1), end="")

# Left-over of current frame's duration
time.sleep(max(0, duration - (time.time() - start)))
finally:
self.seek(prev_seek_pos)
self._seek_position = prev_seek_pos
# Move the cursor to the line after the image
# Prevents "overlayed" output in the terminal
print("\033[%dB" % lines, end="", flush=True)
Expand Down
2 changes: 1 addition & 1 deletion term_img/tui/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def change_frame(*_) -> None:
Image._frame_cache = None

image = image_widget._image
Image._frame_cache = [None] * image._n_frames
Image._frame_cache = [None] * image.n_frames
image.seek(0)
n = 1
last_alarm = loop.set_alarm_in(FRAME_DURATION, change_frame)
Expand Down
8 changes: 5 additions & 3 deletions tests/test_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def test_constructor(self):
assert image._is_animated is True
assert image._frame_duration == 0.1
assert image._seek_position == 0
assert image._n_frames == image.n_frames
assert image._n_frames is None

# Ensure size arguments get through to `set_size()`
with pytest.raises(ValueError, match=r".* both width and height"):
Expand Down Expand Up @@ -127,10 +127,12 @@ def test_n_frames(self):
assert image.n_frames == 1

image = TermImage(anim_img)
assert image.n_frames > 1
n_frames = image.n_frames # On-demand computation
assert n_frames > 1
assert image.n_frames == image._n_frames == n_frames

with pytest.raises(AttributeError):
image.n_frames = 0
image.n_frames = 2

def test_rendered_size_height_width(self):
image = TermImage(python_img)
Expand Down

0 comments on commit 968e6e2

Please sign in to comment.