From 0f18fedf1386c9f67b5ecd59462ecdac68542443 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 4 Feb 2021 14:18:47 +0530 Subject: [PATCH] Add support for animations to icat --- kittens/icat/main.py | 86 +++++++++++++++++++++++++++++++++++++++---- kittens/tui/images.py | 15 +++++++- 2 files changed, 93 insertions(+), 8 deletions(-) diff --git a/kittens/icat/main.py b/kittens/icat/main.py index 4a97cecd8..46c2e051c 100755 --- a/kittens/icat/main.py +++ b/kittens/icat/main.py @@ -5,8 +5,8 @@ import contextlib import os import re -import socket import signal +import socket import sys import zlib from base64 import standard_b64encode @@ -17,18 +17,18 @@ from typing import ( Dict, Generator, List, NamedTuple, Optional, Pattern, Tuple, Union ) -from kitty.guess_mime_type import guess_type from kitty.cli import parse_args from kitty.cli_stub import IcatCLIOptions from kitty.constants import appname +from kitty.guess_mime_type import guess_type from kitty.typing import GRT_f, GRT_t from kitty.utils import ( TTYIO, ScreenSize, ScreenSizeGetter, fit_image, screen_size_function ) from ..tui.images import ( - ConvertFailed, GraphicsCommand, NoImageMagick, OpenFailed, render_as_single_image, fsenc, - identify + ConvertFailed, Dispose, GraphicsCommand, NoImageMagick, OpenFailed, + RenderedImage, fsenc, identify, render_as_single_image, render_image ) from ..tui.operations import clear_images_on_screen, raw_mode @@ -207,7 +207,8 @@ def show( fmt: 'GRT_f', transmit_mode: 'GRT_t' = 't', align: str = 'center', - place: Optional['Place'] = None + place: Optional['Place'] = None, + use_number: int = 0 ) -> None: cmd = GraphicsCommand() cmd.a = 'T' @@ -215,6 +216,9 @@ def show( cmd.s = width cmd.v = height cmd.z = zindex + if use_number: + cmd.I = use_number # noqa + cmd.q = 2 if place: set_cursor_for_place(place, cmd, width, height, align) else: @@ -232,6 +236,56 @@ def show( write_chunked(cmd, data) +def show_frames(frame_data: RenderedImage, use_number: int) -> None: + transmit_cmd = GraphicsCommand() + transmit_cmd.a = 'f' + transmit_cmd.I = use_number # noqa + transmit_cmd.q = 2 + if can_transfer_with_files: + transmit_cmd.t = 't' + transmit_cmd.f = 24 if frame_data.mode == 'rgb' else 32 + + def control(frame_number: int = 0, loops: Optional[int] = None, gap: Optional[int] = 0, start_animation: bool = False) -> None: + cmd = GraphicsCommand() + cmd.a = 'a' + cmd.I = use_number # noqa + cmd.r = frame_number + if loops is not None: + cmd.v = loops + 1 + if gap is not None: + cmd.z = gap if gap > 0 else -1 + if start_animation: + cmd.s = 1 + write_gr_cmd(cmd) + + anchor_frame = 0 + + for frame in frame_data.frames: + frame_number = frame.index + 1 + if frame.dispose < Dispose.previous: + anchor_frame = frame_number + if frame_number == 1: + control(frame_number, gap=frame.gap, loops=1) + continue + if frame.dispose is Dispose.previous: + if anchor_frame != frame_number: + transmit_cmd.c = anchor_frame + else: + transmit_cmd.c = (frame_number - 1) if frame.needs_blend else 0 + transmit_cmd.s = frame.width + transmit_cmd.v = frame.height + transmit_cmd.x = frame.canvas_x + transmit_cmd.y = frame.canvas_y + transmit_cmd.z = frame.gap if frame.gap > 0 else -1 + if can_transfer_with_files: + write_gr_cmd(transmit_cmd, standard_b64encode(os.path.abspath(frame.path).encode(fsenc))) + else: + with open(frame.path, 'rb') as f: + data = f.read() + write_chunked(transmit_cmd, data) + control(loops=0, start_animation=True) + + def parse_z_index(val: str) -> int: origin = 0 if val.startswith('--'): @@ -254,6 +308,7 @@ def process(path: str, args: IcatCLIOptions, parsed_opts: ParsedOpts, is_tempfil needs_scaling = m.width > available_width or m.height > available_height needs_scaling = needs_scaling or args.scale_up file_removed = False + use_number = 0 if m.fmt == 'png' and not needs_scaling: outfile = path transmit_mode: 'GRT_t' = 't' if is_tempfile else 'f' @@ -263,8 +318,25 @@ def process(path: str, args: IcatCLIOptions, parsed_opts: ParsedOpts, is_tempfil else: fmt = 24 if m.mode == 'rgb' else 32 transmit_mode = 't' - outfile, width, height = render_as_single_image(path, m, available_width, available_height, args.scale_up) - show(outfile, width, height, parsed_opts.z_index, fmt, transmit_mode, align=args.align, place=parsed_opts.place) + if len(m) == 1: + outfile, width, height = render_as_single_image(path, m, available_width, available_height, args.scale_up) + else: + import struct + use_number = max(1, struct.unpack('@I', os.urandom(4))[0]) + with NamedTemporaryFile() as f: + prefix = f.name + frame_data = render_image(path, prefix, m, available_width, available_height, args.scale_up) + outfile, width, height = frame_data.frames[0].path, frame_data.width, frame_data.height + show( + outfile, width, height, parsed_opts.z_index, fmt, transmit_mode, + align=args.align, place=parsed_opts.place, use_number=use_number + ) + if use_number: + show_frames(frame_data, use_number) + if not can_transfer_with_files: + for fr in frame_data.frames: + with contextlib.suppress(FileNotFoundError): + os.unlink(fr.path) if not args.place: print() # ensure cursor is on a new line return file_removed diff --git a/kittens/tui/images.py b/kittens/tui/images.py index 2c14ae2a5..bb954deba 100644 --- a/kittens/tui/images.py +++ b/kittens/tui/images.py @@ -8,6 +8,7 @@ import sys from base64 import standard_b64encode from collections import defaultdict, deque from contextlib import suppress +from enum import IntEnum from itertools import count from typing import ( Any, Callable, DefaultDict, Deque, Dict, Iterator, List, Optional, @@ -30,6 +31,13 @@ except Exception: fsenc = 'utf-8' +class Dispose(IntEnum): + undefined = 0 + none = 1 + background = 2 + previous = 3 + + class Frame: gap: int # milliseconds canvas_width: int @@ -43,6 +51,7 @@ class Frame: canvas_y: int mode: str needs_blend: bool + dispose: Dispose path: str = '' def __init__(self, identify_data: Union['Frame', Dict[str, str]]): @@ -60,6 +69,7 @@ class Frame: q = identify_data['transparency'].lower() self.mode = 'rgba' if q in ('blend', 'true') else 'rgb' self.needs_blend = q == 'blend' + self.dispose = getattr(Dispose, identify_data['dispose'].lower()) def __repr__(self) -> str: canvas = f'{self.canvas_width}x{self.canvas_height}:{self.canvas_x}+{self.canvas_y}' @@ -120,7 +130,7 @@ def run_imagemagick(path: str, cmd: Sequence[str], keep_stdout: bool = True) -> def identify(path: str) -> ImageData: import json - q = '{"fmt":"%m","canvas":"%g","transparency":"%A","gap":"%T","index":"%p","size":"%wx%h","dpi":"%xx%y"},' + q = '{"fmt":"%m","canvas":"%g","transparency":"%A","gap":"%T","index":"%p","size":"%wx%h","dpi":"%xx%y","dispose":"%D"},' exe = find_exe('magick') if exe: cmd = [exe, 'identify'] @@ -275,6 +285,9 @@ class GraphicsCommand: z: int = 0 # z-index d: GRT_d = 'a' # what to delete + def __repr__(self) -> str: + return self.serialize().decode('ascii').replace('\033', '^]') + def serialize(self, payload: Union[bytes, str] = b'') -> bytes: items = [] for k in GraphicsCommand.__annotations__: