images.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  1. #!/usr/bin/env python
  2. # License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
  3. import codecs
  4. import os
  5. import sys
  6. from base64 import standard_b64encode
  7. from collections import defaultdict, deque
  8. from contextlib import suppress
  9. from enum import IntEnum
  10. from itertools import count
  11. from typing import Any, Callable, ClassVar, DefaultDict, Deque, Dict, Generic, Iterator, List, Optional, Sequence, Tuple, Type, TypeVar, Union, cast
  12. from kitty.conf.utils import positive_float, positive_int
  13. from kitty.fast_data_types import create_canvas
  14. from kitty.typing import GRT_C, CompletedProcess, GRT_a, GRT_d, GRT_f, GRT_m, GRT_o, GRT_t, HandlerType
  15. from kitty.utils import ScreenSize, fit_image, which
  16. from .operations import cursor
  17. try:
  18. fsenc = sys.getfilesystemencoding() or 'utf-8'
  19. codecs.lookup(fsenc)
  20. except Exception:
  21. fsenc = 'utf-8'
  22. class Dispose(IntEnum):
  23. undefined = 0
  24. none = 1
  25. background = 2
  26. previous = 3
  27. class Frame:
  28. gap: int # milliseconds
  29. canvas_width: int
  30. canvas_height: int
  31. width: int
  32. height: int
  33. index: int
  34. xdpi: float
  35. ydpi: float
  36. canvas_x: int
  37. canvas_y: int
  38. mode: str
  39. needs_blend: bool
  40. dimensions_swapped: bool
  41. dispose: Dispose
  42. path: str = ''
  43. def __init__(self, identify_data: Union['Frame', Dict[str, str]]):
  44. if isinstance(identify_data, Frame):
  45. for k in Frame.__annotations__:
  46. setattr(self, k, getattr(identify_data, k))
  47. else:
  48. self.gap = max(0, int(identify_data['gap']) * 10)
  49. sz, pos = identify_data['canvas'].split('+', 1)
  50. self.canvas_width, self.canvas_height = map(positive_int, sz.split('x', 1))
  51. self.canvas_x, self.canvas_y = map(int, pos.split('+', 1))
  52. self.width, self.height = map(positive_int, identify_data['size'].split('x', 1))
  53. self.xdpi, self.ydpi = map(positive_float, identify_data['dpi'].split('x', 1))
  54. self.index = positive_int(identify_data['index'])
  55. q = identify_data['transparency'].lower()
  56. self.mode = 'rgba' if q in ('blend', 'true') else 'rgb'
  57. self.needs_blend = q == 'blend'
  58. self.dispose = getattr(Dispose, identify_data['dispose'].lower())
  59. self.dimensions_swapped = identify_data.get('orientation') in ('5', '6', '7', '8')
  60. if self.dimensions_swapped:
  61. self.canvas_width, self.canvas_height = self.canvas_height, self.canvas_width
  62. self.width, self.height = self.height, self.width
  63. def __repr__(self) -> str:
  64. canvas = f'{self.canvas_width}x{self.canvas_height}:{self.canvas_x}+{self.canvas_y}'
  65. geom = f'{self.width}x{self.height}'
  66. return f'Frame(index={self.index}, gap={self.gap}, geom={geom}, canvas={canvas}, dispose={self.dispose.name})'
  67. class ImageData:
  68. def __init__(self, fmt: str, width: int, height: int, mode: str, frames: List[Frame]):
  69. self.width, self.height, self.fmt, self.mode = width, height, fmt, mode
  70. self.transmit_fmt: GRT_f = (24 if self.mode == 'rgb' else 32)
  71. self.frames = frames
  72. def __len__(self) -> int:
  73. return len(self.frames)
  74. def __iter__(self) -> Iterator[Frame]:
  75. yield from self.frames
  76. def __repr__(self) -> str:
  77. frames = '\n '.join(map(repr, self.frames))
  78. return f'Image(fmt={self.fmt}, mode={self.mode},\n {frames}\n)'
  79. class OpenFailed(ValueError):
  80. def __init__(self, path: str, message: str):
  81. ValueError.__init__(
  82. self, f'Failed to open image: {path} with error: {message}'
  83. )
  84. self.path = path
  85. class ConvertFailed(ValueError):
  86. def __init__(self, path: str, message: str):
  87. ValueError.__init__(
  88. self, f'Failed to convert image: {path} with error: {message}'
  89. )
  90. self.path = path
  91. class NoImageMagick(Exception):
  92. pass
  93. class OutdatedImageMagick(ValueError):
  94. def __init__(self, detailed_error: str):
  95. super().__init__('ImageMagick on this system is too old ImageMagick 7+ required which was first released in 2016')
  96. self.detailed_error = detailed_error
  97. last_imagemagick_cmd: Sequence[str] = ()
  98. def run_imagemagick(path: str, cmd: Sequence[str], keep_stdout: bool = True) -> 'CompletedProcess[bytes]':
  99. global last_imagemagick_cmd
  100. import subprocess
  101. last_imagemagick_cmd = cmd
  102. try:
  103. p = subprocess.run(cmd, stdout=subprocess.PIPE if keep_stdout else subprocess.DEVNULL, stderr=subprocess.PIPE)
  104. except FileNotFoundError:
  105. raise NoImageMagick('ImageMagick is required to process images')
  106. if p.returncode != 0:
  107. raise OpenFailed(path, p.stderr.decode('utf-8'))
  108. return p
  109. def identify(path: str) -> ImageData:
  110. import json
  111. q = (
  112. '{"fmt":"%m","canvas":"%g","transparency":"%A","gap":"%T","index":"%p","size":"%wx%h",'
  113. '"dpi":"%xx%y","dispose":"%D","orientation":"%[EXIF:Orientation]"},'
  114. )
  115. exe = which('magick')
  116. if exe:
  117. cmd = [exe, 'identify']
  118. else:
  119. cmd = ['identify']
  120. p = run_imagemagick(path, cmd + ['-format', q, '--', path])
  121. raw = p.stdout.rstrip(b',')
  122. data = json.loads(b'[' + raw + b']')
  123. first = data[0]
  124. frames = list(map(Frame, data))
  125. image_fmt = first['fmt'].lower()
  126. if image_fmt == 'gif' and not any(f.gap > 0 for f in frames):
  127. # Some broken GIF images have all zero gaps, browsers with their usual
  128. # idiot ideas render these with a default 100ms gap https://bugzilla.mozilla.org/show_bug.cgi?id=125137
  129. # Browsers actually force a 100ms gap at any zero gap frame, but that
  130. # just means it is impossible to deliberately use zero gap frames for
  131. # sophisticated blending, so we dont do that.
  132. for f in frames:
  133. f.gap = 100
  134. mode = 'rgb'
  135. for f in frames:
  136. if f.mode == 'rgba':
  137. mode = 'rgba'
  138. break
  139. return ImageData(image_fmt, frames[0].canvas_width, frames[0].canvas_height, mode, frames)
  140. class RenderedImage(ImageData):
  141. def __init__(self, fmt: str, width: int, height: int, mode: str):
  142. super().__init__(fmt, width, height, mode, [])
  143. def render_image(
  144. path: str, output_prefix: str,
  145. m: ImageData,
  146. available_width: int, available_height: int,
  147. scale_up: bool,
  148. only_first_frame: bool = False,
  149. remove_alpha: str = '',
  150. flip: bool = False, flop: bool = False,
  151. ) -> RenderedImage:
  152. import tempfile
  153. has_multiple_frames = len(m) > 1
  154. get_multiple_frames = has_multiple_frames and not only_first_frame
  155. exe = which('magick')
  156. if exe:
  157. cmd = [exe, 'convert']
  158. else:
  159. exe = which('convert')
  160. if exe is None:
  161. raise OSError('Failed to find the ImageMagick convert executable, make sure it is present in PATH')
  162. cmd = [exe]
  163. if remove_alpha:
  164. cmd += ['-background', remove_alpha, '-alpha', 'remove']
  165. else:
  166. cmd += ['-background', 'none']
  167. if flip:
  168. cmd.append('-flip')
  169. if flop:
  170. cmd.append('-flop')
  171. cmd += ['--', path]
  172. if only_first_frame and has_multiple_frames:
  173. cmd[-1] += '[0]'
  174. cmd.append('-auto-orient')
  175. scaled = False
  176. width, height = m.width, m.height
  177. if scale_up:
  178. if width < available_width:
  179. r = available_width / width
  180. width, height = available_width, int(height * r)
  181. scaled = True
  182. if scaled or width > available_width or height > available_height:
  183. width, height = fit_image(width, height, available_width, available_height)
  184. resize_cmd = ['-resize', f'{width}x{height}!']
  185. if get_multiple_frames:
  186. # we have to coalesce, resize and de-coalesce all frames
  187. resize_cmd = ['-coalesce'] + resize_cmd + ['-deconstruct']
  188. cmd += resize_cmd
  189. cmd += ['-depth', '8', '-set', 'filename:f', '%w-%h-%g-%p']
  190. ans = RenderedImage(m.fmt, width, height, m.mode)
  191. if only_first_frame:
  192. ans.frames = [Frame(m.frames[0])]
  193. else:
  194. ans.frames = list(map(Frame, m.frames))
  195. bytes_per_pixel = 3 if m.mode == 'rgb' else 4
  196. def check_resize(frame: Frame) -> None:
  197. # ImageMagick sometimes generates RGBA images smaller than the specified
  198. # size. See https://github.com/kovidgoyal/kitty/issues/276 for examples
  199. sz = os.path.getsize(frame.path)
  200. expected_size = bytes_per_pixel * frame.width * frame.height
  201. if sz < expected_size:
  202. missing = expected_size - sz
  203. if missing % (bytes_per_pixel * width) != 0:
  204. raise ConvertFailed(
  205. path, 'ImageMagick failed to convert {} correctly,'
  206. ' it generated {} < {} of data (w={}, h={}, bpp={})'.format(
  207. path, sz, expected_size, frame.width, frame.height, bytes_per_pixel))
  208. frame.height -= missing // (bytes_per_pixel * frame.width)
  209. if frame.index == 0:
  210. ans.height = frame.height
  211. ans.width = frame.width
  212. with tempfile.TemporaryDirectory(dir=os.path.dirname(output_prefix)) as tdir:
  213. output_template = os.path.join(tdir, f'im-%[filename:f].{m.mode}')
  214. if get_multiple_frames:
  215. cmd.append('+adjoin')
  216. run_imagemagick(path, cmd + [output_template])
  217. unseen = {x.index for x in m}
  218. for x in os.listdir(tdir):
  219. try:
  220. parts = x.split('.', 1)[0].split('-')
  221. index = int(parts[-1])
  222. unseen.discard(index)
  223. f = ans.frames[index]
  224. f.width, f.height = map(positive_int, parts[1:3])
  225. sz, pos = parts[3].split('+', 1)
  226. f.canvas_width, f.canvas_height = map(positive_int, sz.split('x', 1))
  227. f.canvas_x, f.canvas_y = map(int, pos.split('+', 1))
  228. except Exception:
  229. raise OutdatedImageMagick(f'Unexpected output filename: {x!r} produced by ImageMagick command: {last_imagemagick_cmd}')
  230. f.path = output_prefix + f'-{index}.{m.mode}'
  231. os.rename(os.path.join(tdir, x), f.path)
  232. check_resize(f)
  233. f = ans.frames[0]
  234. if f.width != ans.width or f.height != ans.height:
  235. with open(f.path, 'r+b') as ff:
  236. data = ff.read()
  237. ff.seek(0)
  238. ff.truncate()
  239. cd = create_canvas(data, f.width, f.canvas_x, f.canvas_y, ans.width, ans.height, 3 if ans.mode == 'rgb' else 4)
  240. ff.write(cd)
  241. if get_multiple_frames:
  242. if unseen:
  243. raise ConvertFailed(path, f'Failed to render {len(unseen)} out of {len(m)} frames of animation')
  244. elif not ans.frames[0].path:
  245. raise ConvertFailed(path, 'Failed to render image')
  246. return ans
  247. def render_as_single_image(
  248. path: str, m: ImageData,
  249. available_width: int, available_height: int,
  250. scale_up: bool,
  251. tdir: Optional[str] = None,
  252. remove_alpha: str = '', flip: bool = False, flop: bool = False,
  253. ) -> Tuple[str, int, int]:
  254. import tempfile
  255. fd, output = tempfile.mkstemp(prefix='tty-graphics-protocol-', suffix=f'.{m.mode}', dir=tdir)
  256. os.close(fd)
  257. result = render_image(
  258. path, output, m, available_width, available_height, scale_up,
  259. only_first_frame=True, remove_alpha=remove_alpha, flip=flip, flop=flop)
  260. os.rename(result.frames[0].path, output)
  261. return output, result.width, result.height
  262. def can_display_images() -> bool:
  263. ans: Optional[bool] = getattr(can_display_images, 'ans', None)
  264. if ans is None:
  265. ans = which('convert') is not None
  266. setattr(can_display_images, 'ans', ans)
  267. return ans
  268. ImageKey = Tuple[str, int, int]
  269. SentImageKey = Tuple[int, int, int]
  270. T = TypeVar('T')
  271. class Alias(Generic[T]):
  272. currently_processing: ClassVar[str] = ''
  273. def __init__(self, defval: T) -> None:
  274. self.name = ''
  275. self.defval = defval
  276. def __get__(self, instance: Optional['GraphicsCommand'], cls: Optional[Type['GraphicsCommand']] = None) -> T:
  277. if instance is None:
  278. return self.defval
  279. return cast(T, instance._actual_values.get(self.name, self.defval))
  280. def __set__(self, instance: 'GraphicsCommand', val: T) -> None:
  281. if val == self.defval:
  282. instance._actual_values.pop(self.name, None)
  283. else:
  284. instance._actual_values[self.name] = val
  285. def __set_name__(self, owner: Type['GraphicsCommand'], name: str) -> None:
  286. if len(name) == 1:
  287. Alias.currently_processing = name
  288. self.name = Alias.currently_processing
  289. class GraphicsCommand:
  290. a = action = Alias(cast(GRT_a, 't'))
  291. q = quiet = Alias(0)
  292. f = format = Alias(32)
  293. t = transmission_type = Alias(cast(GRT_t, 'd'))
  294. s = data_width = animation_state = Alias(0)
  295. v = data_height = loop_count = Alias(0)
  296. S = data_size = Alias(0)
  297. O = data_offset = Alias(0) # noqa
  298. i = image_id = Alias(0)
  299. I = image_number = Alias(0) # noqa
  300. p = placement_id = Alias(0)
  301. o = compression = Alias(cast(Optional[GRT_o], None))
  302. m = more = Alias(cast(GRT_m, 0))
  303. x = left_edge = Alias(0)
  304. y = top_edge = Alias(0)
  305. w = width = Alias(0)
  306. h = height = Alias(0)
  307. X = cell_x_offset = blend_mode = Alias(0)
  308. Y = cell_y_offset = bgcolor = Alias(0)
  309. c = columns = other_frame_number = dest_frame = Alias(0)
  310. r = rows = frame_number = source_frame = Alias(0)
  311. z = z_index = gap = Alias(0)
  312. C = cursor_movement = compose_mode = Alias(cast(GRT_C, 0))
  313. d = delete_action = Alias(cast(GRT_d, 'a'))
  314. def __init__(self) -> None:
  315. self._actual_values: Dict[str, Any] = {}
  316. def __repr__(self) -> str:
  317. return self.serialize().decode('ascii').replace('\033', '^]')
  318. def clone(self) -> 'GraphicsCommand':
  319. ans = GraphicsCommand()
  320. ans._actual_values = self._actual_values.copy()
  321. return ans
  322. def serialize(self, payload: Union[bytes, str] = b'') -> bytes:
  323. items = []
  324. for k, val in self._actual_values.items():
  325. items.append(f'{k}={val}')
  326. ans: List[bytes] = []
  327. w = ans.append
  328. w(b'\033_G')
  329. w(','.join(items).encode('ascii'))
  330. if payload:
  331. w(b';')
  332. if isinstance(payload, str):
  333. payload = standard_b64encode(payload.encode('utf-8'))
  334. w(payload)
  335. w(b'\033\\')
  336. return b''.join(ans)
  337. def clear(self) -> None:
  338. self._actual_values = {}
  339. def iter_transmission_chunks(self, data: Optional[bytes] = None, level: int = -1, compression_threshold: int = 1024) -> Iterator[bytes]:
  340. if data is None:
  341. yield self.serialize()
  342. return
  343. gc = self.clone()
  344. gc.S = len(data)
  345. if level and len(data) >= compression_threshold:
  346. import zlib
  347. compressed = zlib.compress(data, level)
  348. if len(compressed) < len(data):
  349. gc.o = 'z'
  350. data = compressed
  351. gc.S = len(data)
  352. data = standard_b64encode(data)
  353. while data:
  354. chunk, data = data[:4096], data[4096:]
  355. gc.m = 1 if data else 0
  356. yield gc.serialize(chunk)
  357. gc.clear()
  358. class Placement:
  359. cmd: GraphicsCommand
  360. x: int = 0
  361. y: int = 0
  362. def __init__(self, cmd: GraphicsCommand, x: int = 0, y: int = 0):
  363. self.cmd = cmd
  364. self.x = x
  365. self.y = y
  366. class ImageManager:
  367. def __init__(self, handler: HandlerType):
  368. self.image_id_counter = count()
  369. self.handler = handler
  370. self.filesystem_ok: Optional[bool] = None
  371. self.image_data: Dict[str, ImageData] = {}
  372. self.failed_images: Dict[str, Exception] = {}
  373. self.converted_images: Dict[ImageKey, ImageKey] = {}
  374. self.sent_images: Dict[ImageKey, int] = {}
  375. self.image_id_to_image_data: Dict[int, ImageData] = {}
  376. self.image_id_to_converted_data: Dict[int, ImageKey] = {}
  377. self.transmission_status: Dict[int, Union[str, int]] = {}
  378. self.placements_in_flight: DefaultDict[int, Deque[Placement]] = defaultdict(deque)
  379. self.update_image_placement_for_resend: Optional[Callable[[int, Placement], bool]]
  380. @property
  381. def next_image_id(self) -> int:
  382. return next(self.image_id_counter) + 2
  383. @property
  384. def screen_size(self) -> ScreenSize:
  385. return self.handler.screen_size
  386. def __enter__(self) -> None:
  387. import tempfile
  388. self.tdir = tempfile.mkdtemp(prefix='kitten-images-')
  389. with tempfile.NamedTemporaryFile(dir=self.tdir, delete=False) as f:
  390. f.write(b'abcd')
  391. gc = GraphicsCommand()
  392. gc.a = 'q'
  393. gc.s = gc.v = gc.i = 1
  394. gc.t = 'f'
  395. self.handler.cmd.gr_command(gc, standard_b64encode(f.name.encode(fsenc)))
  396. def __exit__(self, *a: Any) -> None:
  397. import shutil
  398. shutil.rmtree(self.tdir, ignore_errors=True)
  399. self.handler.cmd.clear_images_on_screen(delete_data=True)
  400. self.delete_all_sent_images()
  401. del self.handler
  402. def delete_all_sent_images(self) -> None:
  403. gc = GraphicsCommand()
  404. gc.a = 'd'
  405. for img_id in self.transmission_status:
  406. gc.i = img_id
  407. self.handler.cmd.gr_command(gc)
  408. self.transmission_status.clear()
  409. def handle_response(self, apc: str) -> None:
  410. cdata, payload = apc[1:].partition(';')[::2]
  411. control = {}
  412. for x in cdata.split(','):
  413. k, v = x.partition('=')[::2]
  414. control[k] = v
  415. try:
  416. image_id = int(control.get('i', '0'))
  417. except Exception:
  418. image_id = 0
  419. if image_id == 1:
  420. self.filesystem_ok = payload == 'OK'
  421. return
  422. if not image_id:
  423. return
  424. if not self.transmission_status.get(image_id):
  425. self.transmission_status[image_id] = payload
  426. else:
  427. in_flight = self.placements_in_flight[image_id]
  428. if in_flight:
  429. pl = in_flight.popleft()
  430. if payload.startswith('ENOENT:'):
  431. with suppress(Exception):
  432. self.resend_image(image_id, pl)
  433. if not in_flight:
  434. self.placements_in_flight.pop(image_id, None)
  435. def resend_image(self, image_id: int, pl: Placement) -> None:
  436. if self.update_image_placement_for_resend is not None and not self.update_image_placement_for_resend(image_id, pl):
  437. return
  438. image_data = self.image_id_to_image_data[image_id]
  439. skey = self.image_id_to_converted_data[image_id]
  440. self.transmit_image(image_data, image_id, *skey)
  441. with cursor(self.handler.write):
  442. self.handler.cmd.set_cursor_position(pl.x, pl.y)
  443. self.handler.cmd.gr_command(pl.cmd)
  444. def send_image(self, path: str, max_cols: Optional[int] = None, max_rows: Optional[int] = None, scale_up: bool = False) -> SentImageKey:
  445. path = os.path.abspath(path)
  446. if path in self.failed_images:
  447. raise self.failed_images[path]
  448. if path not in self.image_data:
  449. try:
  450. self.image_data[path] = identify(path)
  451. except Exception as e:
  452. self.failed_images[path] = e
  453. raise
  454. m = self.image_data[path]
  455. ss = self.screen_size
  456. if max_cols is None:
  457. max_cols = ss.cols
  458. if max_rows is None:
  459. max_rows = ss.rows
  460. available_width = max_cols * ss.cell_width
  461. available_height = max_rows * ss.cell_height
  462. key = path, available_width, available_height
  463. skey = self.converted_images.get(key)
  464. if skey is None:
  465. try:
  466. self.converted_images[key] = skey = self.convert_image(path, available_width, available_height, m, scale_up)
  467. except Exception as e:
  468. self.failed_images[path] = e
  469. raise
  470. final_width, final_height = skey[1:]
  471. if final_width == 0:
  472. return 0, 0, 0
  473. image_id = self.sent_images.get(skey)
  474. if image_id is None:
  475. image_id = self.next_image_id
  476. self.transmit_image(m, image_id, *skey)
  477. self.sent_images[skey] = image_id
  478. self.image_id_to_converted_data[image_id] = skey
  479. self.image_id_to_image_data[image_id] = m
  480. return image_id, skey[1], skey[2]
  481. def hide_image(self, image_id: int) -> None:
  482. gc = GraphicsCommand()
  483. gc.a = 'd'
  484. gc.i = image_id
  485. self.handler.cmd.gr_command(gc)
  486. def show_image(self, image_id: int, x: int, y: int, src_rect: Optional[Tuple[int, int, int, int]] = None) -> None:
  487. gc = GraphicsCommand()
  488. gc.a = 'p'
  489. gc.i = image_id
  490. if src_rect is not None:
  491. gc.x, gc.y, gc.w, gc.h = map(int, src_rect)
  492. self.placements_in_flight[image_id].append(Placement(gc, x, y))
  493. with cursor(self.handler.write):
  494. self.handler.cmd.set_cursor_position(x, y)
  495. self.handler.cmd.gr_command(gc)
  496. def convert_image(self, path: str, available_width: int, available_height: int, image_data: ImageData, scale_up: bool = False) -> ImageKey:
  497. rgba_path, width, height = render_as_single_image(path, image_data, available_width, available_height, scale_up, tdir=self.tdir)
  498. return rgba_path, width, height
  499. def transmit_image(self, image_data: ImageData, image_id: int, rgba_path: str, width: int, height: int) -> int:
  500. self.transmission_status[image_id] = 0
  501. gc = GraphicsCommand()
  502. gc.a = 't'
  503. gc.f = image_data.transmit_fmt
  504. gc.s = width
  505. gc.v = height
  506. gc.i = image_id
  507. if self.filesystem_ok:
  508. gc.t = 'f'
  509. self.handler.cmd.gr_command(
  510. gc, standard_b64encode(rgba_path.encode(fsenc)))
  511. else:
  512. import zlib
  513. with open(rgba_path, 'rb') as f:
  514. data = f.read()
  515. gc.S = len(data)
  516. data = zlib.compress(data)
  517. gc.o = 'z'
  518. data = standard_b64encode(data)
  519. while data:
  520. chunk, data = data[:4096], data[4096:]
  521. gc.m = 1 if data else 0
  522. self.handler.cmd.gr_command(gc, chunk)
  523. gc.clear()
  524. return image_id