Modify ImageMagick wrapper to handle animated images

This commit is contained in:
Kovid Goyal 2021-02-01 14:12:26 +05:30
parent edf4e14e4c
commit 9cf5348c36
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
7 changed files with 101 additions and 80 deletions

View File

@ -9,9 +9,8 @@ from gettext import gettext as _
from typing import Any, Dict, Sequence, Union from typing import Any, Dict, Sequence, Union
from kitty.conf.definition import Option, Shortcut, option_func from kitty.conf.definition import Option, Shortcut, option_func
from kitty.conf.utils import ( from kitty.conf.utils import python_string, to_color, to_color_or_none
positive_int, python_string, to_color, to_color_or_none from kitty.utils import positive_int
)
# }}} # }}}

View File

@ -27,7 +27,7 @@ from kitty.utils import (
) )
from ..tui.images import ( from ..tui.images import (
ConvertFailed, GraphicsCommand, NoImageMagick, OpenFailed, convert, fsenc, ConvertFailed, GraphicsCommand, NoImageMagick, OpenFailed, render_as_single_image, fsenc,
identify identify
) )
from ..tui.operations import clear_images_on_screen, raw_mode from ..tui.operations import clear_images_on_screen, raw_mode
@ -263,7 +263,7 @@ def process(path: str, args: IcatCLIOptions, parsed_opts: ParsedOpts, is_tempfil
else: else:
fmt = 24 if m.mode == 'rgb' else 32 fmt = 24 if m.mode == 'rgb' else 32
transmit_mode = 't' transmit_mode = 't'
outfile, width, height = convert(path, m, available_width, available_height, args.scale_up) 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) show(outfile, width, height, parsed_opts.z_index, fmt, transmit_mode, align=args.align, place=parsed_opts.place)
if not args.place: if not args.place:
print() # ensure cursor is on a new line print() # ensure cursor is on a new line

View File

@ -17,7 +17,9 @@ from typing import (
from kitty.typing import ( from kitty.typing import (
CompletedProcess, GRT_a, GRT_d, GRT_f, GRT_m, GRT_o, GRT_t, HandlerType CompletedProcess, GRT_a, GRT_d, GRT_f, GRT_m, GRT_o, GRT_t, HandlerType
) )
from kitty.utils import ScreenSize, find_exe, fit_image from kitty.utils import (
ScreenSize, find_exe, fit_image, positive_float, positive_int
)
from .operations import cursor from .operations import cursor
@ -30,29 +32,42 @@ except Exception:
class Frame: class Frame:
gap: int # milliseconds gap: int # milliseconds
canvas_width: int
canvas_height: int
width: int width: int
height: int height: int
index: int index: int
x: int = 0 xdpi: float
y: int = 0 ydpi: float
canvas_x: int
canvas_y: int
path: str = '' path: str = ''
def __init__(self, identify_data: Dict[str, str]): def __init__(self, identify_data: Union['Frame', Dict[str, str]]):
self.gap = int(identify_data['gap']) * 10 if isinstance(identify_data, Frame):
sz, pos = identify_data['geometry'].split('+', 1) for k in Frame.__annotations__:
self.width, self.height = map(int, sz.split('x', 1)) setattr(self, k, getattr(identify_data, k))
self.x, self.y = map(int, pos.split('+', 1)) else:
self.index = int(identify_data['index']) self.gap = max(0, int(identify_data['gap']) * 10)
sz, pos = identify_data['canvas'].split('+', 1)
self.canvas_width, self.canvas_height = map(positive_int, sz.split('x', 1))
self.canvas_x, self.canvas_y = map(int, pos.split('+', 1))
self.width, self.height = map(positive_int, identify_data['size'].split('x', 1))
self.xdpi, self.ydpi = map(positive_float, identify_data['dpi'].split('x', 1))
self.index = positive_int(identify_data['index'])
def __repr__(self) -> str:
canvas = f'{self.canvas_width}x{self.canvas_height}:{self.canvas_x}+{self.canvas_y}'
geom = f'{self.width}x{self.height}'
return f'Frame(index={self.index}, gap={self.gap}, {geom=}, {canvas=})'
class ImageData: class ImageData:
def __init__(self, fmt: str, width: int, height: int, mode: str, frames: Sequence[Frame]): def __init__(self, fmt: str, width: int, height: int, mode: str, frames: List[Frame]):
self.width, self.height, self.fmt, self.mode = width, height, fmt, mode self.width, self.height, self.fmt, self.mode = width, height, fmt, mode
self.transmit_fmt: GRT_f = (24 if self.mode == 'rgb' else 32) self.transmit_fmt: GRT_f = (24 if self.mode == 'rgb' else 32)
self.frames = frames self.frames = frames
self.is_animated = len(frames) > 1
self.temporary_directory_object = None
def __len__(self) -> int: def __len__(self) -> int:
return len(self.frames) return len(self.frames)
@ -60,6 +75,10 @@ class ImageData:
def __iter__(self) -> Iterator[Frame]: def __iter__(self) -> Iterator[Frame]:
yield from self.frames yield from self.frames
def __repr__(self) -> str:
frames = '\n '.join(map(repr, self.frames))
return f'Image(fmt={self.fmt}, mode={self.mode},\n {frames}\n)'
class OpenFailed(ValueError): class OpenFailed(ValueError):
@ -96,35 +115,39 @@ def run_imagemagick(path: str, cmd: Sequence[str], keep_stdout: bool = True) ->
def identify(path: str) -> ImageData: def identify(path: str) -> ImageData:
import json import json
q = '{"fmt":"%m","geometry":"%g","transparency":"%A","gap":"%T","index":"%p"},' q = '{"fmt":"%m","canvas":"%g","transparency":"%A","gap":"%T","index":"%p","size":"%wx%h","dpi":"%xx%y"},'
p = run_imagemagick(path, ['identify', '-format', q, '--', path]) p = run_imagemagick(path, ['identify', '-format', q, '--', path])
data = json.loads(b'[' + p.stdout.rstrip(b',') + b']') data = json.loads(b'[' + p.stdout.rstrip(b',') + b']')
frame = data[0] frame = data[0]
mode = 'rgba' if frame['transparency'].lower() in ('blend', 'true') else 'rgb' all_transparencies = {f['transparency'].lower() for f in data}
mode = 'rgba' if all_transparencies & {'blend', 'true'} else 'rgb'
fmt = frame['fmt'].lower() fmt = frame['fmt'].lower()
if fmt == 'jpeg': if fmt == 'jpeg':
mode = 'rgb' mode = 'rgb'
frames = tuple(map(Frame, data)) frames = list(map(Frame, data))
return ImageData(fmt, frames[0].width, frames[0].height, mode, frames) return ImageData(fmt, frames[0].width, frames[0].height, mode, frames)
class RenderedImage: class RenderedImage(ImageData):
pass
def __init__(self, fmt: str, width: int, height: int, mode: str):
super().__init__(fmt, width, height, mode, [])
def render_image( def render_image(
path: str, m: ImageData, path: str, output_template: str,
m: ImageData,
available_width: int, available_height: int, available_width: int, available_height: int,
scale_up: bool, scale_up: bool,
tdir: Optional[str] = None only_first_frame: bool = False
) -> RenderedImage: ) -> RenderedImage:
from tempfile import mkdtemp
base = mkdtemp(prefix='kitty-tui-image-', dir=tdir)
exe = find_exe('convert') exe = find_exe('convert')
if exe is None: if exe is None:
raise OSError('Failed to find the ImageMagick convert executable, make sure it is present in PATH') raise OSError('Failed to find the ImageMagick convert executable, make sure it is present in PATH')
outfile = os.path.join(base, f'output%07d.{m.mode}')
cmd = [exe, '-background', 'none', '--', path] cmd = [exe, '-background', 'none', '--', path]
index_of_path_in_cmd = len(cmd) - 1
if only_first_frame and len(m) > 1:
cmd[index_of_path_in_cmd] += '[0]'
scaled = False scaled = False
width, height = m.width, m.height width, height = m.width, m.height
if scale_up: if scale_up:
@ -134,50 +157,49 @@ def render_image(
scaled = True scaled = True
if scaled or width > available_width or height > available_height: if scaled or width > available_width or height > available_height:
width, height = fit_image(width, height, available_width, available_height) width, height = fit_image(width, height, available_width, available_height)
cmd += ['-resize', '{}x{}!'.format(width, height)] resize_cmd = ['-resize', '{}x{}!'.format(width, height)]
if not only_first_frame and len(m.frames) > 1:
# we have to coalesce, resize and de-coalesce all frames
resize_cmd = ['-coalesce'] + resize_cmd + ['-deconstruct']
cmd += resize_cmd
cmd += ['-depth', '8'] cmd += ['-depth', '8']
run_imagemagick(path, cmd + [outfile]) run_imagemagick(path, cmd + [output_template])
def convert(
path: str, m: ImageData,
available_width: int, available_height: int,
scale_up: bool,
tdir: Optional[str] = None
) -> Tuple[str, int, int]:
from tempfile import NamedTemporaryFile
width, height = m.width, m.height
exe = find_exe('convert')
if exe is None:
raise OSError('Failed to find the ImageMagick convert executable, make sure it is present in PATH')
cmd = [exe, '-background', 'none', '--', path]
scaled = False
if scale_up:
if width < available_width:
r = available_width / width
width, height = available_width, int(height * r)
scaled = True
if scaled or width > available_width or height > available_height:
width, height = fit_image(width, height, available_width, available_height)
cmd += ['-resize', '{}x{}!'.format(width, height)]
cmd += ['-depth', '8']
with NamedTemporaryFile(prefix='icat-', suffix='.' + m.mode, delete=False, dir=tdir) as outfile:
run_imagemagick(path, cmd + [outfile.name])
# ImageMagick sometimes generated rgba images smaller than the specified
# size. See https://github.com/kovidgoyal/kitty/issues/276 for examples
sz = os.path.getsize(outfile.name)
bytes_per_pixel = 3 if m.mode == 'rgb' else 4 bytes_per_pixel = 3 if m.mode == 'rgb' else 4
expected_size = bytes_per_pixel * width * height ans = RenderedImage(m.fmt, width, height, m.mode)
for i, src in enumerate(m):
frame = Frame(src)
frame.path = output_template if only_first_frame else (output_template % frame.index)
# ImageMagick sometimes generates RGBA images smaller than the specified
# size. See https://github.com/kovidgoyal/kitty/issues/276 for examples
sz = os.path.getsize(frame.path)
expected_size = bytes_per_pixel * frame.width * frame.height
if sz < expected_size: if sz < expected_size:
missing = expected_size - sz missing = expected_size - sz
if missing % (bytes_per_pixel * width) != 0: if missing % (bytes_per_pixel * width) != 0:
raise ConvertFailed( raise ConvertFailed(
path, 'ImageMagick failed to convert {} correctly,' path, 'ImageMagick failed to convert {} correctly,'
' it generated {} < {} of data (w={}, h={}, bpp={})'.format( ' it generated {} < {} of data (w={}, h={}, bpp={})'.format(
path, sz, expected_size, width, height, bytes_per_pixel)) path, sz, expected_size, frame.width, frame.height, bytes_per_pixel))
height -= missing // (bytes_per_pixel * width) frame.height -= missing // (bytes_per_pixel * frame.width)
if i == 0:
ans.height = frame.height
ans.frames.append(frame)
if only_first_frame:
break
return ans
return outfile.name, width, height
def render_as_single_image(
path: str, m: ImageData,
available_width: int, available_height: int,
scale_up: bool,
tdir: Optional[str] = None
) -> Tuple[str, int, int]:
import tempfile
fd, output = tempfile.mkstemp(prefix='icat-', suffix=f'.{m.mode}', dir=tdir)
os.close(fd)
result = render_image(path, output, m, available_width, available_height, scale_up, only_first_frame=True)
return output, result.width, result.height
def can_display_images() -> bool: def can_display_images() -> bool:
@ -398,7 +420,7 @@ class ImageManager:
self.handler.cmd.gr_command(gc) self.handler.cmd.gr_command(gc)
def convert_image(self, path: str, available_width: int, available_height: int, image_data: ImageData, scale_up: bool = False) -> ImageKey: def convert_image(self, path: str, available_width: int, available_height: int, image_data: ImageData, scale_up: bool = False) -> ImageKey:
rgba_path, width, height = convert(path, image_data, available_width, available_height, scale_up, tdir=self.tdir) rgba_path, width, height = render_as_single_image(path, image_data, available_width, available_height, scale_up, tdir=self.tdir)
return rgba_path, width, height return rgba_path, width, height
def transmit_image(self, image_data: ImageData, image_id: int, rgba_path: str, width: int, height: int) -> int: def transmit_image(self, image_data: ImageData, image_id: int, rgba_path: str, width: int, height: int) -> int:

View File

@ -11,7 +11,7 @@ from typing import (
) )
from ..rgb import Color, to_color as as_color from ..rgb import Color, to_color as as_color
from ..types import ParsedShortcut from ..types import ParsedShortcut, ConvertibleToNumbers
from ..utils import expandvars, log_error from ..utils import expandvars, log_error
key_pat = re.compile(r'([a-zA-Z][a-zA-Z0-9_-]*)\s+(.+)$') key_pat = re.compile(r'([a-zA-Z][a-zA-Z0-9_-]*)\s+(.+)$')
@ -35,17 +35,6 @@ def to_color_or_none(x: str) -> Optional[Color]:
return None if x.lower() == 'none' else to_color(x) return None if x.lower() == 'none' else to_color(x)
ConvertibleToNumbers = Union[str, bytes, int, float]
def positive_int(x: ConvertibleToNumbers) -> int:
return max(0, int(x))
def positive_float(x: ConvertibleToNumbers) -> float:
return max(0, float(x))
def unit_float(x: ConvertibleToNumbers) -> float: def unit_float(x: ConvertibleToNumbers) -> float:
return max(0, min(float(x), 1)) return max(0, min(float(x), 1))

View File

@ -13,8 +13,7 @@ from typing import (
from . import fast_data_types as defines from . import fast_data_types as defines
from .conf.definition import Option, Shortcut, option_func from .conf.definition import Option, Shortcut, option_func
from .conf.utils import ( from .conf.utils import (
choices, positive_float, positive_int, to_bool, to_cmdline as tc, to_color, choices, to_bool, to_cmdline as tc, to_color, to_color_or_none, unit_float
to_color_or_none, unit_float
) )
from .constants import config_dir, is_macos from .constants import config_dir, is_macos
from .fast_data_types import CURSOR_BEAM, CURSOR_BLOCK, CURSOR_UNDERLINE from .fast_data_types import CURSOR_BEAM, CURSOR_BLOCK, CURSOR_UNDERLINE
@ -25,7 +24,7 @@ from .key_names import (
from .layout.interface import all_layouts from .layout.interface import all_layouts
from .rgb import Color, color_as_int, color_as_sharp, color_from_int from .rgb import Color, color_as_int, color_as_sharp, color_from_int
from .types import FloatEdges, SingleKey from .types import FloatEdges, SingleKey
from .utils import log_error from .utils import log_error, positive_float, positive_int
class InvalidMods(ValueError): class InvalidMods(ValueError):

View File

@ -2,7 +2,7 @@
# vim:fileencoding=utf-8 # vim:fileencoding=utf-8
# License: GPLv3 Copyright: 2021, Kovid Goyal <kovid at kovidgoyal.net> # License: GPLv3 Copyright: 2021, Kovid Goyal <kovid at kovidgoyal.net>
from typing import NamedTuple from typing import NamedTuple, Union
class ParsedShortcut(NamedTuple): class ParsedShortcut(NamedTuple):
@ -47,3 +47,6 @@ class SingleKey(NamedTuple):
mods: int = 0 mods: int = 0
is_native: bool = False is_native: bool = False
key: int = -1 key: int = -1
ConvertibleToNumbers = Union[str, bytes, int, float]

View File

@ -23,6 +23,7 @@ from .constants import (
) )
from .options_stub import Options from .options_stub import Options
from .rgb import Color, to_color from .rgb import Color, to_color
from .types import ConvertibleToNumbers
from .typing import AddressFamily, PopenType, Socket, StartupCtx from .typing import AddressFamily, PopenType, Socket, StartupCtx
BASE = os.path.dirname(os.path.abspath(__file__)) BASE = os.path.dirname(os.path.abspath(__file__))
@ -632,3 +633,11 @@ class SSHConnectionData(NamedTuple):
binary: str binary: str
hostname: str hostname: str
port: Optional[int] = None port: Optional[int] = None
def positive_int(x: ConvertibleToNumbers) -> int:
return max(0, int(x))
def positive_float(x: ConvertibleToNumbers) -> float:
return max(0, float(x))