Skip to content

Instantly share code, notes, and snippets.

@ku1ik
Last active November 30, 2015 12:42
Show Gist options
  • Save ku1ik/504474702dd18c7dc0ec to your computer and use it in GitHub Desktop.
Save ku1ik/504474702dd18c7dc0ec to your computer and use it in GitHub Desktop.
Convert from asciinema "format 1 JSON" to asciinema "player JSON"
require 'json'
require 'open3'
class Hash
def slice *keys
select{|k| keys.member?(k)}
end
end
class Stdout
include Enumerable
class SingleFile < self
attr_reader :path
def initialize(path)
@path = path
@stdout = JSON.load(File.read(path))['stdout']
end
def each(&blk)
@stdout.each(&blk)
end
end
class Buffered < self
MIN_FRAME_LENGTH = 1.0 / 60
attr_reader :stdout
def initialize(stdout)
@stdout = stdout
end
def each
buffered_delay, buffered_data = 0.0, []
stdout.each do |delay, data|
if buffered_delay + delay < MIN_FRAME_LENGTH || buffered_data.empty?
buffered_delay += delay
buffered_data << data
else
yield(buffered_delay, buffered_data.join)
buffered_delay = delay
buffered_data = [data]
end
end
yield(buffered_delay, buffered_data.join) unless buffered_data.empty?
end
end
end
class Terminal
BINARY_PATH = "./terminal"
def initialize(width, height)
@process = Process.new("#{BINARY_PATH} #{width} #{height}")
end
def feed(data)
process.write("d\n#{data.bytesize}\n")
process.write(data)
end
def snapshot
process.write("p\n")
lines = JSON.parse(process.read_line)
Snapshot.build(lines)
end
def cursor
process.write("c\n")
c = JSON.parse(process.read_line)
Cursor.new(c['x'], c['y'], c['visible'])
end
def release
process.stop
end
private
attr_reader :process
class Process
def initialize(command)
@stdin, @stdout, @thread = Open3.popen2(command)
end
def write(data)
raise "terminal died" unless @thread.alive?
@stdin.write(data)
end
def read_line
raise "terminal died" unless @thread.alive?
@stdout.readline.strip
end
def stop
@stdin.close
end
end
end
class Cursor
attr_reader :x, :y, :visible
def initialize(x, y, visible)
@x, @y, @visible = x, y, visible
end
def diff(other)
diff = {}
diff[:x] = x if other && x != other.x || other.nil?
diff[:y] = y if other && y != other.y || other.nil?
diff[:visible] = visible if other && visible != other.visible || other.nil?
diff
end
end
class Grid
attr_reader :width, :height, :lines
def initialize(lines)
@lines = lines
@width = lines.first && lines.first.inject(0) { |l| l.size } || 0
@height = lines.size
end
def crop(x, y, width, height)
cropped_lines = lines[y...y+height].map { |line| crop_line(line, x, width) }
self.class.new(cropped_lines)
end
def diff(other)
(0...height).each_with_object({}) do |y, diff|
if other.nil? || other.lines[y] != lines[y]
diff[y] = lines[y]
end
end
end
def as_json(*)
lines.as_json
end
private
def crop_line(line, x, width)
n = 0
cells = []
line.each do |cell|
if n <= x && x < n + cell.size
cells << cell[x-n...x-n+width]
elsif x < n && x + width >= n + cell.size
cells << cell
elsif n < x + width && x + width < n + cell.size
cells << cell[0...x+width-n]
end
n += cell.size
end
cells
end
end
class Snapshot < Grid
def self.build(data)
data = data.map { |cells|
cells.map { |cell|
Cell.new(cell[0], Brush.new(cell[1]))
}
}
new(data)
end
def thumbnail(w, h)
x = 0
y = height - h - trailing_empty_lines
y = 0 if y < 0
crop(x, y, w, h)
end
private
def trailing_empty_lines
n = 0
(height - 1).downto(0) do |y|
break unless line_empty?(y)
n += 1
end
n
end
def line_empty?(y)
lines[y].empty? || lines[y].all? { |cell| cell.empty? }
end
end
class Cell
attr_reader :text, :brush
def initialize(text, brush)
@text = text
@brush = brush
end
def size
text.size
end
def empty?
text.blank? && brush.default?
end
def ==(other)
text == other.text && brush == other.brush
end
def [](*args)
self.class.new(text[*args], brush)
end
def as_json(*)
[text, brush.as_json]
end
def to_json(*)
JSON.dump(as_json)
end
end
class Brush
ALLOWED_ATTRIBUTES = [:fg, :bg, :bold, :underline, :inverse, :blink]
DEFAULT_FG_CODE = 7
DEFAULT_BG_CODE = 0
def initialize(attributes = {})
@attributes = Hash[*attributes.map { |k,v| [k.to_sym, v] }.flatten]
end
def ==(other)
fg == other.fg &&
bg == other.bg &&
bold? == other.bold? &&
underline? == other.underline? &&
blink? == other.blink?
end
def fg
inverse? ? bg_code || DEFAULT_BG_CODE : fg_code
end
def bg
inverse? ? fg_code || DEFAULT_FG_CODE : bg_code
end
def bold?
!!attributes[:bold]
end
def underline?
!!attributes[:underline]
end
def inverse?
!!attributes[:inverse]
end
def blink?
!!attributes[:blink]
end
def default?
fg.nil? && bg.nil? && !bold? && !underline? && !inverse? && !blink?
end
def as_json(*)
attributes.slice(*ALLOWED_ATTRIBUTES)
end
protected
attr_reader :attributes
private
def fg_code
calculate_code(:fg, bold?)
end
def bg_code
calculate_code(:bg, blink?)
end
def calculate_code(attr_name, strong)
code = attributes[attr_name]
if code
if code < 8 && strong
code += 8
end
end
code
end
end
class JsonFileWriter
def write_enumerable(file, array)
first = true
file << '['
array.each do |item|
if first
first = false
else
file << ','
end
file << item.to_json
end
file << ']'
file.close
end
end
class Film
def initialize(stdout, terminal)
@stdout = stdout
@terminal = terminal
end
def snapshot_at(time)
stdout_each_until(time) do |delay, data|
terminal.feed(data)
end
terminal.snapshot
end
def frames
frames = stdout.map do |delay, data|
terminal.feed(data)
[delay, Frame.new(terminal.snapshot, terminal.cursor)]
end
FrameDiffList.new(frames)
end
private
def stdout_each_until(seconds)
stdout.each do |delay, frame_data|
seconds -= delay
break if seconds <= 0
yield(delay, frame_data)
end
end
attr_reader :stdout, :terminal
end
class Frame
attr_reader :snapshot, :cursor
def initialize(snapshot, cursor)
@snapshot = snapshot
@cursor = cursor
end
def diff(other)
FrameDiff.new(snapshot_diff(other), cursor_diff(other))
end
private
def snapshot_diff(other)
snapshot.diff(other && other.snapshot)
end
def cursor_diff(other)
cursor.diff(other && other.cursor)
end
end
class FrameDiff
def initialize(line_changes, cursor_changes)
@line_changes = line_changes
@cursor_changes = cursor_changes
end
def as_json(*)
json = {}
json[:lines] = line_changes unless line_changes.empty?
json[:cursor] = cursor_changes unless cursor_changes.empty?
json
end
def to_json(*)
JSON.dump(as_json)
end
private
attr_reader :line_changes, :cursor_changes
end
class FrameDiffList
include Enumerable
def initialize(frames)
@frames = frames
end
def each(*args, &blk)
frame_diffs.each(*args, &blk)
end
private
attr_reader :frames
def frame_diffs
previous_frame = nil
frames.map { |delay, frame|
diff = frame.diff(previous_frame)
previous_frame = frame
[delay, diff]
}
end
end
input_file = ARGV[0]
output_file = ARGV[1]
asciicast = JSON.load(File.read(input_file))
terminal = Terminal.new(asciicast['width'], asciicast['height'])
stdout = Stdout::Buffered.new(Stdout::SingleFile.new(input_file))
file = File.open(output_file, 'w')
film = Film.new(stdout, terminal)
JsonFileWriter.new.write_enumerable(file, film.frames)
terminal.release

0. compile libtsm and terminal binary

NOTE: libtsm will only compile on Linux.

sudo apt-get install -y autoconf libtool pkg-config
DIR=$(mktemp -d -t tsmXXXXXX)
cd $DIR
git clone git://people.freedesktop.org/~dvdhrm/libtsm .
git checkout bb4e454
test -f ./configure || NOCONFIGURE=1 ./autogen.sh
./configure --prefix=/usr/local
make
sudo make install
sudo ldconfig
cd -

git clone https://github.com/asciinema/asciinema.org
cd asciinema.org/src
make
cd -
cp asciinema.org/bin/terminal .

1. record

asciinema rec foo.json

2. convert

ruby convert.rb foo.json foo-for-player.json
@sandric
Copy link

sandric commented Nov 30, 2015

I believe with this commit asciinema/asciinema-server@0f91a03 there's no need in git checkout bb4e454 - if so there's an error gets spawned, libtsm's master is working ok thought.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment