asciinema-rails 0.1.1
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 +7 -0
- data/.gitignore +14 -0
- data/.rspec +2 -0
- data/Gemfile +9 -0
- data/LICENSE.txt +22 -0
- data/README.md +107 -0
- data/Rakefile +2 -0
- data/asciinema-rails.gemspec +27 -0
- data/lib/asciinema-rails.rb +1 -0
- data/lib/asciinema/asciicast.rb +44 -0
- data/lib/asciinema/asciicast_frames_file_updater.rb +24 -0
- data/lib/asciinema/asciicast_snapshot_updater.rb +20 -0
- data/lib/asciinema/brush.rb +78 -0
- data/lib/asciinema/cell.rb +33 -0
- data/lib/asciinema/cursor.rb +18 -0
- data/lib/asciinema/film.rb +40 -0
- data/lib/asciinema/frame.rb +26 -0
- data/lib/asciinema/frame_diff.rb +20 -0
- data/lib/asciinema/frame_diff_list.rb +26 -0
- data/lib/asciinema/grid.rb +51 -0
- data/lib/asciinema/json_file_writer.rb +21 -0
- data/lib/asciinema/rails.rb +8 -0
- data/lib/asciinema/rails/convertor.rb +97 -0
- data/lib/asciinema/rails/engine.rb +6 -0
- data/lib/asciinema/rails/version.rb +5 -0
- data/lib/asciinema/snapshot.rb +41 -0
- data/lib/asciinema/stdout.rb +101 -0
- data/lib/asciinema/terminal.rb +65 -0
- data/spec/.rspec +2 -0
- data/spec/asciinemosh_spec.rb +63 -0
- data/spec/fixtures/sudosh-script +8 -0
- data/spec/fixtures/sudosh-time +22 -0
- data/spec/spec_helper.rb +50 -0
- data/src/terminal +0 -0
- data/src/terminal.c +307 -0
- data/vendor/assets/javscripts/asciinema-rails.js +4 -0
- data/vendor/assets/javscripts/asciinema.org/asciinema-player.js +1149 -0
- data/vendor/assets/javscripts/asciinema.org/rAF.js +32 -0
- data/vendor/assets/javscripts/asciinema.org/react-0.10.0.js +17228 -0
- data/vendor/assets/javscripts/asciinema.org/screenfull.js +143 -0
- data/vendor/assets/stylesheets/asciinema-rails.css +4 -0
- data/vendor/assets/stylesheets/asciinema.org/asciinema-player.css +1732 -0
- data/vendor/assets/stylesheets/asciinema.org/themes/solarized-dark.css +35 -0
- data/vendor/assets/stylesheets/asciinema.org/themes/solarized-light.css +35 -0
- data/vendor/assets/stylesheets/asciinema.org/themes/tango.css +35 -0
- metadata +192 -0
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'asciinema/frame_diff'
|
2
|
+
|
3
|
+
class Frame
|
4
|
+
|
5
|
+
attr_reader :snapshot, :cursor
|
6
|
+
|
7
|
+
def initialize(snapshot, cursor)
|
8
|
+
@snapshot = snapshot
|
9
|
+
@cursor = cursor
|
10
|
+
end
|
11
|
+
|
12
|
+
def diff(other)
|
13
|
+
FrameDiff.new(snapshot_diff(other), cursor_diff(other))
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def snapshot_diff(other)
|
19
|
+
snapshot.diff(other && other.snapshot)
|
20
|
+
end
|
21
|
+
|
22
|
+
def cursor_diff(other)
|
23
|
+
cursor.diff(other && other.cursor)
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
class FrameDiff
|
2
|
+
|
3
|
+
def initialize(line_changes, cursor_changes)
|
4
|
+
@line_changes = line_changes
|
5
|
+
@cursor_changes = cursor_changes
|
6
|
+
end
|
7
|
+
|
8
|
+
def as_json(*)
|
9
|
+
json = {}
|
10
|
+
json[:lines] = line_changes unless line_changes.blank?
|
11
|
+
json[:cursor] = cursor_changes unless cursor_changes.blank?
|
12
|
+
|
13
|
+
json
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
attr_reader :line_changes, :cursor_changes
|
19
|
+
|
20
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'active_support/all'
|
2
|
+
|
3
|
+
class FrameDiffList
|
4
|
+
include Enumerable
|
5
|
+
|
6
|
+
delegate :each, :to => :frame_diffs
|
7
|
+
|
8
|
+
def initialize(frames)
|
9
|
+
@frames = frames
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
attr_reader :frames
|
15
|
+
|
16
|
+
def frame_diffs
|
17
|
+
previous_frame = nil
|
18
|
+
|
19
|
+
frames.map { |delay, frame|
|
20
|
+
diff = frame.diff(previous_frame)
|
21
|
+
previous_frame = frame
|
22
|
+
[delay, diff]
|
23
|
+
}
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
class Grid
|
2
|
+
require 'active_support/core_ext/enumerable'
|
3
|
+
|
4
|
+
attr_reader :width, :height, :lines
|
5
|
+
|
6
|
+
def initialize(lines)
|
7
|
+
@lines = lines
|
8
|
+
@width = lines.first && lines.first.sum(&:size) || 0
|
9
|
+
@height = lines.size
|
10
|
+
end
|
11
|
+
|
12
|
+
def crop(x, y, width, height)
|
13
|
+
cropped_lines = lines[y...y+height].map { |line| crop_line(line, x, width) }
|
14
|
+
|
15
|
+
self.class.new(cropped_lines)
|
16
|
+
end
|
17
|
+
|
18
|
+
def diff(other)
|
19
|
+
(0...height).each_with_object({}) do |y, diff|
|
20
|
+
if other.nil? || other.lines[y] != lines[y]
|
21
|
+
diff[y] = lines[y]
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def as_json(*)
|
27
|
+
lines.as_json
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def crop_line(line, x, width)
|
33
|
+
n = 0
|
34
|
+
cells = []
|
35
|
+
|
36
|
+
line.each do |cell|
|
37
|
+
if n <= x && x < n + cell.size
|
38
|
+
cells << cell[x-n...x-n+width]
|
39
|
+
elsif x < n && x + width >= n + cell.size
|
40
|
+
cells << cell
|
41
|
+
elsif n < x + width && x + width < n + cell.size
|
42
|
+
cells << cell[0...x+width-n]
|
43
|
+
end
|
44
|
+
|
45
|
+
n += cell.size
|
46
|
+
end
|
47
|
+
|
48
|
+
cells
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
class JsonFileWriter
|
2
|
+
|
3
|
+
def write_enumerable(file, array)
|
4
|
+
first = true
|
5
|
+
file << '['
|
6
|
+
|
7
|
+
array.each do |item|
|
8
|
+
if first
|
9
|
+
first = false
|
10
|
+
else
|
11
|
+
file << ','
|
12
|
+
end
|
13
|
+
|
14
|
+
file << item.to_json
|
15
|
+
end
|
16
|
+
|
17
|
+
file << ']'
|
18
|
+
file.close
|
19
|
+
end
|
20
|
+
|
21
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'asciinema/asciicast'
|
3
|
+
require 'asciinema/asciicast_frames_file_updater'
|
4
|
+
require 'asciinema/asciicast_snapshot_updater'
|
5
|
+
|
6
|
+
|
7
|
+
|
8
|
+
module Asciinema
|
9
|
+
module Rails
|
10
|
+
class Convertor
|
11
|
+
|
12
|
+
# Generate an asciicast from Sudosh log files.
|
13
|
+
|
14
|
+
# *Params*:
|
15
|
+
# - sudosh_timing_file_path: path to the Sudosh timing file
|
16
|
+
# - sudosh_script_file_path: path to the Sudosh script file
|
17
|
+
# - options: Additional options hash:
|
18
|
+
# * +:original_terminal_cols+: width of the Sudosh session terminal
|
19
|
+
# * +:original_terminal_rows+: height of the Sudosh session terminal
|
20
|
+
# * +:asciicast_path+: file to write output to
|
21
|
+
# *Returns*:
|
22
|
+
# - A File object pointing to the generated asciicast.
|
23
|
+
|
24
|
+
def self.sudosh_to_asciicast(sudosh_timing_file_path, sudosh_script_file_path, options = {})
|
25
|
+
original_terminal_cols = options[:original_terminal_cols] || 180
|
26
|
+
original_terminal_rows = options[:original_terminal_rows] || 43
|
27
|
+
asciicast_path = options[:asciicast_path]
|
28
|
+
|
29
|
+
timings = []
|
30
|
+
byte_offsets = []
|
31
|
+
|
32
|
+
# Split the sudosh timing file into individual time splits and data byte offsets.
|
33
|
+
# When paired with the Sudosh data file, each time split can be though of as an animation 'frame'
|
34
|
+
File.open(sudosh_timing_file_path) do |f|
|
35
|
+
f.each_line.each do |line|
|
36
|
+
split = line.split(' ')
|
37
|
+
timings << split[0].to_f
|
38
|
+
byte_offsets << split[1].to_i
|
39
|
+
end
|
40
|
+
end
|
41
|
+
duration = timings.inject(:+) ## add all of the time splits to get the total duration
|
42
|
+
|
43
|
+
# Split the script file into segments as defined by the byte offsets in the timing file.
|
44
|
+
# TODO: Write stdout directly to file rather than into memory first.
|
45
|
+
stdout = []
|
46
|
+
File.open(sudosh_script_file_path, 'rb') do |file|
|
47
|
+
until file.eof?
|
48
|
+
timestamp = '%.5f' % timings.shift.to_f
|
49
|
+
terminal_chunk = file.read(byte_offsets.shift)
|
50
|
+
stdout << [Float(timestamp), terminal_chunk]
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
json = {version: 1, width: original_terminal_cols, height: original_terminal_rows, duration: duration}
|
55
|
+
json[:stdout] = stdout
|
56
|
+
|
57
|
+
self.to_file(JSON.pretty_generate(json), asciicast_path)
|
58
|
+
|
59
|
+
end
|
60
|
+
|
61
|
+
# Generate a playback file from an asciicast.
|
62
|
+
|
63
|
+
# *Params*:
|
64
|
+
# - asciicast_path: path to the asciicast file
|
65
|
+
# - options: Additional options hash:
|
66
|
+
# * +:playback_path+: file to write the playback data to
|
67
|
+
# *Returns*:
|
68
|
+
# - An array containing:
|
69
|
+
# * the playback file content as a string
|
70
|
+
# * a snapshot string
|
71
|
+
# * a hash containing the original width (cols), height (rows) and duration of the asciicast.
|
72
|
+
|
73
|
+
def self.asciicast_to_playback(asciicast_path, options = {})
|
74
|
+
playback_path = options[:playback_path] # playback data will be written here
|
75
|
+
|
76
|
+
json = JSON.parse(File.read(asciicast_path)) # load the asciicast data into a hash
|
77
|
+
asciicast = Asciicast.new(json['width'], json['height'], json['duration'], asciicast_path) # create an Asciicast object
|
78
|
+
AsciicastFramesFileUpdater.new.update(asciicast, playback_path) # set playback data in Asciicast object, write to file also
|
79
|
+
AsciicastSnapshotUpdater.new.update(asciicast) # set snapshot data in Asciicast object.
|
80
|
+
|
81
|
+
[asciicast.stdout_frames, ActiveSupport::JSON.encode(asciicast.snapshot), {width: json['width'], height: json['height'], duration: json['duration']} ]
|
82
|
+
|
83
|
+
end
|
84
|
+
|
85
|
+
private
|
86
|
+
|
87
|
+
def self.to_file(content, outfile_location=nil)
|
88
|
+
out_file = outfile_location.present? ? File.new(outfile_location) : Tempfile.new('outfile')
|
89
|
+
File.open(out_file, "w") do |f|
|
90
|
+
f.write(content)
|
91
|
+
end
|
92
|
+
out_file
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'asciinema/cell'
|
2
|
+
require 'asciinema/brush'
|
3
|
+
|
4
|
+
class Snapshot < Grid
|
5
|
+
|
6
|
+
def self.build(data)
|
7
|
+
data = data.map { |cells|
|
8
|
+
cells.map { |cell|
|
9
|
+
Cell.new(cell[0], Brush.new(cell[1]))
|
10
|
+
}
|
11
|
+
}
|
12
|
+
|
13
|
+
new(data)
|
14
|
+
end
|
15
|
+
|
16
|
+
def thumbnail(w, h)
|
17
|
+
x = 0
|
18
|
+
y = height - h - trailing_empty_lines
|
19
|
+
y = 0 if y < 0
|
20
|
+
|
21
|
+
crop(x, y, w, h)
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def trailing_empty_lines
|
27
|
+
n = 0
|
28
|
+
|
29
|
+
(height - 1).downto(0) do |y|
|
30
|
+
break unless line_empty?(y)
|
31
|
+
n += 1
|
32
|
+
end
|
33
|
+
|
34
|
+
n
|
35
|
+
end
|
36
|
+
|
37
|
+
def line_empty?(y)
|
38
|
+
lines[y].empty? || lines[y].all? { |cell| cell.empty? }
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
require 'open-uri'
|
2
|
+
require 'oj'
|
3
|
+
|
4
|
+
class Stdout
|
5
|
+
include Enumerable
|
6
|
+
|
7
|
+
class SingleFile < self
|
8
|
+
attr_reader :path
|
9
|
+
|
10
|
+
def initialize(path)
|
11
|
+
@path = path
|
12
|
+
end
|
13
|
+
|
14
|
+
def each(&blk)
|
15
|
+
open(path, 'r') do |f|
|
16
|
+
Oj.sc_parse(FrameIterator.new(blk), f)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
class FrameIterator < ::Oj::ScHandler
|
21
|
+
|
22
|
+
def initialize(callback)
|
23
|
+
@callback = callback
|
24
|
+
end
|
25
|
+
|
26
|
+
def array_start
|
27
|
+
if @top # we're already inside top level array
|
28
|
+
[] # <- this will hold pair [delay, data]
|
29
|
+
else
|
30
|
+
@top = []
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def array_append(a, v)
|
35
|
+
if a.equal?(@top)
|
36
|
+
@callback.call(*v)
|
37
|
+
else
|
38
|
+
a << v
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
45
|
+
|
46
|
+
class MultiFile < self
|
47
|
+
attr_reader :data_path, :timing_path
|
48
|
+
|
49
|
+
def initialize(data_path, timing_path)
|
50
|
+
@data_path = data_path
|
51
|
+
@timing_path = timing_path
|
52
|
+
end
|
53
|
+
|
54
|
+
def each
|
55
|
+
File.open(data_path, 'rb') do |file|
|
56
|
+
File.foreach(timing_path) do |line|
|
57
|
+
yield(*delay_and_data_for_line(file, line))
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
private
|
63
|
+
|
64
|
+
def delay_and_data_for_line(file, line)
|
65
|
+
delay, size = TimingParser.parse_line(line)
|
66
|
+
data = file.read(size).to_s.force_encoding('utf-8')
|
67
|
+
|
68
|
+
[delay, data]
|
69
|
+
end
|
70
|
+
|
71
|
+
end
|
72
|
+
|
73
|
+
class Buffered < self
|
74
|
+
MIN_FRAME_LENGTH = 1.0 / 60
|
75
|
+
|
76
|
+
attr_reader :stdout
|
77
|
+
|
78
|
+
def initialize(stdout)
|
79
|
+
@stdout = stdout
|
80
|
+
end
|
81
|
+
|
82
|
+
def each
|
83
|
+
buffered_delay, buffered_data = 0.0, []
|
84
|
+
|
85
|
+
stdout.each do |delay, data|
|
86
|
+
if buffered_delay + delay < MIN_FRAME_LENGTH || buffered_data.empty?
|
87
|
+
buffered_delay += delay
|
88
|
+
buffered_data << data
|
89
|
+
else
|
90
|
+
yield(buffered_delay, buffered_data.join)
|
91
|
+
buffered_delay = delay
|
92
|
+
buffered_data = [data]
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
yield(buffered_delay, buffered_data.join) unless buffered_data.empty?
|
97
|
+
end
|
98
|
+
|
99
|
+
end
|
100
|
+
|
101
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'open3'
|
2
|
+
require 'yajl'
|
3
|
+
require 'asciinema/grid'
|
4
|
+
require 'asciinema/snapshot'
|
5
|
+
require 'asciinema/cursor'
|
6
|
+
|
7
|
+
class Terminal
|
8
|
+
|
9
|
+
# BINARY_PATH = (File.dirname(__FILE__) + "/../../bin/" + "terminal").to_s
|
10
|
+
BINARY_PATH = 'terminal'
|
11
|
+
|
12
|
+
def initialize(width, height)
|
13
|
+
@process = Process.new("#{BINARY_PATH} #{width} #{height}")
|
14
|
+
end
|
15
|
+
|
16
|
+
def feed(data)
|
17
|
+
process.write("d\n#{data.bytesize}\n")
|
18
|
+
process.write(data)
|
19
|
+
end
|
20
|
+
|
21
|
+
def snapshot
|
22
|
+
process.write("p\n")
|
23
|
+
lines = Yajl::Parser.new.parse(process.read_line)
|
24
|
+
|
25
|
+
Snapshot.build(lines)
|
26
|
+
end
|
27
|
+
|
28
|
+
def cursor
|
29
|
+
process.write("c\n")
|
30
|
+
c = Yajl::Parser.new.parse(process.read_line)
|
31
|
+
|
32
|
+
Cursor.new(c['x'], c['y'], c['visible'])
|
33
|
+
end
|
34
|
+
|
35
|
+
def release
|
36
|
+
process.stop
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
attr_reader :process
|
42
|
+
|
43
|
+
class Process
|
44
|
+
|
45
|
+
def initialize(command)
|
46
|
+
@stdin, @stdout, @thread = Open3.popen2(command)
|
47
|
+
end
|
48
|
+
|
49
|
+
def write(data)
|
50
|
+
raise "terminal died" unless @thread.alive?
|
51
|
+
@stdin.write(data)
|
52
|
+
end
|
53
|
+
|
54
|
+
def read_line
|
55
|
+
raise "terminal died" unless @thread.alive?
|
56
|
+
@stdout.readline.strip
|
57
|
+
end
|
58
|
+
|
59
|
+
def stop
|
60
|
+
@stdin.close
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
64
|
+
|
65
|
+
end
|