crystal-ffmpeg
crystal-ffmpeg
A Crystal port of Instructure's ruby-ffmpeg gem: a simple yet powerful wrapper around the ffmpeg and ffprobe binaries for reading multimedia metadata and transcoding media.
The public API mirrors the gem — FFMPEG::Media, FFMPEG::Stream, the FFMPEG::CommandArgs DSL, FFMPEG::Preset / FFMPEG::Presets, FFMPEG::Filters, FFMPEG::Reporters, FFMPEG::Transcoder, FFMPEG::Remuxer and FFMPEG::DASH::Manifest — reimplemented in idiomatic, type-safe Crystal.
Why (and what's different)
This shard is designed to run inside queue workers, where the realistic failure mode is not a crash but a corrupted input that makes a subprocess hang forever. Every subprocess — ffmpeg, ffprobe (metadata reads included) and exiftool — is supervised:
- Metadata reads (
FFMPEG.ffprobe_capture3, used byMedia#load!) are bounded by a total wall-clock cap (FFMPEG.io_timeout, default 30 s). The Ruby gem leaves this path uncapped; this port fixes that. - Transcodes (
FFMPEG.ffmpeg_execute) are bounded by an inactivity timeout that resets on every progress line (FFMPEG.io_timeout), plus an optional overall cap (FFMPEG.timeout). - On expiry the child is killed with
SIGKILLand reaped — no zombie processes, no stuck worker slots — andFFMPEG::TimeoutErroris raised.
All commands are spawned with argv arrays (never through a shell), and the supervision is built on Channel/select, safe under Crystal 1.21 execution contexts.
Compatibility
- Crystal >= 1.21.0
- Tested against ffmpeg/ffprobe 8. On macOS:
brew install ffmpeg.
Installation
Add the dependency to your shard.yml and run shards install:
dependencies:
crystal-ffmpeg:
github: MatheusBasso99/crystal-ffmpeg
Usage
require "crystal-ffmpeg"
Metadata
media = FFMPEG::Media.new("path/to/media.mp4")
media.valid? # => true (false if ffprobe fails to read the media)
media.local? # => true
media.remote? # => false
media.duration # => 7.11 (seconds)
media.resolution # => "3840x2160" (rotation-corrected)
media.display_aspect_ratio # => FFMPEG::Rational(16/9)
media.frame_rate # => FFMPEG::Rational(60/1)
media.video_codec_name # => "h264"
media.audio_codec_name # => "aac"
media.extname # => ".mp4" (derived from the container, not the filename)
Remote media works too (http/https), and metadata can be loaded lazily:
media = FFMPEG::Media.new("https://example.com/media.mp4", load: false)
media.loaded? # => false
media.video? # => true (loaded automatically on first access)
Transcoding with presets
Multiple presets run in a single ffmpeg command; each preset contributes its arguments and one output path:
transcoder = FFMPEG::Transcoder.new(
presets: [
FFMPEG::Presets.h264_360p,
FFMPEG::Presets.aac_128k,
] of FFMPEG::Preset
)
status = transcoder.process(media, "output/video.mp4") do |report|
if report.is_a?(FFMPEG::Reporters::Progress)
puts "time=#{report.time} speed=#{report.speed}"
end
end
status.paths # => ["output/video.360p.mp4", "output/video.m4a"]
status.success? # => true
The media-aware FFMPEG::CommandArgs DSL clamps target rates to the source (never up-rating), and unknown DSL methods fall through to plain arguments:
args = FFMPEG::CommandArgs.compose(media) do
map media.video_mapping_id do
video_codec_name "libx264"
frame_rate 30 # clamped to the source frame rate
end
use_timeline 1 # falls through to "-use_timeline 1"
end
args.to_a # => ["-map", "v:0", "-c:v", "libx264", "-r", "30", "-use_timeline", "1"]
Remuxing
status = FFMPEG::Remuxer.new.process("input.webm", "output.mp4")
status.success? # => true
If the direct stream copy fails and the video codec supports it (H.264/HEVC), the remuxer falls back to Annex B extraction with a corrected frame rate, and restores the rotation tag via exiftool when available.
DASH manifests
manifest = FFMPEG::DASH::Manifest.parse(File.read("manifest.mpd"))
manifest.base_url = "https://cdn.example.com/media/"
manifest.segment_query = "token=abc"
manifest.to_xml # the rewritten MPD document
manifest.to_m3u8 # the equivalent HLS master playlist
Development
shards install # install dev dependencies (ameba)
crystal spec # run the test suite
crystal tool format # format the code
bin/ameba # lint
crystal build --no-codegen src/crystal-ffmpeg.cr # fast type-check
# Line coverage (100% enforced) via kcov:
crystal build --debug spec/coverage_runner_spec.cr -o bin/spec_runner
kcov --clean --include-path="$(pwd)/src" coverage ./bin/spec_runner
The specs need ffmpeg/ffprobe in the PATH and use the media fixtures under spec/fixtures (ported from the Ruby gem).
Acknowledgments
Huge thanks to Instructure and the authors of the original ruby-ffmpeg gem — this shard is a direct port of their excellent design and public API.
Contributing
- Fork it (https://github.com/MatheusBasso99/crystal-ffmpeg/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
crystal-ffmpeg
- 0
- 0
- 0
- 0
- 1
- 1 day ago
- July 19, 2026
MIT License
Mon, 20 Jul 2026 01:24:55 GMT