ascii-art-python 2.0.2__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {ascii_art_python-2.0.2/src/ascii_art_python.egg-info → ascii_art_python-2.1.0}/PKG-INFO +1 -1
  2. {ascii_art_python-2.0.2 → ascii_art_python-2.1.0}/pyproject.toml +2 -1
  3. {ascii_art_python-2.0.2 → ascii_art_python-2.1.0}/src/ascii_art_python/__init__.py +1 -1
  4. {ascii_art_python-2.0.2 → ascii_art_python-2.1.0}/src/ascii_art_python/ascii_base.py +108 -99
  5. ascii_art_python-2.1.0/src/ascii_art_python/cli.py +182 -0
  6. {ascii_art_python-2.0.2 → ascii_art_python-2.1.0}/src/ascii_art_python/full_mode.py +18 -9
  7. {ascii_art_python-2.0.2 → ascii_art_python-2.1.0}/src/ascii_art_python/new_skool.py +13 -8
  8. {ascii_art_python-2.0.2 → ascii_art_python-2.1.0}/src/ascii_art_python/old_skool.py +15 -8
  9. {ascii_art_python-2.0.2 → ascii_art_python-2.1.0/src/ascii_art_python.egg-info}/PKG-INFO +1 -1
  10. {ascii_art_python-2.0.2 → ascii_art_python-2.1.0}/tests/test_ascii_api.py +15 -9
  11. ascii_art_python-2.0.2/src/ascii_art_python/cli.py +0 -107
  12. {ascii_art_python-2.0.2 → ascii_art_python-2.1.0}/LICENSE +0 -0
  13. {ascii_art_python-2.0.2 → ascii_art_python-2.1.0}/MANIFEST.in +0 -0
  14. {ascii_art_python-2.0.2 → ascii_art_python-2.1.0}/README.md +0 -0
  15. {ascii_art_python-2.0.2 → ascii_art_python-2.1.0}/setup.cfg +0 -0
  16. {ascii_art_python-2.0.2 → ascii_art_python-2.1.0}/src/ascii_art_python/assets/fonts/GoogleSansCode-Regular.ttf +0 -0
  17. {ascii_art_python-2.0.2 → ascii_art_python-2.1.0}/src/ascii_art_python/assets/fonts/KreativeSquareSM.ttf +0 -0
  18. {ascii_art_python-2.0.2 → ascii_art_python-2.1.0}/src/ascii_art_python/tools.py +0 -0
  19. {ascii_art_python-2.0.2 → ascii_art_python-2.1.0}/src/ascii_art_python.egg-info/SOURCES.txt +0 -0
  20. {ascii_art_python-2.0.2 → ascii_art_python-2.1.0}/src/ascii_art_python.egg-info/dependency_links.txt +0 -0
  21. {ascii_art_python-2.0.2 → ascii_art_python-2.1.0}/src/ascii_art_python.egg-info/entry_points.txt +0 -0
  22. {ascii_art_python-2.0.2 → ascii_art_python-2.1.0}/src/ascii_art_python.egg-info/requires.txt +0 -0
  23. {ascii_art_python-2.0.2 → ascii_art_python-2.1.0}/src/ascii_art_python.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ascii_art_python
3
- Version: 2.0.2
3
+ Version: 2.1.0
4
4
  Summary: A Python library and CLI tool for converting images and videos into ASCII art.
5
5
  Author-email: Guillem Prieur <prieurguillem38@gmail.com>
6
6
  Project-URL: Homepage, https://gitlab.pprriieeuurr.fr/guill_prieur/ascii-art-python-2
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ascii_art_python"
7
- version = "2.0.2"
7
+ version = "2.1.0"
8
8
  authors = [
9
9
  { name="Guillem Prieur", email="prieurguillem38@gmail.com" },
10
10
  ]
@@ -12,6 +12,7 @@ description = "A Python library and CLI tool for converting images and videos in
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.9"
14
14
 
15
+
15
16
  keywords = [
16
17
  "ascii",
17
18
  "ascii-art",
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Module from https://gitlab.pprriieeuurr.fr/guill_prieur/ascii-art-python-2
3
3
  """
4
- __version__ = "2.0.2"
4
+ __version__ = "2.1.0"
5
5
  from . import new_skool
6
6
  from . import old_skool
7
7
  from . import full_mode
@@ -10,7 +10,7 @@ import pathlib
10
10
  import sys
11
11
  import time
12
12
  from abc import ABC, abstractmethod
13
- from typing import Any, Callable, Union
13
+ from typing import Any, Callable, List, Optional, Tuple
14
14
 
15
15
  import cv2
16
16
  import numpy as np
@@ -51,8 +51,8 @@ class Image(ABC):
51
51
  def __init__(
52
52
  self,
53
53
  image: PIL.Image.Image,
54
- height: Union[int, None] = None,
55
- width: Union[int, None] = None,
54
+ height: Optional[int] = None,
55
+ width: Optional[int] = None,
56
56
  ) -> None:
57
57
  """
58
58
  Initializes the base Image instance.
@@ -61,10 +61,10 @@ class Image(ABC):
61
61
  -----------
62
62
  image : PIL.Image.Image
63
63
  The source image to be processed.
64
- height : Union[int, None]
64
+ height : Optional[int]
65
65
  The target height for the output image. If None, it scales proportionally
66
66
  or maintains the original height depending on the width parameter.
67
- width : Union[int, None]
67
+ width : Optional[int]
68
68
  The target width for the output image. If None, it scales proportionally
69
69
  or maintains the original width depending on the height parameter.
70
70
  """
@@ -113,7 +113,7 @@ class Image(ABC):
113
113
  raise ValueError("height must be a positive integer")
114
114
  self._height = value
115
115
 
116
- def get_backgrounds(self, bg_chars: Union[list[str], None] = None, better: bool = True) -> list[str]:
116
+ def get_backgrounds(self, bg_chars: Optional[List[str]] = None, better_contrast: bool = True) -> List[str]:
117
117
  """
118
118
  Calculates the background ASCII characters based on image luminosity.
119
119
 
@@ -122,13 +122,15 @@ class Image(ABC):
122
122
 
123
123
  Parameters
124
124
  -----------
125
- bg_chars : Union[list[str], None]
125
+ bg_chars : Optional[List[str]]
126
126
  A list of single characters ordered by visual density.
127
127
  If None, the default background characters are used.
128
+ better_contrast : bool
129
+ If True, the program will increase the contrast to improve the result.
128
130
 
129
131
  Returns
130
132
  --------
131
- list[str]
133
+ List[str]
132
134
  A list of strings, where each string represents a row of
133
135
  the background ASCII image.
134
136
  """
@@ -140,19 +142,22 @@ class Image(ABC):
140
142
  raise ValueError("bg_chars must be a list of strings")
141
143
  if not all(len(char) == 1 for char in bg_chars):
142
144
  raise ValueError("bg_chars must be a list of strings of length 1")
143
- if not isinstance(better, bool):
144
- raise TypeError("better must be a bool")
145
+ if not isinstance(better_contrast, bool):
146
+ raise TypeError("better_contrast must be a bool")
145
147
 
146
148
  image = self.__source.convert("L").resize((self.width, self.height))
147
- if better:
149
+ if better_contrast:
148
150
  image = PIL.ImageOps.autocontrast(image, 1)
149
151
 
150
152
  conversion_factor = len(bg_chars) / 256
151
153
  img_array = np.array(image)
152
154
  indices = (img_array * conversion_factor).astype(int)
153
- return ["".join(np.array(bg_chars)[row]) for row in indices]
155
+ np_bg_chars = np.array(bg_chars)
156
+ return ["".join(np_bg_chars[row]) for row in indices]
154
157
 
155
- def get_edges(self, edge_chars: Union[list[str], None] = None) -> list[str]:
158
+ def get_edges(
159
+ self, edge_chars: Optional[List[str]] = None, color_edges: bool = False
160
+ ) -> List[str]:
156
161
  """
157
162
  Detects image edges and maps them to directional ASCII characters.
158
163
 
@@ -161,20 +166,24 @@ class Image(ABC):
161
166
 
162
167
  Parameters
163
168
  -----------
164
- edge_chars : Union[list[str], None]
169
+ edge_chars : Optional[List[str]]
165
170
  A list of exactly 4 single characters representing vertical,
166
171
  diagonal-up, horizontal, and diagonal-down edges respectively.
167
172
  If None, default edge characters are used.
173
+ color_edges : bool
174
+ If True, performs edge detection on LAB color channels to catch
175
+ isoluminant edges (better quality but slower). If False, uses
176
+ grayscale edge detection (faster, recommended for videos).
168
177
 
169
178
  Returns
170
179
  --------
171
- list[str]
180
+ List[str]
172
181
  A list of strings, where each string represents a row of
173
182
  the foreground ASCII edges.
174
183
  """
175
184
  if edge_chars is None:
176
185
  edge_chars = self.DEFAULT_EDGE_CHARS
177
- if not (isinstance(edge_chars, list)):
186
+ if not isinstance(edge_chars, list):
178
187
  raise TypeError("edge_chars must be a list")
179
188
  if len(edge_chars) != 4:
180
189
  raise ValueError("edge_chars must be a list of 4 characters")
@@ -182,33 +191,48 @@ class Image(ABC):
182
191
  raise ValueError("edge_chars must be a list of strings")
183
192
  if not all(len(char) == 1 for char in edge_chars):
184
193
  raise ValueError("edge_chars must be a list of strings of length 1")
194
+ if not isinstance(color_edges, bool):
195
+ raise TypeError("color_edges must be a bool")
185
196
 
186
197
  img_pil = self.__source.convert("RGB").resize((self.width, self.height))
187
198
  img_rgb = np.array(img_pil)
188
- img_lab = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2LAB)
189
- l_chan, a_chan, b_chan = cv2.split(img_lab)
190
- edges_l = cv2.Canny(l_chan, 50, 150)
191
- edges_a = cv2.Canny(a_chan, 20, 80)
192
- edges_b = cv2.Canny(b_chan, 20, 80)
193
- edge_mask = (edges_l | edges_a | edges_b) == 255
194
- sobel_x = cv2.Sobel(l_chan, cv2.CV_64F, 1, 0)
195
- sobel_y = cv2.Sobel(l_chan, cv2.CV_64F, 0, 1)
199
+
200
+ if color_edges:
201
+ img_lab = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2LAB)
202
+ l_chan, a_chan, b_chan = cv2.split(img_lab)
203
+ edges_l = cv2.Canny(l_chan, 50, 150)
204
+ edges_a = cv2.Canny(a_chan, 20, 80)
205
+ edges_b = cv2.Canny(b_chan, 20, 80)
206
+ edge_mask = (edges_l | edges_a | edges_b) == 255
207
+ sobel_target = l_chan
208
+ else:
209
+ img_gray = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY)
210
+ edges_gray = cv2.Canny(img_gray, 50, 150)
211
+ edge_mask = edges_gray == 255
212
+ sobel_target = img_gray
213
+
214
+ sobel_x = cv2.Sobel(sobel_target, cv2.CV_64F, 1, 0)
215
+ sobel_y = cv2.Sobel(sobel_target, cv2.CV_64F, 0, 1)
196
216
  angles = np.degrees(np.arctan2(sobel_y, sobel_x)) % 180
217
+
197
218
  char_matrix = np.full((self.height, self.width), " ", dtype=object)
198
219
  char_matrix[edge_mask & ((angles >= 67.5) & (angles < 112.5))] = edge_chars[2]
199
220
  char_matrix[edge_mask & ((angles >= 22.5) & (angles < 67.5))] = edge_chars[1]
200
221
  char_matrix[edge_mask & ((angles >= 112.5) & (angles < 157.5))] = edge_chars[3]
201
222
  char_matrix[edge_mask & ((angles < 22.5) | (angles >= 157.5))] = edge_chars[0]
223
+
202
224
  return ["".join(row) for row in char_matrix]
203
225
 
204
226
  @abstractmethod
205
- def to_list(self) -> list[str]:
227
+ def to_list(self, **kwargs: Any) -> List[str]:
206
228
  """
207
- Abstract method to convert the image to a list of ASCII strings.
229
+ An abstract method for converting the image into a list of ASCII strings.
230
+
231
+ Accepts additional parameters via kwargs depending on the mode.
208
232
 
209
233
  Returns
210
234
  --------
211
- list[str]
235
+ List[str]
212
236
  A list of ASCII strings representing the fully processed image.
213
237
  """
214
238
 
@@ -260,7 +284,7 @@ class Image(ABC):
260
284
 
261
285
  @classmethod
262
286
  def from_path(
263
- cls, path: str, height: Union[int, None] = None, width: Union[int, None] = None
287
+ cls, path: str, height: Optional[int] = None, width: Optional[int] = None
264
288
  ) -> "Image":
265
289
  """
266
290
  Instantiates an Image object from a file path.
@@ -269,9 +293,9 @@ class Image(ABC):
269
293
  -----------
270
294
  path : str
271
295
  The system path to the image file.
272
- height : Union[int, None]
296
+ height : Optional[int]
273
297
  The desired output height.
274
- width : Union[int, None]
298
+ width : Optional[int]
275
299
  The desired output width.
276
300
 
277
301
  Returns
@@ -330,7 +354,7 @@ class Image(ABC):
330
354
 
331
355
  return cls(output_image, width=text_width, height=len(content_list) * font_size)
332
356
 
333
- def export(self, filename: str = "mika_export"):
357
+ def export(self, filename: str = "mika_export", font_size: int = 15):
334
358
  """
335
359
  Exports the rendered ASCII image to a PNG file.
336
360
 
@@ -344,7 +368,7 @@ class Image(ABC):
344
368
  raise TypeError("filename must be a string")
345
369
  if pathlib.Path.is_file(pathlib.Path(filename + ".png")):
346
370
  raise ValueError("file must not exist")
347
- self.to_image().save(f"{filename}.png")
371
+ self.to_image(font_size = font_size).save(f"{filename}.png")
348
372
 
349
373
 
350
374
  class Video:
@@ -385,30 +409,26 @@ class Video:
385
409
  if pathlib.Path.is_file(pathlib.Path(output_path)):
386
410
  raise ValueError("output_path must not exist")
387
411
 
388
- source_video = VideoFileClip(source_video_path)
389
- audio = source_video.audio
390
- target_video = VideoFileClip(target_video_path)
412
+ with VideoFileClip(source_video_path) as source_video, VideoFileClip(target_video_path) as target_video:
413
+ audio = source_video.audio
414
+ final_video = target_video.with_audio(audio)
391
415
 
392
- final_video = target_video.with_audio(audio)
393
-
394
- final_video.write_videofile(
395
- output_path,
396
- codec="libx264",
397
- audio_codec="aac",
398
- fps=target_video.fps,
399
- logger=None,
400
- )
416
+ final_video.write_videofile(
417
+ output_path,
418
+ codec="libx264",
419
+ audio_codec="aac",
420
+ fps=target_video.fps,
421
+ logger=None,
422
+ )
401
423
 
402
- source_video.close()
403
- target_video.close()
404
- final_video.close()
424
+ final_video.close()
405
425
 
406
426
  def __init__(
407
427
  self,
408
428
  path: str,
409
429
  cls_image: type["Image"],
410
- width: Union[int, None] = None,
411
- height: Union[int, None] = None,
430
+ width: Optional[int] = None,
431
+ height: Optional[int] = None,
412
432
  fps: int = 10,
413
433
  ) -> None:
414
434
  """
@@ -421,9 +441,9 @@ class Video:
421
441
  cls_image : type[Image]
422
442
  The specific Image class (e.g., full_mode.Image) used to process
423
443
  each frame of the video.
424
- width : Union[int, None]
444
+ width : Optional[int]
425
445
  The target width for the output frames.
426
- height : Union[int, None]
446
+ height : Optional[int]
427
447
  The target height for the output frames.
428
448
  fps : int
429
449
  The maximum frames per second for processing and exporting.
@@ -451,44 +471,30 @@ class Video:
451
471
  raise ValueError("Error: Unable to read video frames to determine size.")
452
472
 
453
473
  image_test = self.cls_image(
454
- PIL.Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
474
+ PIL.Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)),
475
+ width = width,
476
+ height = height
455
477
  )
456
- self._cached_original_size = (image_test.width * 15, image_test.height * 15)
478
+ self.width = image_test.width
479
+ self.height = image_test.height
457
480
 
458
- original_size = self._cached_original_size
459
- if height is None and width is None:
460
- self.width, self.height = original_size
461
- elif height is not None and width is None:
462
- self.height = height
463
- self.width = int(self.height * original_size[0] / original_size[1])
464
- elif height is None and width is not None:
465
- self.width = width
466
- self.height = int(self.width * original_size[1] / original_size[0])
467
- elif height is not None and width is not None:
468
- self.height = height
469
- self.width = width
470
-
471
- self.__cap = None
481
+ self._cap = None
472
482
  self.__frame_skip = max(1, int(self._cached_fps / self.max_fps))
473
483
  self.__frame_count = 0
474
484
 
475
485
  def __enter__(self):
476
- if self.__cap is not None:
477
- self.__cap.release()
478
- self.__cap = cv2.VideoCapture(self.path)
479
- if not self.__cap.isOpened():
486
+ if self._cap is not None:
487
+ self._cap.release()
488
+ self._cap = cv2.VideoCapture(self.path)
489
+ if not self._cap.isOpened():
480
490
  raise ValueError(f"Error: Unable to open the video at '{self.path}'")
481
491
  self.__frame_count = 0
482
492
  return self
483
493
 
484
494
  def __exit__(self, exc_type, exc_val, exc_tb):
485
- if self.__cap is not None:
486
- self.__cap.release()
487
- self.__cap = None
488
-
489
- @property
490
- def original_size(self) -> tuple[int, int]:
491
- return self._cached_original_size
495
+ if self._cap is not None:
496
+ self._cap.release()
497
+ self._cap = None
492
498
 
493
499
  @property
494
500
  def fps(self) -> float:
@@ -522,26 +528,26 @@ class Video:
522
528
  self._height = value
523
529
 
524
530
  def __iter__(self) -> "Video":
525
- if self.__cap is None or not self.__cap.isOpened():
526
- self.__cap = cv2.VideoCapture(self.path)
531
+ if self._cap is None or not self._cap.isOpened():
532
+ self._cap = cv2.VideoCapture(self.path)
527
533
  self.__frame_count = 0
528
534
  return self
529
535
 
530
536
  def __next__(self) -> Any:
531
- if self.__cap is None:
537
+ if self._cap is None:
532
538
  raise StopIteration
533
539
 
534
540
  while True:
535
- success, frame = self.__cap.read()
541
+ success, frame = self._cap.read()
536
542
 
537
543
  if not success:
538
- self.__cap.release()
539
- self.__cap = None
544
+ self._cap.release()
545
+ self._cap = None
540
546
  raise StopIteration
541
547
 
542
548
  if self.__frame_count % self.__frame_skip == 0:
543
549
  result_frame = self.cls_image(
544
- PIL.Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)),
550
+ PIL.Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)),
545
551
  width=self.width,
546
552
  height=self.height,
547
553
  )
@@ -551,10 +557,10 @@ class Video:
551
557
  self.__frame_count += 1
552
558
 
553
559
  def __del__(self) -> None:
554
- if hasattr(self, "_Video__cap") and self.__cap is not None:
555
- self.__cap.release()
560
+ if hasattr(self, "_cap") and self._cap is not None:
561
+ self._cap.release()
556
562
 
557
- def export(self, filename: str = "mika_export", sound: bool = False):
563
+ def export(self, filename: str = "mika_export", sound: bool = False, font_size: int = 12):
558
564
  """
559
565
  Exports the processed ASCII video to an mp4 file.
560
566
 
@@ -575,10 +581,14 @@ class Video:
575
581
  raise TypeError("sound must be a bool")
576
582
  if pathlib.Path.is_file(pathlib.Path(filename + ".mp4")):
577
583
  raise ValueError("filename must not exist")
584
+ if not isinstance(font_size, int):
585
+ raise TypeError("font_size must be an int")
586
+ if font_size <= 0:
587
+ raise ValueError("font_size must be a positive integer")
588
+
589
+ out_width = self.width * font_size
590
+ out_height = self.height * font_size
578
591
 
579
- out_width = self.width * 15
580
- out_height = self.height * 15
581
-
582
592
  if out_width % 2 != 0:
583
593
  out_width += 1
584
594
  if out_height % 2 != 0:
@@ -590,21 +600,20 @@ class Video:
590
600
  fourcc,
591
601
  self.fps / max(1, int(self.fps / self.max_fps)),
592
602
  (out_width, out_height),
593
- True,
603
+ False,
594
604
  )
595
605
 
596
606
  with self:
597
607
  for frame in tqdm(self, "Exporting frames", len(self)):
598
- pil_img = frame.to_image(font_size=15)
599
-
608
+ pil_img = frame.to_image(font_size = font_size)
609
+
600
610
  if pil_img.size != (out_width, out_height):
601
611
  pil_img = pil_img.resize((out_width, out_height))
602
-
612
+
603
613
  tab = np.array(pil_img)
604
- tab_bgr = cv2.cvtColor(tab, cv2.COLOR_GRAY2BGR)
605
-
606
- out.write(tab_bgr)
607
-
614
+
615
+ out.write(tab)
616
+
608
617
  out.release()
609
618
 
610
619
  if sound:
@@ -0,0 +1,182 @@
1
+ """
2
+ Command-line interface module for ASCII generation.
3
+
4
+ Provides click-based commands to export or print ASCII art
5
+ from image and video files.
6
+ """
7
+
8
+ import mimetypes
9
+ import shutil
10
+ from typing import Any, Optional, Tuple
11
+
12
+ import click
13
+
14
+ from . import full_mode, new_skool, old_skool
15
+ from .tools import clean_print
16
+
17
+ MODES = {
18
+ "full_mode": full_mode,
19
+ "old_skool": old_skool,
20
+ "new_skool": new_skool,
21
+ }
22
+
23
+
24
+ def _get_media_type_and_module(source_path: str, mode: str) -> Tuple[str, Any]:
25
+ """
26
+ Determines the media type of the source file and retrieves the corresponding module.
27
+
28
+ Parameters
29
+ -----------
30
+ source_path : str
31
+ The file path to the input media.
32
+ mode : str
33
+ The desired ASCII generation mode.
34
+
35
+ Returns
36
+ --------
37
+ Tuple[str, Any]
38
+ A tuple containing the media type ('image' or 'video') and the associated module.
39
+
40
+ Raises
41
+ -------
42
+ click.ClickException
43
+ If the media type cannot be determined or the mode is invalid.
44
+ """
45
+ media_type, _ = mimetypes.guess_type(source_path)
46
+
47
+ if media_type is None:
48
+ raise click.ClickException("Error: Unable to determine the file type (image or video).")
49
+
50
+ if media_type.startswith("image"):
51
+ base_type = "image"
52
+ elif media_type.startswith("video"):
53
+ base_type = "video"
54
+ else:
55
+ raise click.ClickException(f"Error: Unsupported media type '{media_type}'.")
56
+
57
+ if mode not in MODES:
58
+ raise click.ClickException(f"Error: Mode '{mode}' is not recognized.")
59
+
60
+ return base_type, MODES[mode]
61
+
62
+
63
+ @click.command()
64
+ @click.argument("source_path", type=click.Path(exists=True))
65
+ @click.argument("target_filename", type=click.STRING)
66
+ @click.option(
67
+ "--width",
68
+ "-w",
69
+ type=int,
70
+ default=None,
71
+ help="The number of characters in the width of the output."
72
+ )
73
+ @click.option(
74
+ "--height",
75
+ "-h",
76
+ type=int,
77
+ default=None,
78
+ help="The number of characters in the height of the output."
79
+ )
80
+ @click.option(
81
+ "--mode",
82
+ "-m",
83
+ type=click.Choice(list(MODES.keys())),
84
+ default="full_mode",
85
+ help="The ASCII generation mode to use."
86
+ )
87
+ @click.option(
88
+ "--fontsize",
89
+ "-f",
90
+ type=int,
91
+ default=12,
92
+ help="The size of each character in the exported video."
93
+ )
94
+ @click.option(
95
+ "--sound/--no-sound",
96
+ type=bool,
97
+ default=True,
98
+ help="If the file is a video, export it with or without audio."
99
+ )
100
+ def create_and_export(
101
+ source_path: str,
102
+ target_filename: str,
103
+ width: Optional[int],
104
+ height: Optional[int],
105
+ mode: str,
106
+ fontsize: int,
107
+ sound: bool,
108
+ ) -> None:
109
+ """
110
+ Creates and exports an ASCII art image or video.
111
+
112
+ Processes the input file located at SOURCE_PATH and exports the result
113
+ to TARGET_FILENAME with the specified settings.
114
+ """
115
+ click.echo(f"▶ Converting '{source_path}'...")
116
+ click.echo(f"▶ Settings: length=({width}, {height}), mode={mode}")
117
+
118
+ media_type, module = _get_media_type_and_module(source_path, mode)
119
+ click.echo(f"▶ Export type: {media_type}")
120
+
121
+ if media_type == "image":
122
+ image = module.Image.from_path(source_path, height=height, width=width)
123
+ image.export(target_filename, font_size=fontsize)
124
+ click.echo("✓ Conversion complete!")
125
+
126
+ elif media_type == "video":
127
+ video = module.Video(source_path, width=width, height=height)
128
+ click.echo(
129
+ f"Please note: You are about to export a video with the following settings: "
130
+ f"length=({video.width}, {video.height}) font_size={fontsize}"
131
+ )
132
+ click.confirm("Do you want to continue?", abort=True)
133
+ video.export(target_filename, sound=sound, font_size=fontsize)
134
+
135
+ click.echo("✓ Conversion complete!")
136
+ if sound:
137
+ click.echo(f"✓ Your file is available here: {target_filename}_audio.mp4")
138
+ else:
139
+ click.echo(f"✓ Your file is available here: {target_filename}.mp4")
140
+
141
+
142
+ @click.command()
143
+ @click.argument("source_path", type=click.Path(exists=True))
144
+ @click.option(
145
+ "--mode",
146
+ "-m",
147
+ type=click.Choice(list(MODES.keys())),
148
+ default="full_mode",
149
+ help="The ASCII generation mode to use."
150
+ )
151
+ def create_and_echo(source_path: str, mode: str) -> None:
152
+ """
153
+ Creates and prints an ASCII art image or video in the terminal.
154
+
155
+ Processes the input file located at SOURCE_PATH and outputs the result
156
+ directly to the standard output.
157
+ """
158
+ max_width, max_height = shutil.get_terminal_size()
159
+ max_height *= 2
160
+
161
+ click.echo(f"▶ Converting '{source_path}'...")
162
+ click.echo(f"▶ Settings: mode={mode}")
163
+
164
+ media_type, module = _get_media_type_and_module(source_path, mode)
165
+ click.echo(f"▶ Echo type: {media_type}")
166
+
167
+ if media_type == "image":
168
+ image = module.Image.from_path(source_path, height=max_height)
169
+ if image.width > max_width:
170
+ image = module.Image.from_path(source_path, width=max_width)
171
+
172
+ click.echo(image.get_alternate_lines())
173
+ click.echo("✓ Conversion complete!")
174
+
175
+ elif media_type == "video":
176
+ video = module.Video(source_path, height=max_height)
177
+ if video.width > max_width:
178
+ video = module.Video(source_path, width=max_width)
179
+
180
+ click.clear()
181
+ video.print_in_terminal(clean_print)
182
+ click.echo("✓ Conversion complete!")
@@ -5,7 +5,7 @@ This module provides classes to process images and videos in full mode,
5
5
  combining backgrounds and edges for detailed ASCII representation.
6
6
  """
7
7
 
8
- from typing import Union
8
+ from typing import Optional, Any, List
9
9
 
10
10
  from . import ascii_base
11
11
 
@@ -20,20 +20,29 @@ class Image(ascii_base.Image):
20
20
  combining background characters and foreground edges.
21
21
  """
22
22
 
23
- def to_list(self) -> list[str]:
23
+ def to_list(self, better_contrast: bool = True, color_edges: bool = False, **kwargs: Any) -> List[str]:
24
24
  """
25
25
  Converts the image to a list of ASCII strings.
26
26
 
27
27
  Combines the background characters and edge characters. If an edge
28
28
  character is empty (" "), it uses the corresponding background character.
29
29
 
30
+ Parameters
31
+ -----------
32
+ better_contrast : bool
33
+ If True, the program will increase the contrast to improve the result.
34
+ color_edges : bool
35
+ If True, performs edge detection on LAB color channels to catch
36
+ isoluminant edges (better quality but slower). If False, uses
37
+ grayscale edge detection (faster, recommended for videos).
38
+
30
39
  Returns
31
40
  --------
32
- list[str]
41
+ List[str]
33
42
  A list of strings where each string represents a row of the ASCII image.
34
43
  """
35
- backgrounds = self.get_backgrounds()
36
- edges = self.get_edges()
44
+ backgrounds = self.get_backgrounds(better_contrast = better_contrast)
45
+ edges = self.get_edges(color_edges = color_edges)
37
46
  result = [""] * len(edges)
38
47
  for i, e in enumerate(edges):
39
48
  for j, f in enumerate(e):
@@ -52,8 +61,8 @@ class Video(ascii_base.Video):
52
61
  def __init__(
53
62
  self,
54
63
  path: str,
55
- width: Union[int, None] = None,
56
- height: Union[int, None] = None,
64
+ width: Optional[int] = None,
65
+ height: Optional[int] = None,
57
66
  fps: int = 10,
58
67
  ) -> None:
59
68
  """
@@ -63,9 +72,9 @@ class Video(ascii_base.Video):
63
72
  -----------
64
73
  path : str
65
74
  The file path to the input video.
66
- width : Union[int, None]
75
+ width : Optional[int]
67
76
  The desired width of the output video. If None, it is calculated automatically.
68
- height : Union[int, None]
77
+ height : Optional[int]
69
78
  The desired height of the output video. If None, it is calculated automatically.
70
79
  fps : int
71
80
  The frames per second to process the video at (default is 10).
@@ -5,7 +5,7 @@ This module provides classes to process images and videos in new skool mode,
5
5
  relying entirely on background characters for the ASCII representation.
6
6
  """
7
7
 
8
- from typing import Union
8
+ from typing import Optional, Any, List
9
9
 
10
10
  from . import ascii_base
11
11
 
@@ -20,19 +20,24 @@ class Image(ascii_base.Image):
20
20
  using only the background characters.
21
21
  """
22
22
 
23
- def to_list(self) -> list[str]:
23
+ def to_list(self, better_contrast: bool = True, **kwargs: Any) -> List[str]:
24
24
  """
25
25
  Converts the image to a list of ASCII strings.
26
26
 
27
27
  Retrieves and returns only the background characters representing
28
28
  the image, ignoring edges.
29
29
 
30
+ Parameters
31
+ -----------
32
+ better_contrast : bool
33
+ If True, the program will increase the contrast to improve the result.
34
+
30
35
  Returns
31
36
  --------
32
- list[str]
37
+ List[str]
33
38
  A list of strings where each string represents a row of the ASCII image.
34
39
  """
35
- return self.get_backgrounds()
40
+ return self.get_backgrounds(better_contrast = better_contrast)
36
41
 
37
42
 
38
43
  class Video(ascii_base.Video):
@@ -46,8 +51,8 @@ class Video(ascii_base.Video):
46
51
  def __init__(
47
52
  self,
48
53
  path: str,
49
- width: Union[int, None] = None,
50
- height: Union[int, None] = None,
54
+ width: Optional[int] = None,
55
+ height: Optional[int] = None,
51
56
  fps: int = 10,
52
57
  ) -> None:
53
58
  """
@@ -57,9 +62,9 @@ class Video(ascii_base.Video):
57
62
  -----------
58
63
  path : str
59
64
  The file path to the input video.
60
- width : Union[int, None]
65
+ width : Optional[int]
61
66
  The desired width of the output video. If None, it is calculated automatically.
62
- height : Union[int, None]
67
+ height : Optional[int]
63
68
  The desired height of the output video. If None, it is calculated automatically.
64
69
  fps : int
65
70
  The frames per second to process the video at (default is 10).
@@ -5,7 +5,7 @@ This module provides classes to process images and videos in old skool mode,
5
5
  relying entirely on foreground edges for the ASCII representation.
6
6
  """
7
7
 
8
- from typing import Union
8
+ from typing import Optional, Any, List
9
9
 
10
10
  from . import ascii_base
11
11
 
@@ -20,19 +20,26 @@ class Image(ascii_base.Image):
20
20
  using only the edge characters.
21
21
  """
22
22
 
23
- def to_list(self) -> list[str]:
23
+ def to_list(self, color_edges: bool = False, **kwargs: Any) -> List[str]:
24
24
  """
25
25
  Converts the image to a list of ASCII strings.
26
26
 
27
27
  Retrieves and returns only the edge characters representing
28
28
  the image, ignoring backgrounds.
29
29
 
30
+ Parameters
31
+ -----------
32
+ color_edges : bool
33
+ If True, performs edge detection on LAB color channels to catch
34
+ isoluminant edges (better quality but slower). If False, uses
35
+ grayscale edge detection (faster, recommended for videos).
36
+
30
37
  Returns
31
38
  --------
32
- list[str]
39
+ List[str]
33
40
  A list of strings where each string represents a row of the ASCII image.
34
41
  """
35
- return self.get_edges()
42
+ return self.get_edges(color_edges = color_edges)
36
43
 
37
44
 
38
45
  class Video(ascii_base.Video):
@@ -46,8 +53,8 @@ class Video(ascii_base.Video):
46
53
  def __init__(
47
54
  self,
48
55
  path: str,
49
- width: Union[int, None] = None,
50
- height: Union[int, None] = None,
56
+ width: Optional[int] = None,
57
+ height: Optional[int] = None,
51
58
  fps: int = 10,
52
59
  ) -> None:
53
60
  """
@@ -57,9 +64,9 @@ class Video(ascii_base.Video):
57
64
  -----------
58
65
  path : str
59
66
  The file path to the input video.
60
- width : Union[int, None]
67
+ width : Optional[int]
61
68
  The desired width of the output video. If None, it is calculated automatically.
62
- height : Union[int, None]
69
+ height : Optional[int]
63
70
  The desired height of the output video. If None, it is calculated automatically.
64
71
  fps : int
65
72
  The frames per second to process the video at (default is 10).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ascii_art_python
3
- Version: 2.0.2
3
+ Version: 2.1.0
4
4
  Summary: A Python library and CLI tool for converting images and videos into ASCII art.
5
5
  Author-email: Guillem Prieur <prieurguillem38@gmail.com>
6
6
  Project-URL: Homepage, https://gitlab.pprriieeuurr.fr/guill_prieur/ascii-art-python-2
@@ -168,8 +168,7 @@ class TestVideoBase:
168
168
  assert vid.path == "fake_video.mp4"
169
169
  assert vid.max_fps == 12
170
170
  assert vid.fps == 24.0 # Mocked value returned by cv2.CAP_PROP_FPS
171
- # The hidden original size is (frame_width * 20, frame_height * 20)
172
- assert vid.original_size == (50 * 15, 50 * 15)
171
+ assert vid.height * vid.width == 2500
173
172
 
174
173
  @patch('pathlib.Path.is_file', return_value=False)
175
174
  def test_video_initialization_file_not_found(self, mock_is_file):
@@ -196,18 +195,25 @@ class TestVideoBase:
196
195
  @patch.object(ascii_base, 'VideoFileClip')
197
196
  def test_transfer_audio(self, mock_videofileclip, mock_is_file):
198
197
  """Tests the audio transfer logic (mocked)."""
199
- mock_is_file.side_effect = lambda path: path.name != "output.mp4" # output.mp4 should not exist
200
-
198
+ mock_is_file.side_effect = lambda path: path.name != "output.mp4"
199
+
201
200
  mock_source = MagicMock()
202
201
  mock_target = MagicMock()
203
202
  mock_final = MagicMock()
204
-
205
- # MoviePy mocks configuration
203
+
204
+ mock_source.__enter__.return_value = mock_source
205
+ mock_target.__enter__.return_value = mock_target
206
+
206
207
  mock_videofileclip.side_effect = [mock_source, mock_target]
207
208
  mock_target.with_audio.return_value = mock_final
208
-
209
+
209
210
  ascii_base.Video.transfer_audio("source.mp4", "target.mp4", "output.mp4")
210
-
211
+
211
212
  mock_target.with_audio.assert_called_once_with(mock_source.audio)
213
+
212
214
  mock_final.write_videofile.assert_called_once()
213
- mock_source.close.assert_called_once()
215
+
216
+ mock_source.__exit__.assert_called_once()
217
+ mock_target.__exit__.assert_called_once()
218
+
219
+ mock_final.close.assert_called_once()
@@ -1,107 +0,0 @@
1
- import mimetypes
2
- import shutil
3
- import sys
4
- import click
5
- from . import full_mode, old_skool, new_skool
6
-
7
- from .tools import clean_print
8
-
9
- MODES = {
10
- "full_mode": full_mode,
11
- "old_skool": old_skool,
12
- "new_skool": new_skool
13
- }
14
-
15
- @click.command()
16
- @click.argument("source_path", type=click.Path(exists=True))
17
- @click.argument("target_filename", type=click.STRING)
18
- @click.option("--width", "-w", type=int, default=None, help="The number of characters in the width of the output")
19
- @click.option("--height", "-h", type=int, default=None, help="The number of characters in the height of the output")
20
- @click.option("--mode", "-m", type=click.Choice(["full_mode", "old_skool", "new_skool"]), default="full_mode", help="full_mode / old_skool / new_skool")
21
- @click.option("--sound/--no-sound", type=bool, default=True, help="If the file is a video file, export it with or without audio.")
22
- def create_and_export(source_path, target_filename, width, height, mode, sound):
23
- """
24
- Creates and exports an ASCII art image or video.
25
- """
26
- click.echo(f"▶ Converting '{source_path}'...")
27
- click.echo(f"▶ Settings: length=({width}, {height}), mode={mode}")
28
-
29
- type_mime, _ = mimetypes.guess_type(source_path)
30
-
31
- if type_mime is None:
32
- click.secho("✗ Error: Unable to determine which export type to use")
33
- sys.exit(1)
34
- elif type_mime.startswith('image'):
35
- click.echo("▶ Export type: image")
36
- if mode not in MODES:
37
- click.secho(f"✗ Error: Mode '{mode}' is not recognized.")
38
- sys.exit(1)
39
-
40
- module = MODES[mode]
41
- image = module.Image.from_path(source_path, width, height)
42
- image.export(target_filename)
43
- click.echo("✓ Conversion complete!")
44
- elif type_mime.startswith('video'):
45
- click.echo("▶ Export type: video")
46
- if mode not in MODES:
47
- click.secho(f"✗ Error: Mode '{mode}' is not recognized.")
48
- sys.exit(1)
49
-
50
- module = MODES[mode]
51
- video = module.Video(source_path, width, height)
52
- click.echo(f"Please note: You are about to export a video with the following settings: length=({video.width}, {video.height})")
53
- click.confirm("Do you want to continue?", abort=True)
54
- video.export(target_filename, sound=sound)
55
- click.echo("✓ Conversion complete!")
56
- if sound:
57
- click.echo(f"✓ Your file is available here: {target_filename}_audio.mp4")
58
- else:
59
- click.echo(f"✓ Your file is available here: {target_filename}.mp4")
60
- else:
61
- click.secho("✗ Error: Unable to determine which export type to use")
62
- sys.exit(1)
63
-
64
-
65
- @click.command()
66
- @click.argument("source_path", type=click.Path(exists=True))
67
- @click.option("--mode", "-m", default="full_mode", help="full_mode / old_skool / new_skool")
68
- def create_and_echo(source_path, mode):
69
- """
70
- Creates and prints an ASCII art image or video.
71
- """
72
- max_width, max_height = shutil.get_terminal_size()
73
- max_height *= 2
74
- click.echo(f"▶ Converting '{source_path}'...")
75
- click.echo(f"▶ Settings: mode={mode}")
76
-
77
- type_mime, _ = mimetypes.guess_type(source_path)
78
-
79
- if type_mime is None:
80
- click.secho("✗ Error: Unable to determine which export type to use")
81
- sys.exit(1)
82
- elif type_mime.startswith('image'):
83
- click.echo("▶ Echo type: image")
84
- if mode not in MODES:
85
- click.secho(f"✗ Error: Mode '{mode}' is not recognized.")
86
- sys.exit(1)
87
-
88
- module = MODES[mode]
89
- image = module.Image.from_path(source_path, width, height)
90
- click.echo(image.get_alternate_lines())
91
- click.echo("✓ Conversion complete!")
92
- elif type_mime.startswith('video'):
93
- click.echo("▶ Echo type: video")
94
- if mode not in MODES:
95
- click.secho(f"✗ Error: Mode '{mode}' is not recognized.")
96
- sys.exit(1)
97
-
98
- module = MODES[mode]
99
- video = module.Video(source_path, height = max_height)
100
- if video.width > max_width:
101
- video = module.Video(source_path, width = max_width)
102
- click.clear()
103
- video.print_in_terminal(clean_print)
104
- click.echo("✓ Conversion complete!")
105
- else:
106
- click.secho("✗ Error: Unable to determine which export type to use")
107
- sys.exit(1)