convert2ascii 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 37a56a08059d638a7c1e406248484e8fb3ca0fd1d494c8676ac5cc1119e15eeb
4
+ data.tar.gz: 3cbf2abb2d13a8a1d1091128852584d35d073ac8b1d7a056dc856916d913582d
5
+ SHA512:
6
+ metadata.gz: e1dcecc188abc4c1a5916199f91fba45fa1ee70d6fe25427a27b502e5c41fa57fe3c3dedf4cd0a7f3402ac36f132e44f87874ada185d0dc374d78eb29fd9e389
7
+ data.tar.gz: aedcc98597e9ef05af5c26435384cb72741b2e97cca92df0e1e2b44e9314f0f2a465f5a070503db29a5bc16bb0cc97933ab4ab7a9b9d21f44179f9df923bc6f9
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Mark24
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # Convert2ASCII
2
+
3
+ Convert Image/Video to ASCII art.
4
+
5
+
6
+ ## Test pass
7
+
8
+ * MacOS 15.2 ✅
9
+ * Ubuntu 24.04 ✅
10
+ * Windows 11 ❌
11
+
12
+
13
+ ## Example
14
+
15
+ ![example](./example/wukong.jpg)
16
+
17
+ ![neo](./example/neo.gif)
18
+
19
+ ![example2](./example/doulai.jpg)
20
+
21
+ ## Prerequisites
22
+
23
+ * Ruby3+
24
+ * ImageMagick ([Download here](https://imagemagick.org/script/download.php))
25
+ * ffmpeg ([Download here](https://www.ffmpeg.org/))
26
+
27
+
28
+ ## Executable command
29
+
30
+ ### image2ascii
31
+
32
+ Make image to ascii art in your terminal.
33
+
34
+ ```bash
35
+ image2ascii -h
36
+ Usage: image2ascii [options]
37
+ --version verison
38
+ -i, --image=URI image uri (required)
39
+ -w, --width=WIDTH image width (integer)
40
+ -s, --style=STYLE ascii style: ['color'| 'text']
41
+ -b, --block ascii color style use BLOCK or not [ true | false ]
42
+ ```
43
+
44
+ ### video2ascii
45
+
46
+ Make image to ascii art in your terminal.
47
+
48
+ ```bash
49
+ Usage: video2ascii [options]
50
+
51
+ * default will generate and play without save.
52
+ * -p will just play ascii frames dir, and ignore -i, -o others options. --loop will play loop
53
+ * -i,-o will just generate and output frames and ignore others options
54
+ --version verison
55
+ -i, --input=URI video uri (required)
56
+ -w, --width=WIDTH video width (integer)
57
+ -s, --style=STYLE ascii style: ['color'| 'text']
58
+ -b, --block ascii color style use BLOCK or not [ true | false ]
59
+ -o, --ouput=OUTPUT save ascii frame to output dirname
60
+ -p, --play_dir=PLAY_DIRNAME input ascii frames dirname to play
61
+ --loop
62
+ ```
63
+
64
+
65
+ ## As a Gem
66
+
67
+ ### Convert2Ascii::Image2Ascii
68
+
69
+
70
+ ```ruby
71
+ require 'convert2ascii/image2ascii'
72
+
73
+ # generate image
74
+ uri = "path/to/image"
75
+ ascii = Convert2Ascii::Image2Ascii.new(uri:, width: 50)
76
+
77
+ # generate image
78
+ ascii.generate
79
+ # display in your terminal
80
+ ascii.tty_print
81
+
82
+
83
+ # also chain call
84
+ ascii.generate.tty_print
85
+
86
+ ```
87
+
88
+
89
+ ### Convert2Ascii::Video2Ascii
90
+
91
+ ```ruby
92
+ require 'convert2ascii/video2ascii'
93
+
94
+ # generate video
95
+ uri = "path/to/video.mp4"
96
+ ascii = Convert2Ascii::Video2Ascii.new(uri:, width: 50)
97
+ # generate video
98
+ ascii.generate
99
+ # save frames
100
+ ascii.save(output_path)
101
+
102
+ # play in terminal
103
+ ascii.play
104
+
105
+
106
+ # chain call
107
+ ascii.generate.play
108
+
109
+ ```
110
+
111
+ ## Inspired by
112
+
113
+ * [michaelkofron/image2ascii](https://github.com/michaelkofron/image2ascii)
114
+ * [andrewcohen/video_to_ascii](https://github.com/andrewcohen/video_to_ascii)
data/Rakefile ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+ require "bundler/gem_tasks"
3
+
4
+ desc "Run Test"
5
+ task :test do
6
+ tests = Dir.glob("./test/test_*.rb")
7
+ tests.each do |t|
8
+ system("ruby #{t}")
9
+ end
10
+ end
11
+
12
+ desc "Build Rdoc"
13
+ task :build_rdoc do
14
+ system("rdoc build")
15
+ end
16
+
17
+
18
+
19
+ task default: %i[]
data/TODO.md ADDED
@@ -0,0 +1,8 @@
1
+ # TODO
2
+
3
+ * [x] video loop play
4
+ * [x] audio play use ffplay
5
+ * [x] add audio loop play
6
+ * [ ] add play control: pause, next, prev, exit
7
+ * [ ] memory play is too limit
8
+ * [ ] windows support
data/exe/image2ascii ADDED
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env ruby
2
+ require 'optparse'
3
+ require_relative "../lib/convert2ascii/image2ascii"
4
+
5
+
6
+ options = {}
7
+ OptionParser.new do |parser|
8
+ parser.banner = "Usage: image2ascii [options]"
9
+
10
+ parser.on("--version", "verison") do |v|
11
+ puts "convert2ascii/image2ascii: v#{::Convert2Ascii::VERSION}"
12
+ puts "author: Mark24"
13
+ puts "mail: mark.zhangyoung@gmail.com"
14
+ puts "project: https://github.com/mark24Code/convert2ascii"
15
+ return
16
+ end
17
+
18
+ parser.on("-iURI", "--image=URI", "image uri (required)") do |uri|
19
+ options[:uri] = uri
20
+
21
+ # check options
22
+ unless options[:uri]
23
+ puts "Error: --image option is required."
24
+ exit 1
25
+ end
26
+ end
27
+
28
+ parser.on("-wWIDTH", "--width=WIDTH", Integer ,"image width (integer)") do |width|
29
+ options[:width] = width
30
+ end
31
+
32
+ parser.on("-sSTYLE", "--style=STYLE", "ascii style: ['color'| 'text']") do |style|
33
+ options[:style] = style
34
+
35
+ styles = ["color", "text"]
36
+ # check options
37
+ unless styles.include? options[:style]
38
+ puts "Error: --style option must be [\"color\" | \"text\"]."
39
+ exit 1
40
+ end
41
+ end
42
+
43
+ parser.on("-b", "--block", "ascii color style use BLOCK or not [ true | false ] ") do |color_block|
44
+ options[:color_block] = color_block || false
45
+ end
46
+ end.parse!
47
+
48
+ Convert2Ascii::Image2Ascii.new(**options).generate.tty_print
data/exe/video2ascii ADDED
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env ruby
2
+ require 'optparse'
3
+ require_relative "../lib/convert2ascii/video2ascii"
4
+ require_relative "../lib/convert2ascii/terminal-player"
5
+
6
+
7
+ def get_name_order(name_path)
8
+ if name_path
9
+ return File.basename(name_path, ".*").to_i
10
+ end
11
+ end
12
+
13
+ def play_frames(dist_dir, play_loop: false)
14
+
15
+ config = JSON.parse(File.read(File.join(dist_dir, "meta.json")))
16
+ step_duration = config["step_duration"]
17
+ audio_name = config["audio"]
18
+ audio = audio_name ? File.join(dist_dir, audio_name) : nil
19
+
20
+ frames_path = Dir.glob("#{dist_dir}/*.txt")
21
+ frames_path = frames_path.sort do |a, b|
22
+ get_name_order(a) <=> get_name_order(b)
23
+ end
24
+
25
+ # TODO
26
+ # 载入内存中可能会遇到过大的问题
27
+ frames = frames_path.map { |f| File.open(f).read }
28
+
29
+ payload = {
30
+ frames: frames,
31
+ audio: audio,
32
+ play_loop: play_loop,
33
+ step_duration: step_duration
34
+ }
35
+ Convert2Ascii::TerminalPlayer.new(**payload).play
36
+ end
37
+
38
+
39
+ options = {}
40
+ OptionParser.new do |parser|
41
+ parser.banner = <<DOC
42
+ Usage: video2ascii [options]
43
+
44
+ * default will generate and play without save.
45
+ * -p will just play ascii frames dir, and ignore -i, -o others options. --loop will play loop
46
+ * -i,-o will just generate and output frames and ignore others options
47
+ DOC
48
+ parser.on("--version", "verison") do |v|
49
+ puts "convert2ascii/video2ascii: v#{::Convert2Ascii::VERSION}"
50
+ puts "author: Mark24"
51
+ puts "mail: mark.zhangyoung@gmail.com"
52
+ puts "project: https://github.com/mark24Code/convert2ascii"
53
+ return
54
+ end
55
+
56
+ parser.on("-iURI", "--input=URI", "video uri (required)") do |uri|
57
+ options[:uri] = uri
58
+
59
+ # check options
60
+ unless options[:uri]
61
+ puts "Error: --video option is required."
62
+ exit 1
63
+ end
64
+ end
65
+
66
+ parser.on("-wWIDTH", "--width=WIDTH", Integer ,"video width (integer)") do |width|
67
+ options[:width] = width
68
+ end
69
+
70
+ parser.on("-sSTYLE", "--style=STYLE", "ascii style: ['color'| 'text']") do |style|
71
+ options[:style] = style
72
+
73
+ styles = ["color", "text"]
74
+ # check options
75
+ unless styles.include? options[:style]
76
+ puts "Error: --style option must be [\"color\" | \"text\"]."
77
+ exit 1
78
+ end
79
+ end
80
+
81
+ parser.on("-b", "--block", "ascii color style use BLOCK or not [ true | false ] ") do |color_block|
82
+ options[:color_block] = color_block || false
83
+ end
84
+
85
+ parser.on("-o", "--ouput=OUTPUT", "save ascii frame to output dirname") do |output|
86
+ options[:output] = output
87
+ end
88
+
89
+ parser.on("-p", "--play_dir=PLAY_DIRNAME", "input ascii frames dirname to play") do |play_dir|
90
+ options[:play_dir] = play_dir
91
+ end
92
+
93
+ parser.on("--loop", "play loop") do |play_loop|
94
+ options[:play_loop] = play_loop
95
+ end
96
+ end.parse!
97
+
98
+ if options[:play_dir]
99
+ # play frames
100
+ # TODO
101
+ # 导出文件必须包含更多信息 step_duration
102
+ play_loop = false
103
+
104
+ if options[:play_loop]
105
+ play_loop = options[:play_loop]
106
+ end
107
+ play_frames(options[:play_dir], play_loop: play_loop)
108
+ exit 0
109
+ end
110
+
111
+ payload = {
112
+ uri: options[:uri],
113
+ width: options[:width],
114
+ style: options[:style],
115
+ color_block: options[:color_block],
116
+ }
117
+
118
+ if options[:uri] and options[:output]
119
+ output = options[:output]
120
+
121
+ Convert2Ascii::Video2Ascii.new(**payload).generate.save(output)
122
+
123
+ exit 0
124
+ end
125
+
126
+
127
+ Convert2Ascii::Video2Ascii.new(**payload).generate.play
@@ -0,0 +1,167 @@
1
+ require 'rainbow'
2
+
3
+ module Convert2Ascii
4
+ module OS
5
+
6
+ module OS_ENUM
7
+ MacOS = :macos
8
+ Linux = :linux
9
+ Windows = :ms
10
+ Unknow = :unknow
11
+ end
12
+
13
+ def detect_os
14
+ if RUBY_PLATFORM =~ /linux/
15
+ return OS_ENUM::Linux
16
+ elsif RUBY_PLATFORM =~ /darwin/
17
+ return OS_ENUM::MacOS
18
+ elsif RUBY_PLATFORM =~ /mswin|mingw|cygwin/
19
+ return OS_ENUM::Windows
20
+ else
21
+ return OS_ENUM::Unknow
22
+ end
23
+ end
24
+ end
25
+
26
+ class CheckPackageError < StandardError
27
+ end
28
+
29
+ class CheckPackage
30
+ include OS
31
+
32
+ def initialize
33
+ @os_name = nil
34
+ end
35
+
36
+ def macos_check
37
+ end
38
+
39
+ def ms_check
40
+ end
41
+
42
+ def linux_check
43
+ end
44
+
45
+ def unknow_check
46
+ raise CheckPackageError, Rainbow("[Error] #{@os_name} not support!").red
47
+ end
48
+
49
+
50
+ def macos_installed?(package_name)
51
+ output = `brew list #{package_name} 2>/dev/null`
52
+ return output != ""
53
+ end
54
+
55
+ def detect_linux_distribution
56
+
57
+ if !File.exist?('/etc/os-release')
58
+ raise CheckPackageError, Rainbow("[Error] can not detect_os! #{@os_name} ").red
59
+ end
60
+
61
+ os_release = File.read('/etc/os-release')
62
+
63
+ if os_release.include?('debian')
64
+ # debian/ubuntu
65
+ return'debian'
66
+ end
67
+
68
+ if os_release.include?('centos')
69
+ # centos
70
+ return'centos'
71
+ end
72
+
73
+ if os_release.include?('redhat')
74
+ # redhat
75
+ return'redhat'
76
+ end
77
+
78
+ if os_release.include?('arch')
79
+ # arch linux
80
+ return'arch'
81
+ end
82
+
83
+ raise CheckPackageError, Rainbow("[Error] #{@os_name} linux platform not support!").red
84
+ end
85
+
86
+
87
+ def debian_installed?(package_name)
88
+ output = `dpkg -l | grep #{package_name}`
89
+ !output.strip.empty?
90
+ end
91
+
92
+ def centos_installed?(package_name)
93
+ output = `rpm -qa | grep #{package_name}`
94
+ !output.strip.empty?
95
+ end
96
+
97
+ def redhat_installed?(package_name)
98
+ output = `yum list installed | grep #{package_name}`
99
+ !output.strip.empty?
100
+ end
101
+
102
+ def arch_installed?(package_name)
103
+ output = `pacman -Q | grep #{package_name}`
104
+ !output.strip.empty?
105
+ end
106
+
107
+ def check
108
+ @os_name = detect_os
109
+ __send__ "#{@os_name}_check"
110
+ end
111
+ end
112
+
113
+ class CheckFFmpeg < CheckPackage
114
+ def initialize
115
+ super
116
+ @name = "ffmpeg"
117
+ @need_error = Rainbow("[Error] `#{@name}` is need!").red
118
+ @tips = Rainbow("[Tips ] For more details and install: https://www.ffmpeg.org/").green
119
+ end
120
+
121
+ def macos_check
122
+ if !macos_installed?(@name)
123
+ raise CheckPackageError, "\n#{@need_error}\n#{@tips}\n"
124
+ end
125
+ end
126
+
127
+ def linux_check
128
+ linux_platform_name = detect_linux_distribution
129
+ __send__ "#{linux_platform_name}_installed?", @name
130
+ end
131
+
132
+ end
133
+
134
+ class CheckImageMagick < CheckPackage
135
+ def initialize
136
+ super
137
+ @name = "imagemagick"
138
+ @need_error = Rainbow("[Error] `imagemagick` is need!").red
139
+ @tips = Rainbow("[Tips ] For more details and install guide: https://github.com/rmagick/rmagick").green
140
+
141
+ end
142
+
143
+ def macos_check
144
+ if !macos_installed?(@name)
145
+ raise CheckPackageError, "\n#{@need_error}\n#{@tips}\n"
146
+ end
147
+ end
148
+
149
+ def linux_check
150
+ # just Debian/Ubuntu
151
+ linux_pkg = {
152
+ "debian" => "libmagickwand-dev",
153
+ "redhat" => "ImageMagick-devel",
154
+ "arch" => "imagemagick",
155
+ }
156
+
157
+ linux_platform_name = detect_linux_distribution
158
+ pkg_name = linux_pkg.fetch(linux_platform_name, nil)
159
+
160
+ if !pkg_name
161
+ raise CheckPackageError, "\n#{@need_error}\n#{@tips}\n"
162
+ end
163
+
164
+ __send__ "#{linux_platform_name}_installed?", pkg_name
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,132 @@
1
+ require "io/console"
2
+ require "rmagick"
3
+ require "rainbow"
4
+ require "open-uri"
5
+ require_relative "./check_package"
6
+ require_relative './version'
7
+
8
+ module Convert2Ascii
9
+ class Image2AsciiError < StandardError
10
+ end
11
+
12
+ class Image2Ascii
13
+ module STYLE_ENUM
14
+ Color = "color"
15
+ Text = "text"
16
+ end
17
+
18
+ module COLOR_ENUM
19
+ Full = "full"
20
+ Greyscale = "greyscale"
21
+ end
22
+
23
+
24
+ attr_reader :width, :ascii_string
25
+ attr_accessor :chars
26
+
27
+ def initialize(**args)
28
+ @uri = args[:uri]
29
+ @width = args[:width] || IO.console.winsize[1]
30
+ @style = args[:style] || STYLE_ENUM::Color # "color": color ansi , "text": plain text
31
+ @color = args[:color] || COLOR_ENUM::Full # full
32
+ @color_block = args[:color_block] || false
33
+
34
+ check_quantum_convert_factor
35
+ # divides quantum depth color space into usable rgb values
36
+ @quantum_convert_factor = Magick::MAGICKCORE_QUANTUM_DEPTH == 16 ? 257 : 1
37
+
38
+ @chars = ".'`^\",:;Il!i><~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$"
39
+ @ascii_string = ""
40
+ end
41
+
42
+ def check_quantum_convert_factor
43
+ # quantum conversion factor for dealing with quantum depth color values
44
+ if Magick::MAGICKCORE_QUANTUM_DEPTH > 16
45
+ raise Image2AsciiError, "[Error] ImageMagick quantum depth is set to #{Magick::MAGICKCORE_QUANTUM_DEPTH}. It needs to be 16 or less"
46
+ end
47
+ end
48
+
49
+ def generate(**args)
50
+ @width = args[:width] || @width
51
+ @style = args[:style] || @style # "color": color ansi , "text": plain text
52
+ @color = args[:color] || @color # full
53
+ @color_block = args[:color_block] || @color_block
54
+
55
+ generate_string
56
+
57
+ self
58
+ end
59
+
60
+ def tty_print
61
+ print @ascii_string
62
+ end
63
+
64
+ private
65
+
66
+ def check_packages
67
+ CheckImageMagick.new.check
68
+ end
69
+
70
+ def generate_string
71
+ resource = URI.open(@uri)
72
+ img = Magick::ImageList.new
73
+ img.from_blob(resource.read)
74
+
75
+ img = correct_aspect_ratio(img, @width)
76
+
77
+ img.each_pixel do |pixel, col, row|
78
+ r, g, b, brightness = get_pixel_values(pixel)
79
+ char = select_character(brightness)
80
+
81
+ if @style == STYLE_ENUM::Text
82
+ @ascii_string << char
83
+ end
84
+
85
+ if @style == STYLE_ENUM::Color
86
+ chosen_color = get_chosen_color(@color, r, g, b)
87
+
88
+ if @color_block == true
89
+ @ascii_string << Rainbow(" ").background(*chosen_color)
90
+ else
91
+ @ascii_string << Rainbow(char).color(*chosen_color)
92
+ end
93
+ end
94
+
95
+ # add line wrap once desired width is reached
96
+ if (col % (@width - 1) == 0) and (col != 0)
97
+ @ascii_string << "\n"
98
+ end
99
+ end
100
+ end
101
+
102
+ def correct_aspect_ratio(img, width)
103
+ img = img.scale(width / img.columns.to_f)
104
+ img = img.scale(img.columns, img.rows / 2)
105
+ end
106
+
107
+ def get_pixel_values(pixel)
108
+ r = pixel.red / @quantum_convert_factor
109
+ g = pixel.green / @quantum_convert_factor
110
+ b = pixel.blue / @quantum_convert_factor
111
+ # Brightness ref: https://en.wikipedia.org/wiki/Relative_luminance
112
+ brightness = (0.2126 * r + 0.7152 * g + 0.0722 * b)
113
+
114
+ return [r, g, b, brightness]
115
+ end
116
+
117
+ def select_character(brightness)
118
+ char_index = brightness / (255.0 / @chars.length)
119
+ @chars[char_index.floor]
120
+ end
121
+
122
+ def get_chosen_color(color, r, g, b)
123
+ if color == COLOR_ENUM::Full
124
+ [r, g, b]
125
+ elsif color == COLOR_ENUM::Greyscale
126
+ [r, r, r]
127
+ else
128
+ color
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,36 @@
1
+ require "async"
2
+ require "async/barrier"
3
+ require "async/semaphore"
4
+ require "etc"
5
+
6
+ module Convert2Ascii
7
+ class MultiTasker
8
+ def initialize(proc_tasks)
9
+ @proc_tasks = proc_tasks
10
+ @count = set_threads_count
11
+ end
12
+
13
+ def set_threads_count
14
+ cpu_threads = (Etc.nprocessors || 1)
15
+ cpu_threads = cpu_threads > 4 ? cpu_threads - 1 : cpu_threads
16
+ cpu_threads
17
+ end
18
+
19
+ def run
20
+ barrier = Async::Barrier.new
21
+
22
+ Sync do
23
+ # Only 10 tasks are created at a time:
24
+ semaphore = Async::Semaphore.new(@count, parent: barrier)
25
+
26
+ @proc_tasks.map do |proc_task|
27
+ semaphore.async do
28
+ proc_task.call
29
+ end
30
+ end.map(&:wait)
31
+ ensure
32
+ barrier.stop
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,184 @@
1
+ require "rainbow"
2
+ require_relative "./terminal"
3
+
4
+ module Convert2Ascii
5
+ class TerminalPlayer
6
+
7
+ SAFE_SLOW_DELTA = 0.9 # seconds
8
+ SAFE_FAST_DELTA = 0.2 # seconds
9
+
10
+ attr_accessor :play_loop, :step_duration, :debug
11
+ def initialize(**args)
12
+ @debug = false
13
+ @audio = args[:audio]
14
+ @frames = args[:frames]
15
+ @play_loop = args[:play_loop]
16
+ @step_duration = args[:step_duration]
17
+
18
+ @total_duration = @frames.length * @step_duration
19
+ @first_frame = true
20
+ @backspace_adjust = "\033[A" * (@frames.length + 1)
21
+
22
+ regist_hook
23
+ end
24
+
25
+ def play
26
+ begin
27
+ init_screen
28
+ render
29
+ rescue => error
30
+ raise error
31
+ ensure
32
+ clean_up
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def regist_hook
39
+ trap("INT") {
40
+ clean_up
41
+ exit 0
42
+ }
43
+
44
+ # TODO ??? 改变会消失
45
+ # # 窗口变化事件
46
+ # trap("SIGWINCH") {
47
+ # resize
48
+ # }
49
+ end
50
+
51
+ def resize
52
+ clear_screen
53
+ end
54
+
55
+ def clear_screen
56
+ Terminal.clear_buffer
57
+ end
58
+
59
+ def init_screen
60
+ clear_screen
61
+ setup
62
+ end
63
+
64
+ def setup
65
+ Terminal.open_buffer
66
+ Terminal.hide_cursor
67
+ Terminal.clear_screen
68
+ end
69
+
70
+ def clean_up
71
+ if !@debug
72
+ Terminal.close_buffer
73
+ Terminal.clear_screen
74
+ Terminal.show_cursor
75
+ end
76
+ end
77
+
78
+ def debug_log(var_name)
79
+ puts Rainbow('-- debug ----').yellow
80
+ puts "class:"
81
+ p var_name.class
82
+ puts "value:"
83
+ p var_name
84
+ end
85
+
86
+ def full_screen(content)
87
+
88
+ if !content
89
+ return content
90
+ end
91
+ # 补齐高度,没内容也要追加 \n 刷新,因为会拖拽 窗体尺寸会变化
92
+ rows, _ = Terminal.winsize
93
+ # content: pure text with "\n"
94
+ content = content.split("\n")
95
+
96
+ rows_fill = []
97
+ delta = rows - content.length
98
+
99
+ if delta > 0
100
+ delta.times do
101
+ rows_fill << "\n"
102
+ end
103
+ elsif delta < 0
104
+ content = content[0..(rows - 1)]
105
+ end
106
+
107
+ content.join("\n")
108
+ end
109
+
110
+ def self_adaption_frame_play
111
+ # 当偏移在-90ms(音频滞后于视频)到+20ms(音频超前视频)之间时,人感觉不到视听质量的变化,这个区域可以认为是同步区域
112
+ # https://github.com/0voice/audio_video_streaming/blob/main/article/029-%E9%9F%B3%E8%A7%86%E9%A2%91%E5%90%8C%E6%AD%A5%E7%AE%97%E6%B3%95.md
113
+
114
+ start_time = Time.now
115
+ if @audio
116
+ Thread.new do
117
+ start_time = Time.now # 以音频为准
118
+ play_cmd = "ffplay -nodisp "
119
+ if @play_loop
120
+ play_cmd << " -loop 0"
121
+ end
122
+ system("#{play_cmd} -i #{@audio} &> /dev/null")
123
+ if @debug
124
+ puts Rainbow("[info] audio time: #{Time.now - start_time} s").green
125
+ end
126
+ end
127
+ end
128
+ frame_index = 0
129
+ loop do
130
+ if frame_index <= @frames.length
131
+ content = @frames[frame_index]
132
+ if !@first_frame
133
+ $stdout.print @backspace_adjust
134
+ end
135
+ clear_screen
136
+ $stdout.print(full_screen(content))
137
+ @first_frame = false
138
+ sleep(@step_duration)
139
+ end
140
+
141
+ actual_time = Time.now - start_time
142
+ video_play_time = frame_index * @step_duration
143
+
144
+ offset_time = actual_time - video_play_time
145
+ if offset_time > SAFE_SLOW_DELTA
146
+ offset = (offset_time / @step_duration).floor
147
+ if @debug
148
+ puts Rainbow("[+] #{offset}").green
149
+ end
150
+ elsif offset_time < -1 * SAFE_FAST_DELTA
151
+ offset = 0
152
+ else
153
+ offset = 1
154
+ end
155
+
156
+ frame_index = (frame_index + offset) % @frames.length
157
+
158
+ real_time = Time.now - start_time
159
+ frame_time = (frame_index + 1) * @step_duration
160
+
161
+ if !@play_loop
162
+ if real_time > @total_duration
163
+ break
164
+ end
165
+ end
166
+
167
+ if @debug
168
+ puts ""
169
+ puts Rainbow("RealTime: #{real_time} s").green
170
+ puts Rainbow("FrameTime: #{frame_time} s").green
171
+ puts Rainbow("CurrentFrame: #{frame_index} / #{@frames.length} ").green
172
+ puts Rainbow("Real - Frame: #{real_time - frame_time} s").green
173
+ puts Rainbow("[+] #{offset}").green
174
+ puts Rainbow("@step_duration #{@step_duration}").green
175
+ puts Rainbow("@play_loop #{@play_loop}").green
176
+ end
177
+ end
178
+ end
179
+
180
+ def render
181
+ self_adaption_frame_play
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,40 @@
1
+ require "io/console"
2
+
3
+ # ANSI_escape_code
4
+ # Ref: https://xn--rpa.cc/irl/term.html
5
+ # https://en.wikipedia.org/wiki/ANSI_escape_code#CSIsection
6
+ module Convert2Ascii
7
+ class Terminal
8
+ class << self
9
+ def winsize
10
+ IO.console.winsize # [rows, columns]
11
+ end
12
+
13
+ def clear_buffer
14
+ print "\x1b[3J"
15
+ end
16
+
17
+ def clear_screen
18
+ print "\x1b[2J"
19
+ end
20
+
21
+ def hide_cursor
22
+ print "\x1b[?25l"
23
+ end
24
+
25
+ def show_cursor
26
+ print "\x1b[?25h"
27
+ end
28
+
29
+ def open_buffer
30
+ # 打开特殊缓存
31
+ print "\x1b[?1049h"
32
+ end
33
+
34
+ def close_buffer
35
+ # 关闭特殊缓存
36
+ print "\x1b[?1049l"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,3 @@
1
+ module Convert2Ascii
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,199 @@
1
+ require "open-uri"
2
+ require "json"
3
+ require "tmpdir"
4
+ require "etc"
5
+ require "io/console"
6
+ require "rainbow"
7
+ require_relative "./image2ascii"
8
+ require_relative "./terminal-player"
9
+ require_relative "./multi-tasker"
10
+ require_relative "./check_package"
11
+ require_relative './version'
12
+
13
+ module Convert2Ascii
14
+ class Video2AsciiError < StandardError
15
+ end
16
+
17
+ class Video2Ascii
18
+ DEFAULT_STEP_DURATION = 0.04
19
+
20
+
21
+ attr_accessor :uri, :width, :threads_count, :output, :step_duration
22
+ def initialize(**args)
23
+ @uri = args[:uri]
24
+ @step_duration = args[:step_duration] || DEFAULT_STEP_DURATION
25
+ @threads_count = set_threads_count
26
+
27
+ @tmpdir = nil
28
+ @output = Dir.pwd
29
+
30
+ # image2ascii attrs
31
+ @width = args[:width] || IO.console.winsize[1]
32
+ @style = args[:style] || Image2Ascii::STYLE_ENUM::Color # "color": color ansi , "text": plain text
33
+ @color = args[:color] || Image2Ascii::COLOR_ENUM::Full # full
34
+ @color_block = args[:color_block] || false
35
+
36
+ check_packages
37
+ regist_hooks
38
+ end
39
+
40
+ def generate(**args)
41
+ @width = args[:width] || @width
42
+ @style = args[:style] || @style # "color": color ansi , "text": plain text
43
+ @color = args[:color] || @color # full
44
+ @color_block = args[:color_block] || @color_block
45
+
46
+ @tmpdir = Dir.mktmpdir
47
+ @audio = get_audio_from_video(@tmpdir)
48
+ screenshots_from_video(@tmpdir)
49
+ convert_all_images(@tmpdir)
50
+ @frames_path = order_frames_path
51
+
52
+ self
53
+ end
54
+
55
+
56
+ def save(output_dir)
57
+ system("rm -rf #{@tmpdir}/*.jpg")
58
+ system("rm -rf #{output_dir} && mkdir #{output_dir}")
59
+ system("cp -r #{@tmpdir}/* #{output_dir}")
60
+
61
+ # save config
62
+ File.open("#{output_dir}/meta.json", "w") do |f|
63
+ config = {
64
+ step_duration: @step_duration,
65
+ audio: @audio ? File.basename(@audio) : nil,
66
+ frames_count: @frames_path.length
67
+ }
68
+ json_data = JSON.generate(config, pretty: true)
69
+ f.puts json_data
70
+ end
71
+
72
+ puts ""
73
+ puts Rainbow("[info] save success!").green
74
+ end
75
+
76
+ def order_frames_path
77
+ frames_path = Dir.glob("#{@tmpdir}/*.txt")
78
+ frames_path = frames_path.sort do |a, b|
79
+ get_name_order(a) <=> get_name_order(b)
80
+ end
81
+
82
+ @frames_path = frames_path
83
+ end
84
+
85
+ def play(**args)
86
+ play_loop = args[:play_loop] || false
87
+ step_duration = args[:step_duration] || @step_duration
88
+ frames = @frames_path.map { |f| File.open(f).read }
89
+
90
+ player_args = {
91
+ frames: ,
92
+ audio: @audio,
93
+ play_loop:,
94
+ step_duration:,
95
+ }
96
+ TerminalPlayer.new(**player_args).play
97
+
98
+ return true
99
+ end
100
+
101
+ private
102
+
103
+ def check_packages
104
+ CheckImageMagick.new.check
105
+ CheckFFmpeg.new.check
106
+ end
107
+
108
+ def regist_hooks
109
+ at_exit {
110
+ if @tmpdir
111
+ FileUtils.remove_entry @tmpdir
112
+ end
113
+ }
114
+ end
115
+
116
+ def set_threads_count
117
+ cpu_threads = (Etc.nprocessors || 1)
118
+ cpu_threads = cpu_threads > 4 ? cpu_threads - 1 : cpu_threads
119
+ cpu_threads
120
+ end
121
+
122
+ def get_audio_from_video(save_dir)
123
+ puts Rainbow("[info] parsing audio...").green
124
+ audio_save = "#{save_dir}/audio.mp3"
125
+ cmd = "ffmpeg -i '#{@uri}' -vn -nostats -loglevel 0 #{audio_save}"
126
+ result = system(cmd)
127
+ if !result
128
+ audio_save = nil
129
+ end
130
+ puts Rainbow("[info] parsing audio done.").green
131
+
132
+ return audio_save
133
+ end
134
+
135
+ def screenshots_from_video(save_dir)
136
+ # ffmpeg use multi threads
137
+ # Benchmark
138
+ # Apple M1 Max:
139
+ # * thread:1 2.64s
140
+ # * thread:9 0.96s
141
+ puts Rainbow("[info] video slicing...").green
142
+ image_path = save_dir
143
+ cmd = "ffmpeg -i '#{@uri}' -threads #{@threads_count} -vf fps=1/#{@step_duration} -nostats -loglevel 0 #{image_path}/%d.jpg"
144
+ result = system(cmd)
145
+
146
+ if !result
147
+ raise Video2AsciiError, Rainbow("\n[Error] exec `#{cmd}` fail!").red
148
+ end
149
+ puts Rainbow("[info] video slicing done.").green
150
+
151
+ return image_path
152
+ end
153
+
154
+ def convert_to_ascii(image)
155
+ config = {
156
+ width: @width,
157
+ style: @style,
158
+ color: @color,
159
+ color_block: @color_block
160
+ }
161
+ Image2Ascii.new(uri: image).generate(**config).ascii_string
162
+ end
163
+
164
+ def get_name_order(name_path)
165
+ if name_path
166
+ return File.basename(name_path, ".*").to_i
167
+ end
168
+ end
169
+
170
+ def convert_all_images(save_dir)
171
+ images = Dir.glob("#{save_dir}/*.jpg")
172
+
173
+ processed_count = 0
174
+ tasks = []
175
+ time_start = Time.now
176
+ images.each_with_index do |image, i|
177
+ tasks << lambda {
178
+ begin
179
+ ascii_string = convert_to_ascii(image)
180
+ basename = File.basename(image, ".jpg")
181
+ File.open("#{@tmpdir}/#{basename}.txt", "w") do |f|
182
+ f.puts ascii_string
183
+ end
184
+
185
+ processed_count += 1
186
+ print(Rainbow("\rprocessing... #{sprintf("%.2f", (1.0 * processed_count / images.length) * 100)} % (time: #{sprintf("%.2f", Time.now - time_start)} s)").green)
187
+ rescue => error
188
+ puts "-------"
189
+ puts Rainbow("\n[Error] convert_to_ascii -> image: #{image} | i: #{i}").red
190
+ puts error
191
+ end
192
+ }
193
+ end
194
+
195
+ MultiTasker.new(tasks).run
196
+ end
197
+
198
+ end
199
+ end
@@ -0,0 +1,3 @@
1
+ require_relative './convert2ascii/version.rb'
2
+ require_relative './convert2ascii/image2ascii.rb'
3
+ require_relative './convert2ascii/video2ascii.rb'
metadata ADDED
@@ -0,0 +1,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: convert2ascii
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Mark24
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2025-01-12 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rmagick
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 6.0.1
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 6.0.1
26
+ - !ruby/object:Gem::Dependency
27
+ name: rainbow
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: 3.1.1
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 3.1.1
40
+ - !ruby/object:Gem::Dependency
41
+ name: async
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.21'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.21'
54
+ description: This gem can help you convert image or video became ASCII art in your
55
+ terminal.
56
+ email:
57
+ - mark.zhangyoung@gmail.com
58
+ executables:
59
+ - image2ascii
60
+ - video2ascii
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - LICENSE.txt
65
+ - README.md
66
+ - Rakefile
67
+ - TODO.md
68
+ - exe/image2ascii
69
+ - exe/video2ascii
70
+ - lib/convert2ascii.rb
71
+ - lib/convert2ascii/check_package.rb
72
+ - lib/convert2ascii/image2ascii.rb
73
+ - lib/convert2ascii/multi-tasker.rb
74
+ - lib/convert2ascii/terminal-player.rb
75
+ - lib/convert2ascii/terminal.rb
76
+ - lib/convert2ascii/version.rb
77
+ - lib/convert2ascii/video2ascii.rb
78
+ homepage: https://github.com/Mark24Code/convert2ascii
79
+ licenses:
80
+ - MIT
81
+ metadata:
82
+ homepage_uri: https://github.com/Mark24Code/convert2ascii
83
+ source_code_uri: https://github.com/Mark24Code/convert2ascii
84
+ changelog_uri: https://github.com/Mark24Code/convert2ascii
85
+ rdoc_options: []
86
+ require_paths:
87
+ - lib
88
+ required_ruby_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: 3.1.0
93
+ required_rubygems_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ requirements: []
99
+ rubygems_version: 3.6.2
100
+ specification_version: 4
101
+ summary: Convert image or video to ASCII art
102
+ test_files: []