Modify ImageMagick wrapper to handle animated images
This commit is contained in:
parent
edf4e14e4c
commit
9cf5348c36
@ -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
|
||||||
)
|
|
||||||
|
|
||||||
# }}}
|
# }}}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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))
|
||||||
|
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user