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 by Media#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 SIGKILL and reaped — no zombie processes, no stuck worker slots — and FFMPEG::TimeoutError is 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

  1. Fork it (https://github.com/MatheusBasso99/crystal-ffmpeg/fork)
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

Contributors

Repository

crystal-ffmpeg

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 1
  • 1 day ago
  • July 19, 2026
License

MIT License

Links
Synced at

Mon, 20 Jul 2026 01:24:55 GMT

Languages