|
#!/usr/bin/env ruby |
|
# |
|
# This script uses ffmpeg and the [1]inline images protocol from iTerm2 to render |
|
# a video on the terminal window. |
|
# |
|
# **IMPORTANT** It is not intended to be used as a regular video player. |
|
# Use only for very unscientific benchmarks. |
|
# |
|
# Frames are extracted by ffmpeg and sent to the script through a local TCP |
|
# server. Then, the frame is written to the terminal with the OSC 1337 |
|
# sequence: |
|
# |
|
# OSC 1337 ; File=inline=1 : <base64> ^G |
|
# |
|
# [1]: https://iterm2.com/documentation-images.html |
|
|
|
require "socket" |
|
require "json" |
|
require "base64" |
|
require "optionparser" |
|
|
|
Options = { |
|
fps: nil, |
|
num_frames: nil, |
|
quality: nil, |
|
image_format: "jpeg", |
|
scale: nil, |
|
position: "1,1", |
|
} |
|
|
|
OptionParser.new do |opts| |
|
opts.on("-f", "--fps [FPS]", OptionParser::DecimalInteger, "Frame rate (`-r` option for ffmpeg)") do |fps| |
|
Options[:fps] = fps |
|
end |
|
|
|
opts.on("-n", "--frames [NUMBER]", OptionParser::DecimalInteger, "Number of frames to render.") do |frames| |
|
Options[:num_frames] = frames |
|
end |
|
|
|
opts.on("-q", "--quality [Q]", OptionParser::DecimalInteger, "Quality (`-q:v` option for ffmpeg)") do |frames| |
|
Options[:quality] = frames |
|
end |
|
|
|
opts.on("-i", "--image-format [FORMAT]", "Image format (default: #{Options[:image_format]})") do |format| |
|
Options[:image_format] = format |
|
end |
|
|
|
opts.on("-s", "--scale [SCALE]", "Scale. See scale filer in ffmpeg docs for details") do |scale| |
|
Options[:scale] = scale |
|
end |
|
|
|
opts.on("-p", "--position [ROW,COLUMN]", "Position for the top-left corner.") do |position| |
|
Options[:position] = position |
|
end |
|
end.parse! |
|
|
|
if ARGV.size != 1 |
|
STDERR.puts "Usage: #$0 video" |
|
exit 1 |
|
end |
|
|
|
VIDEO_FILE = ARGV.shift |
|
|
|
# Restore terminal after exit |
|
at_exit do |
|
print "\e[2J" |
|
STDOUT.flush |
|
print "\e[?1049l\e[?25h" |
|
end |
|
|
|
# Alt-screen, hidden cursor |
|
print "\e[?1049h\e[?25l\e[1;1H\e[2J" |
|
|
|
# |
|
# Extract frame rate and dimensions with ffprobe. |
|
|
|
VIDEO_METADATA = |
|
begin |
|
|
|
ffprobe = %W( |
|
ffprobe |
|
-v error |
|
-print_format json |
|
-select_streams v |
|
-show_streams |
|
#{VIDEO_FILE} |
|
) |
|
|
|
stream = IO.popen(ffprobe) do |child| |
|
JSON.parse(child.read).dig("streams", 0) |
|
end |
|
|
|
fps = |
|
if stream["r_frame_rate"] =~ %r[(\d+)/(\d+)] |
|
$1.to_f / $2.to_f |
|
else |
|
# Unknown value. Assume 24fps |
|
24.0 |
|
end |
|
|
|
Struct.new(:width, :height, :fps).new(stream["width"], stream["height"], fps) |
|
end |
|
|
|
|
|
# |
|
# Start a TCP server to receive frames from ffmpeg. |
|
|
|
SERVER = TCPServer.new("localhost", 0) |
|
LISTEN_PORT = SERVER.addr[1] |
|
|
|
FRAMES_QUEUE = SizedQueue.new(1000) |
|
|
|
Thread.new do |
|
while client = SERVER.accept |
|
data = '' |
|
loop do |
|
new_data = client.recv(4096) |
|
break if new_data.nil? || new_data.empty? |
|
data << new_data |
|
end |
|
|
|
FRAMES_QUEUE << data |
|
end |
|
rescue Errno::EINVAL |
|
FRAMES_QUEUE << nil |
|
end |
|
|
|
|
|
# |
|
# Another background thread to render frames |
|
|
|
RENDER = Thread.new do |
|
def monotonic |
|
Process.clock_gettime(Process::CLOCK_MONOTONIC) |
|
end |
|
|
|
interval = 1.0 / (Options[:fps] || VIDEO_METADATA.fps) |
|
video_metadata = VIDEO_METADATA |
|
|
|
frame_bytes = 0 |
|
frame_count = 0 |
|
frames_dropped = 0 |
|
|
|
first_frame = nil |
|
|
|
cursor_position = Options[:position].scan(/\d+/).join(";") |
|
|
|
while frame = FRAMES_QUEUE.shift |
|
break if frame == :stop |
|
|
|
first_frame ||= monotonic() |
|
next_frame = monotonic() + interval |
|
|
|
# Move the cursor and send the frame using the iTerm2 protocol |
|
print "\e[#{cursor_position}H\e]1337;File=inline=1:" |
|
print Base64.encode64(frame) |
|
print "\a" |
|
|
|
frame_count += 1 |
|
frame_bytes += frame.bytesize |
|
|
|
# Show stats |
|
elapsed = monotonic() - first_frame |
|
print "#{frame_bytes} bytes in #{frame_count} frames" |
|
print " (#{frames_dropped} dropped)" if frames_dropped > 0 |
|
print " (#{(frame_bytes / 1024.0 / frame_count).round(0)} Kib/frame)" |
|
puts " after #{elapsed.round(1)} seconds (#{(frame_count / elapsed).round(2)} fps)\e[K" |
|
|
|
# Print metadata after the first frame |
|
if video_metadata |
|
print "\n[stream]" |
|
video_metadata.to_h.each_pair do |k, v| |
|
print " #{k}=#{v}" |
|
end |
|
video_metadata = nil |
|
end |
|
|
|
# Wait for the next frame, or drop some of them if |
|
# the terminal is too slow. |
|
wait = next_frame - monotonic() |
|
if wait > 0 |
|
sleep wait |
|
else |
|
dropped = (-wait / interval).ceil |
|
begin |
|
dropped.times do |
|
FRAMES_QUEUE.shift(true) |
|
frames_dropped += 1 |
|
end |
|
rescue ThreadError |
|
break # Queue was empty |
|
end |
|
end |
|
|
|
end |
|
end |
|
|
|
# |
|
# Launch ffmpeg to extract frames and send them to our TCP server |
|
|
|
pid = fork do |
|
ENV["NO_COLOR"] = "1" |
|
command = %W( |
|
ffmpeg |
|
-threads 1 |
|
-v error |
|
-i #{VIDEO_FILE} |
|
) |
|
|
|
if fps = Options[:fps] |
|
command << "-r" << fps.to_s |
|
end |
|
|
|
if n = Options[:num_frames] |
|
command << "-frames" << n.to_s |
|
end |
|
|
|
if q = Options[:quality] |
|
command << "-q:v" << q.to_s |
|
end |
|
|
|
if s = Options[:scale] |
|
command << "-vf" << "scale=#{s}" |
|
end |
|
|
|
command.concat(%W( |
|
-f image2 |
|
tcp://localhost:#{LISTEN_PORT}?dummy=%03d.#{Options[:image_format]} |
|
)) |
|
|
|
exec(*command) |
|
end |
|
|
|
# |
|
# Wait until all frames are rendered |
|
|
|
begin |
|
Process.waitpid(pid) |
|
|
|
if not $?.success? |
|
STDERR.puts "\n\n\033[31mffmpeg failed. Press ENTER to exit.\033[m" |
|
readline |
|
exit |
|
end |
|
|
|
SERVER.close_read |
|
FRAMES_QUEUE << :stop |
|
RENDER.join if RENDER.alive? |
|
rescue Interrupt |
|
begin |
|
Process.kill("KILL", pid) |
|
Process.waitpid(pid) |
|
rescue SystemCallError |
|
end |
|
RENDER.terminate |
|
end |
|
|
|
puts "\n\n\n\033[32mFinished. Press ENTER to exit.\033[m" |
|
readline |