ffmpeg_core 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 06f190a0e07f87db2995e3683ee283b90e35f5c295260ced86ec5190308755c2
4
- data.tar.gz: 3e3eaac3bb2efc28349b6e36b91f3c94f878afcaa9d7e99938e67ebcffeb3ab8
3
+ metadata.gz: 026e0c17db47148e554c7b08f77a3e21a7b78b6f7d65915d3bf5159d057aae08
4
+ data.tar.gz: d0e49b402f4dff9aa73944f3a6e1543260d970c1114b4faccf9d3c51bb3da670
5
5
  SHA512:
6
- metadata.gz: a10ff3b06211407dd04bc8d1cd4d26d2fd7962a54e2c4f2bd8b7315759817e7e4ea057eaedfa718091739c877da05c37594c7fdf15457c288ac91c49c35a48d8
7
- data.tar.gz: 52e8c23c81746c10cf6b114570f6abad5f1d5c7bfab8e3e3feeabbd99af4edf68d9f9313e2e4179a789f5fc548ed2bf189c423ea5d2c8a88260f15dd3846e958
6
+ metadata.gz: 1d6eee4ad41d752b13ce1aab69be77ca24a5368b60db93b468feb12c2340d7f26dec285d09924503237a176b6bed299d4d9b1801e375fb6c5953f5c030e8f957
7
+ data.tar.gz: 987108002867b7d622aeb57b2606e59edae50a4762e76bb7287f52f97de836b740d2a65258df1e7f6baf8f1596e288d3bdad1b666e3b1b01974046212cfea3bf
data/CHANGELOG.md CHANGED
@@ -5,7 +5,33 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.3.0] - 2026-01-16
9
+
10
+ ### Added
11
+
12
+ - **Remote Input Support:** `Movie.new` now accepts HTTP, HTTPS, RTMP, and RTSP URLs.
13
+ - **Crop Support:** Added `crop` option to `transcode` (e.g., `crop: { width: 100, height: 100, x: 10, y: 10 }`).
14
+ - **Advanced Metadata:** Added `video_profile` and `video_level` to `Probe`.
15
+ - Rotation detection from `side_data_list` for better compatibility with newer video formats.
16
+
17
+ ### Fixed
18
+
19
+ - **Rotation Geometry:** `Probe#width` and `Probe#height` now correctly swap values if the video is rotated 90 or 270 degrees.
20
+
21
+ ## [0.2.0] - 2026-01-15
22
+
23
+ ### Added
24
+
25
+ - Real-time progress reporting in `Movie#transcode` via block yielding (0.0 to 1.0)
26
+ - Support for video and audio filters (`video_filter`, `audio_filter`)
27
+ - Support for quality settings (`preset`, `crf`)
28
+ - Enhanced metadata in `Probe`: added `rotation`, `aspect_ratio`, and `audio_streams`
29
+ - Comprehensive modular test suite for `Transcoder` and `Screenshot`
30
+
31
+ ### Changed
32
+
33
+ - `Transcoder` now uses `Open3.popen3` for non-blocking execution and progress parsing
34
+ - Improved RSpec testing style (using `have_received` spies)
9
35
 
10
36
  ## [0.1.1] - 2026-01-14
11
37
 
data/README.md CHANGED
@@ -2,10 +2,16 @@
2
2
 
3
3
  Modern Ruby wrapper for FFmpeg with clean API and proper error handling.
4
4
 
5
+ [![Gem Version](https://badge.fury.io/rb/ffmpeg_core.svg)](https://badge.fury.io/rb/ffmpeg_core)
6
+ [![Build Status](https://github.com/alec-c4/ffmpeg_core/actions/workflows/main.yml/badge.svg)](https://github.com/alec-c4/ffmpeg_core/actions)
7
+
5
8
  ## Features
6
9
 
7
10
  - Modern Ruby 3+ conventions
8
11
  - Zero runtime dependencies
12
+ - **Real-time progress reporting**
13
+ - **Support for video/audio filters and quality presets**
14
+ - **Remote input support (HTTP/HTTPS/RTMP/RTSP)**
9
15
  - Proper error handling with detailed context
10
16
  - Thread-safe configuration
11
17
  - Simple, intuitive API
@@ -36,17 +42,26 @@ bundle install
36
42
  ```ruby
37
43
  require "ffmpeg_core"
38
44
 
39
- # Load a video file
45
+ # Load a video file or remote URL
40
46
  movie = FFmpegCore::Movie.new("input.mp4")
47
+ # movie = FFmpegCore::Movie.new("http://example.com/video.mp4")
41
48
 
42
49
  # Get metadata
43
50
  movie.duration # => 120.5 (seconds)
44
- movie.resolution # => "1920x1080"
51
+ movie.resolution # => "1920x1080" (automatically swapped if rotated)
52
+ movie.width # => 1920
53
+ movie.height # => 1080
45
54
  movie.video_codec # => "h264"
46
55
  movie.audio_codec # => "aac"
47
56
  movie.frame_rate # => 29.97
48
57
  movie.bitrate # => 5000 (kb/s)
49
58
  movie.valid? # => true
59
+
60
+ # Access detailed metadata via probe
61
+ movie.probe.rotation # => 90 (degrees)
62
+ movie.probe.aspect_ratio # => "16:9"
63
+ movie.probe.video_profile # => "High"
64
+ movie.probe.video_level # => 41
50
65
  ```
51
66
 
52
67
  ### Transcoding
@@ -54,23 +69,58 @@ movie.valid? # => true
54
69
  ```ruby
55
70
  movie = FFmpegCore::Movie.new("input.mp4")
56
71
 
57
- # Basic transcoding
58
- movie.transcode("output.mp4", video_codec: "libx264")
72
+ # Basic transcoding with progress
73
+ movie.transcode("output.mp4", video_codec: "libx264") do |progress|
74
+ puts "Progress: #{(progress * 100).round(2)}%"
75
+ end
59
76
 
60
- # With options
77
+ # Advanced options (Filters & Quality)
61
78
  movie.transcode("output.mp4", {
62
79
  video_codec: "libx264",
63
80
  audio_codec: "aac",
64
81
  video_bitrate: "1000k",
65
82
  audio_bitrate: "128k",
66
83
  resolution: "1280x720",
67
- frame_rate: 30
84
+ crop: { width: 500, height: 500, x: 10, y: 10 }, # Crop video
85
+ video_filter: "scale=1280:-1,transpose=1", # Resize and rotate
86
+ audio_filter: "volume=0.5", # Reduce volume
87
+ preset: "slow", # ffmpeg preset (ultrafast, fast, medium, slow, etc.)
88
+ crf: 23, # Constant Rate Factor (0-51)
89
+ custom: %w[-map 0:v -map 0:a] # Custom FFmpeg flags
68
90
  })
91
+ ```
92
+
93
+ ### Using Filters
69
94
 
70
- # Custom FFmpeg flags
95
+ FFmpegCore supports raw FFmpeg filter strings for both video (`video_filter` or `-vf`) and audio (`audio_filter` or `-af`).
96
+
97
+ **Common Video Filters:**
98
+
99
+ ```ruby
71
100
  movie.transcode("output.mp4", {
72
- video_codec: "libx264",
73
- custom: ["-preset", "fast", "-crf", "23"]
101
+ # Scale to width 1280, keep aspect ratio
102
+ video_filter: "scale=1280:-1",
103
+
104
+ # Crop 100x100 starting at position (10,10)
105
+ video_filter: "crop=100:100:10:10",
106
+
107
+ # Rotate 90 degrees clockwise
108
+ video_filter: "transpose=1",
109
+
110
+ # Chain multiple filters (Scale then Rotate)
111
+ video_filter: "scale=1280:-1,transpose=1"
112
+ })
113
+ ```
114
+
115
+ **Common Audio Filters:**
116
+
117
+ ```ruby
118
+ movie.transcode("output.mp4", {
119
+ # Increase volume by 50%
120
+ audio_filter: "volume=1.5",
121
+
122
+ # Fade in first 5 seconds
123
+ audio_filter: "afade=t=in:ss=0:d=5"
74
124
  })
75
125
  ```
76
126
 
@@ -82,7 +132,7 @@ movie = FFmpegCore::Movie.new("input.mp4")
82
132
  # Extract screenshot at specific time
83
133
  movie.screenshot("thumbnail.jpg", seek_time: 5)
84
134
 
85
- # With resolution
135
+ # With resolution and quality
86
136
  movie.screenshot("thumbnail.jpg", {
87
137
  seek_time: 10,
88
138
  resolution: "640x360",
@@ -102,7 +152,7 @@ end
102
152
 
103
153
  ## Error Handling
104
154
 
105
- FFmpegCore provides specific error classes for different failure scenarios:
155
+ FFmpegCore provides specific error classes for different failure scenarios. All execution errors (transcoding, probing, screenshots) inherit from `FFmpegCore::ExecutionError`, which provides access to the command, exit status, and stderr output.
106
156
 
107
157
  ```ruby
108
158
  begin
@@ -111,9 +161,9 @@ begin
111
161
  rescue FFmpegCore::InvalidInputError => e
112
162
  # File doesn't exist or is not readable
113
163
  puts "Input error: #{e.message}"
114
- rescue FFmpegCore::TranscodingError => e
115
- # FFmpeg transcoding failed
116
- puts "Transcoding failed: #{e.message}"
164
+ rescue FFmpegCore::ExecutionError => e
165
+ # Covers TranscodingError, ProbeError, and ScreenshotError
166
+ puts "Execution failed: #{e.message}"
117
167
  puts "Command: #{e.command}"
118
168
  puts "Exit status: #{e.exit_status}"
119
169
  puts "Stderr: #{e.stderr}"
@@ -125,14 +175,16 @@ end
125
175
 
126
176
  ### Error Classes
127
177
 
128
- | Error | Description |
129
- |-------|-------------|
130
- | `FFmpegCore::Error` | Base error class |
131
- | `FFmpegCore::BinaryNotFoundError` | FFmpeg/FFprobe not found |
132
- | `FFmpegCore::InvalidInputError` | Input file doesn't exist or unreadable |
133
- | `FFmpegCore::ProbeError` | Failed to extract metadata |
134
- | `FFmpegCore::TranscodingError` | FFmpeg transcoding failed |
135
- | `FFmpegCore::ScreenshotError` | Screenshot extraction failed |
178
+ | Error | Description | Parent |
179
+ | --------------------------------- | -------------------------------------- | ------ |
180
+ | `FFmpegCore::Error` | Base error class | StandardError |
181
+ | `FFmpegCore::BinaryNotFoundError` | FFmpeg/FFprobe not found | Error |
182
+ | `FFmpegCore::InvalidInputError` | Input file doesn't exist or unreadable | Error |
183
+ | `FFmpegCore::OutputError` | Output file cannot be written | Error |
184
+ | `FFmpegCore::ExecutionError` | Base for command execution errors | Error |
185
+ | `FFmpegCore::ProbeError` | Failed to extract metadata | ExecutionError |
186
+ | `FFmpegCore::TranscodingError` | FFmpeg transcoding failed | ExecutionError |
187
+ | `FFmpegCore::ScreenshotError` | Screenshot extraction failed | ExecutionError |
136
188
 
137
189
  ## Development
138
190
 
@@ -27,10 +27,14 @@ module FFmpegCore
27
27
  # @option options [String] :resolution Resolution (e.g., "1280x720")
28
28
  # @option options [Integer, Float] :frame_rate Frame rate (e.g., 30)
29
29
  # @option options [Array<String>] :custom Custom FFmpeg flags
30
+ # @yield [Float] Progress ratio (0.0 to 1.0)
30
31
  # @return [String] Path to transcoded file
31
- def transcode(output_path, options = {})
32
+ def transcode(output_path, options = {}, &block)
33
+ # Inject duration for progress calculation if known
34
+ options[:duration] ||= duration
35
+
32
36
  transcoder = Transcoder.new(path, output_path, options)
33
- transcoder.run
37
+ transcoder.run(&block)
34
38
  end
35
39
 
36
40
  # Extract screenshot from video
@@ -39,16 +39,32 @@ module FFmpegCore
39
39
  video_stream&.dig("codec_name")
40
40
  end
41
41
 
42
+ def video_profile
43
+ video_stream&.dig("profile")
44
+ end
45
+
46
+ def video_level
47
+ video_stream&.dig("level")
48
+ end
49
+
42
50
  def audio_codec
43
51
  audio_stream&.dig("codec_name")
44
52
  end
45
53
 
46
54
  def width
47
- video_stream&.dig("width")
55
+ val = video_stream&.dig("width")
56
+ return val unless val
57
+ return val if (rotation || 0) % 180 == 0
58
+
59
+ video_stream&.dig("height")
48
60
  end
49
61
 
50
62
  def height
51
- video_stream&.dig("height")
63
+ val = video_stream&.dig("height")
64
+ return val unless val
65
+ return val if (rotation || 0) % 180 == 0
66
+
67
+ video_stream&.dig("width")
52
68
  end
53
69
 
54
70
  def frame_rate
@@ -68,9 +84,33 @@ module FFmpegCore
68
84
 
69
85
  def resolution
70
86
  return nil unless width && height
87
+
71
88
  "#{width}x#{height}"
72
89
  end
73
90
 
91
+ def rotation
92
+ return nil unless video_stream
93
+
94
+ # Try to find rotation in tags (common in MP4/MOV)
95
+ tags = video_stream.fetch("tags", {})
96
+ return tags["rotate"].to_i if tags["rotate"]
97
+
98
+ # Try side_data_list (common in some newer formats)
99
+ side_data = video_stream.fetch("side_data_list", []).find { |sd| sd.key?("rotation") }
100
+ return side_data["rotation"].to_i if side_data
101
+
102
+ # Default to 0 if not found
103
+ 0
104
+ end
105
+
106
+ def aspect_ratio
107
+ video_stream&.dig("display_aspect_ratio")
108
+ end
109
+
110
+ def audio_streams
111
+ streams.select { |s| s["codec_type"] == "audio" }
112
+ end
113
+
74
114
  def valid?
75
115
  !video_stream.nil?
76
116
  end
@@ -82,6 +122,8 @@ module FFmpegCore
82
122
  end
83
123
 
84
124
  def validate_file!
125
+ return if path =~ %r{^(https?|rtmp|rtsp)://}
126
+
85
127
  raise InvalidInputError, "File does not exist: #{path}" unless File.exist?(path)
86
128
  raise InvalidInputError, "File is not readable: #{path}" unless File.readable?(path)
87
129
  end
@@ -36,9 +36,7 @@ module FFmpegCore
36
36
  cmd = [FFmpegCore.configuration.ffmpeg_binary]
37
37
 
38
38
  # Seek to timestamp (before input for faster processing)
39
- if options[:seek_time]
40
- cmd += ["-ss", options[:seek_time].to_s]
41
- end
39
+ cmd += ["-ss", options[:seek_time].to_s] if options[:seek_time]
42
40
 
43
41
  # Input file
44
42
  cmd += ["-i", input_path]
@@ -47,9 +45,7 @@ module FFmpegCore
47
45
  cmd += ["-vframes", "1"]
48
46
 
49
47
  # Resolution
50
- if options[:resolution]
51
- cmd += ["-s", options[:resolution]]
52
- end
48
+ cmd += ["-s", options[:resolution]] if options[:resolution]
53
49
 
54
50
  # Quality (2-31, lower is better, default: 2)
55
51
  quality = options[:quality] || 2
@@ -14,17 +14,19 @@ module FFmpegCore
14
14
  @options = options
15
15
  end
16
16
 
17
- def run
17
+ def run(&block)
18
18
  validate_input!
19
19
  ensure_output_directory!
20
20
 
21
21
  command = build_command
22
- execute_command(command)
22
+ execute_command(command, &block)
23
23
  end
24
24
 
25
25
  private
26
26
 
27
27
  def validate_input!
28
+ return if input_path =~ %r{^(https?|rtmp|rtsp)://}
29
+
28
30
  raise InvalidInputError, "Input file does not exist: #{input_path}" unless File.exist?(input_path)
29
31
  end
30
32
 
@@ -40,39 +42,45 @@ module FFmpegCore
40
42
  cmd += ["-i", input_path]
41
43
 
42
44
  # Video codec
43
- if options[:video_codec]
44
- cmd += ["-c:v", options[:video_codec]]
45
- end
45
+ cmd += ["-c:v", options[:video_codec]] if options[:video_codec]
46
46
 
47
47
  # Audio codec
48
- if options[:audio_codec]
49
- cmd += ["-c:a", options[:audio_codec]]
50
- end
48
+ cmd += ["-c:a", options[:audio_codec]] if options[:audio_codec]
51
49
 
52
50
  # Video bitrate
53
- if options[:video_bitrate]
54
- cmd += ["-b:v", normalize_bitrate(options[:video_bitrate])]
55
- end
51
+ cmd += ["-b:v", normalize_bitrate(options[:video_bitrate])] if options[:video_bitrate]
56
52
 
57
53
  # Audio bitrate
58
- if options[:audio_bitrate]
59
- cmd += ["-b:a", normalize_bitrate(options[:audio_bitrate])]
60
- end
54
+ cmd += ["-b:a", normalize_bitrate(options[:audio_bitrate])] if options[:audio_bitrate]
61
55
 
62
56
  # Resolution
63
- if options[:resolution]
64
- cmd += ["-s", options[:resolution]]
65
- end
57
+ cmd += ["-s", options[:resolution]] if options[:resolution]
66
58
 
67
59
  # Frame rate
68
- if options[:frame_rate]
69
- cmd += ["-r", options[:frame_rate].to_s]
60
+ cmd += ["-r", options[:frame_rate].to_s] if options[:frame_rate]
61
+
62
+ # Video filters
63
+ video_filters = []
64
+ video_filters << options[:video_filter] if options[:video_filter]
65
+
66
+ if options[:crop]
67
+ crop = options[:crop]
68
+ video_filters << "crop=#{crop[:width]}:#{crop[:height]}:#{crop[:x]}:#{crop[:y]}"
70
69
  end
70
+
71
+ cmd += ["-vf", video_filters.join(",")] unless video_filters.empty?
72
+
73
+ # Audio filter
74
+ cmd += ["-af", options[:audio_filter]] if options[:audio_filter]
75
+
76
+ # Quality preset
77
+ cmd += ["-preset", options[:preset]] if options[:preset]
78
+
79
+ # Constant Rate Factor (CRF)
80
+ cmd += ["-crf", options[:crf].to_s] if options[:crf]
71
81
 
72
82
  # Custom options (array of strings)
73
- if options[:custom]
74
- cmd += options[:custom]
75
- end
83
+ cmd += options[:custom] if options[:custom]
76
84
 
77
85
  # Overwrite output file
78
86
  cmd += ["-y"]
@@ -89,23 +97,55 @@ module FFmpegCore
89
97
  # 1000 -> "1000k"
90
98
  # "1M" -> "1M"
91
99
  return bitrate.to_s if bitrate.to_s.match?(/\d+[kKmM]/)
100
+
92
101
  "#{bitrate}k"
93
102
  end
94
103
 
95
- def execute_command(command)
96
- stdout, stderr, status = Open3.capture3(*command)
97
-
98
- unless status.success?
99
- raise TranscodingError.new(
100
- "FFmpeg transcoding failed",
101
- command: command.join(" "),
102
- exit_status: status.exitstatus,
103
- stdout: stdout,
104
- stderr: stderr
105
- )
104
+ def execute_command(command, &block)
105
+ # Keep track of last few lines for error reporting
106
+ error_log = []
107
+
108
+ Open3.popen3(*command) do |_stdin, _stdout, stderr, wait_thr|
109
+ # FFmpeg uses \r to update progress line, so we split by \r
110
+ # We also handle \n just in case
111
+ stderr.each_line("\r") do |line|
112
+ clean_line = line.strip
113
+ next if clean_line.empty?
114
+
115
+ error_log << clean_line
116
+ error_log.shift if error_log.size > 20 # Keep last 20 lines
117
+
118
+ parse_progress(clean_line, &block) if block_given?
119
+ end
120
+
121
+ status = wait_thr.value
122
+
123
+ unless status.success?
124
+ raise TranscodingError.new(
125
+ "FFmpeg transcoding failed",
126
+ command: command.join(" "),
127
+ exit_status: status.exitstatus,
128
+ stdout: "",
129
+ stderr: error_log.join("\n")
130
+ )
131
+ end
106
132
  end
107
133
 
108
134
  output_path
109
135
  end
136
+
137
+ def parse_progress(line)
138
+ # Match time=HH:MM:SS.ms
139
+ if line =~ /time=(\d{2}):(\d{2}):(\d{2}\.\d{2})/
140
+ hours, minutes, seconds = $1.to_i, $2.to_i, $3.to_f
141
+ current_time = (hours * 3600) + (minutes * 60) + seconds
142
+
143
+ duration = options[:duration]
144
+ if duration && duration.to_f > 0
145
+ progress = [current_time / duration.to_f, 1.0].min
146
+ yield(progress)
147
+ end
148
+ end
149
+ end
110
150
  end
111
151
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FFmpegCore
4
- VERSION = "0.1.1"
5
- end
4
+ VERSION = "0.3.0"
5
+ end
data/sig/ffmpeg_core.rbs CHANGED
@@ -1,4 +1,4 @@
1
- module FfmpegCore
1
+ module FFmpegCore
2
2
  VERSION: String
3
3
  # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
4
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ffmpeg_core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexey Poimtsev
@@ -21,7 +21,6 @@ files:
21
21
  - LICENSE.txt
22
22
  - README.md
23
23
  - Rakefile
24
- - ffmpeg_core-0.1.0.gem
25
24
  - lib/ffmpeg_core.rb
26
25
  - lib/ffmpeg_core/configuration.rb
27
26
  - lib/ffmpeg_core/errors.rb
Binary file