crystal-streamio v0.1.0
crystal-streamio
Crystal port of the streamio-ffmpeg gem: a simple yet powerful wrapper around the ffmpeg and ffprobe commands for reading movie metadata and transcoding.
On top of the original gem's feature set, every external process call — ffprobe included — is supervised with a hard timeout that kills and reaps the child process. A corrupted or malformed input file that makes ffprobe hang can never silently eat a queue worker slot.
Requirements
- Crystal >= 1.20.2
ffmpegandffprobeonPATH(on macOS:brew install ffmpeg)
Installation
-
Add the dependency to your
shard.yml:dependencies: crystal-streamio: github: MatheusBasso99/crystal-streamio -
Run
shards install
Usage
require "crystal-streamio"
Reading Metadata
movie = FFmpeg::Movie.new("path/to/movie.mov")
movie.duration # => 7.56 (duration of the movie in seconds)
movie.bitrate # => 481836 (bitrate in bit/s)
movie.size # => 455546 (filesize in bytes)
movie.video_stream # => "h264 (Main) (avc1 / 0x31637661), yuv420p, 640x480 [SAR 1:1 DAR 4:3]"
movie.video_codec # => "h264"
movie.colorspace # => "yuv420p"
movie.resolution # => "640x480"
movie.width # => 640 (width of the movie in pixels)
movie.height # => 480 (height of the movie in pixels)
movie.frame_rate # => 16.746... (frames per second)
movie.audio_stream # => "aac (mp4a / 0x6134706d), 44100 Hz, stereo, fltp, 75832 bit/s"
movie.audio_codec # => "aac"
movie.audio_sample_rate # => 44100
movie.audio_channels # => 2
# Multiple audio streams
movie.audio_streams[0].codec_name # => "aac"
movie.valid? # => true (false when ffmpeg fails to read the movie)
Movies can also be read from HTTP/HTTPS URLs; redirects are followed up to FFmpeg.max_http_redirect_attempts (default 10):
movie = FFmpeg::Movie.new("http://example.com/movie.mov")
movie.remote? # => true
movie.size # => Content-Length of the HEAD response
Transcoding
The first argument is the output file path.
movie.transcode("tmp/movie.mp4") # Default ffmpeg settings for mp4 format
Keep track of progress with an optional block.
movie.transcode("movie.mp4") { |progress| puts progress } # 0.2 ... 0.5 ... 1.0
Give custom command line options with an argv array.
movie.transcode("movie.mp4", %w(-acodec aac -vcodec libx264 -ac 2))
Use FFmpeg::EncodingOptions for humanly readable transcoding options. Note that custom is an array so that it can hold repeatable ffmpeg options like -map:
options = FFmpeg::EncodingOptions.new(
video_codec: "libx264", frame_rate: 10, resolution: "320x240", video_bitrate: 300,
video_bitrate_tolerance: 100, aspect: 1.333333, keyframe_interval: 90,
x264_vprofile: "high", x264_preset: "slow",
audio_codec: "aac", audio_bitrate: 32, audio_sample_rate: 22050, audio_channels: 1,
threads: 2, custom: %w(-vf crop=60:60:10:10 -map 0:0 -map 0:1))
movie.transcode("movie.mp4", options)
The transcode method returns a FFmpeg::Movie for the encoded file.
transcoded_movie = movie.transcode("tmp/movie.flv")
transcoded_movie.try(&.video_codec) # => "flv"
transcoded_movie.try(&.audio_codec) # => "mp3"
An aspect ratio is added to the encoding options automatically when none is specified (resolution: "320x180" implies -aspect 1.7777777777777777).
Preserve aspect ratio on width or height with the preserve_aspect_ratio transcoder option:
widescreen_movie = FFmpeg::Movie.new("path/to/widescreen_movie.mov")
options = FFmpeg::EncodingOptions.new(resolution: "320x240")
widescreen_movie.transcode("movie.mp4", options,
FFmpeg::TranscoderOptions.new(preserve_aspect_ratio: :width)) # => 320x180
widescreen_movie.transcode("movie.mp4", options,
FFmpeg::TranscoderOptions.new(preserve_aspect_ratio: :height)) # => 426x240
For constant bitrate encoding use video_min_bitrate and video_max_bitrate with buffer_size:
options = FFmpeg::EncodingOptions.new(
video_min_bitrate: 600, video_max_bitrate: 600, buffer_size: 2000)
movie.transcode("movie.flv", options)
Specifying Input Options
Options that must apply to the input (such as its framerate) go into input_options:
transcoder_options = FFmpeg::TranscoderOptions.new(input_options: {"framerate" => "1/5"})
movie.transcode("movie.mp4", FFmpeg::EncodingOptions.new, transcoder_options)
# ffmpeg -y -framerate 1/5 -i path/to/movie.mov movie.mp4
Overriding the Input Path
When ffmpeg should read a sequence of files instead of the probed movie, set input:
transcoder_options = FFmpeg::TranscoderOptions.new(input: "img_%03d.png")
movie.transcode("movie.mp4", FFmpeg::EncodingOptions.new, transcoder_options)
# ffmpeg -y -i img_%03d.png movie.mp4
Watermarking
Add a watermark image on the video, e.g. at the right top corner with 10px padding:
options = FFmpeg::EncodingOptions.new(
watermark: "full_path_of_watermark.png", resolution: "640x360",
watermark_filter: FFmpeg::WatermarkFilter.new(position: :rt, padding_x: 10, padding_y: 10))
Positions are LT (Left Top), RT (Right Top), LB (Left Bottom) and RB (Right Bottom); padding_x/padding_y default to 10.
Taking Screenshots
movie.screenshot("screenshot.jpg")
movie.screenshot("screenshot.bmp", FFmpeg::EncodingOptions.new(seek_time: 5, resolution: "320x240"))
To generate multiple screenshots in a single pass, specify vframes and a wildcard filename, and disable output file validation:
movie.screenshot("screenshot_%d.jpg",
FFmpeg::EncodingOptions.new(vframes: 20, frame_rate: "1/6"),
FFmpeg::TranscoderOptions.new(validate: false))
Use quality (ffmpeg -q:v, 1..31, lower is better) for compressed formats, and preserve aspect ratio the same way as when transcoding:
movie.screenshot("screenshot.png",
FFmpeg::EncodingOptions.new(seek_time: 2, resolution: "200x120"),
FFmpeg::TranscoderOptions.new(preserve_aspect_ratio: :width))
Creating a Slideshow from Stills
There is no movie to probe, so use FFmpeg::Transcoder directly with input and input_options:
slideshow = FFmpeg::Transcoder.new(nil, "slideshow.mp4",
FFmpeg::EncodingOptions.new(resolution: "320x240"),
FFmpeg::TranscoderOptions.new(
input: "img_%03d.jpeg",
input_options: {"framerate" => "1/5"}))
slideshow.run
Configuration
FFmpeg.ffmpeg_binary = "/usr/local/bin/ffmpeg" # default: found on PATH
FFmpeg.ffprobe_binary = "/usr/local/bin/ffprobe" # default: found on PATH
FFmpeg.max_http_redirect_attempts = 5 # default: 10
FFmpeg.logger # stdlib Log for "ffmpeg"
Timeouts (hang protection)
Reading metadata is capped by a total wall-clock timeout — ffprobe emits nothing until it finishes, so this is the only guard that catches a hang on a corrupted file:
FFmpeg.ffprobe_timeout = 30.seconds # default; nil disables (not recommended)
Transcoding uses an inactivity timeout that resets on every ffmpeg progress line and trips only after that much silence:
FFmpeg::Transcoder.timeout = 10.seconds # default 30 seconds; nil disables
On expiry the child process is killed with SIGKILL, reaped (no zombies) and an FFmpeg::TimeoutError / FFmpeg::Error is raised.
Development
shards install # install dev dependencies (ameba)
crystal spec # run the test suite
crystal tool format # format the code
bin/ameba # lint
# 100% line coverage gate (requires kcov):
crystal build --debug spec/support/coverage_entrypoint.cr -o bin/spec_runner
kcov --clean --include-path="$(pwd)/src" coverage ./bin/spec_runner
Contributing
- Fork it (https://github.com/MatheusBasso99/crystal-streamio/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
Contributors
- Matheus Basso - creator and maintainer
Acknowledgments
This shard is a Crystal port of, and was entirely inspired by, the excellent streamio-ffmpeg Ruby gem. Huge thanks to its authors and contributors — the public API, option set, and test fixtures here all follow their design. Please give the original project a look and a star: https://github.com/streamio/streamio-ffmpeg.
crystal-streamio
- 0
- 0
- 0
- 0
- 1
- about 8 hours ago
- July 9, 2026
MIT License
Thu, 09 Jul 2026 03:58:28 GMT