ffmpeg_core 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +20 -1
- data/README.md +24 -20
- data/lib/ffmpeg_core/errors.rb +1 -1
- data/lib/ffmpeg_core/movie.rb +6 -2
- data/lib/ffmpeg_core/probe.rb +20 -0
- data/lib/ffmpeg_core/screenshot.rb +2 -6
- data/lib/ffmpeg_core/transcoder.rb +64 -34
- data/lib/ffmpeg_core/version.rb +1 -1
- data/sig/ffmpeg_core.rbs +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4efe5152c9dd01b8ebb2d34c9f6d6877371d017845242297f57f65044c968648
|
|
4
|
+
data.tar.gz: a1b09735ebc0acc9c21b252e3fa32b4168e0c5694eab8d72fa299e7c8c88dc9f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f3c4ace64795f63226082eb3eeb6e395c0f35976b86de25a3a8ed7c46973ce7d2b234de2485a6e3cd083060653da3f2d6a8f8c3a254260a15d5e472462097a21
|
|
7
|
+
data.tar.gz: 7b390fe28da3e17efe7a4523001aa41a0aa70685fc8e5d5951faf55e5d2feabe96e3aa5e48f94c0f3a0c70016a23b49c7b063739ae953bde41bba92b524e36b7
|
data/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,26 @@ 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
|
-
## [
|
|
8
|
+
## [0.2.0] - 2026-01-15
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Real-time progress reporting in `Movie#transcode` via block yielding (0.0 to 1.0)
|
|
13
|
+
- Support for video and audio filters (`video_filter`, `audio_filter`)
|
|
14
|
+
- Support for quality settings (`preset`, `crf`)
|
|
15
|
+
- Enhanced metadata in `Probe`: added `rotation`, `aspect_ratio`, and `audio_streams`
|
|
16
|
+
- Comprehensive modular test suite for `Transcoder` and `Screenshot`
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- `Transcoder` now uses `Open3.popen3` for non-blocking execution and progress parsing
|
|
21
|
+
- Improved RSpec testing style (using `have_received` spies)
|
|
22
|
+
|
|
23
|
+
## [0.1.1] - 2026-01-14
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- `ProbeError` now inherits from `ExecutionError` to properly accept keyword arguments (Ruby 4.0 compatibility)
|
|
9
28
|
|
|
10
29
|
## [0.1.0] - 2026-01-14
|
|
11
30
|
|
data/README.md
CHANGED
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Modern Ruby wrapper for FFmpeg with clean API and proper error handling.
|
|
4
4
|
|
|
5
|
+
[](https://badge.fury.io/rb/ffmpeg_core)
|
|
6
|
+
[](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**
|
|
9
14
|
- Proper error handling with detailed context
|
|
10
15
|
- Thread-safe configuration
|
|
11
16
|
- Simple, intuitive API
|
|
@@ -46,6 +51,8 @@ movie.video_codec # => "h264"
|
|
|
46
51
|
movie.audio_codec # => "aac"
|
|
47
52
|
movie.frame_rate # => 29.97
|
|
48
53
|
movie.bitrate # => 5000 (kb/s)
|
|
54
|
+
movie.rotation # => 90 (degrees)
|
|
55
|
+
movie.aspect_ratio # => "16:9"
|
|
49
56
|
movie.valid? # => true
|
|
50
57
|
```
|
|
51
58
|
|
|
@@ -54,23 +61,20 @@ movie.valid? # => true
|
|
|
54
61
|
```ruby
|
|
55
62
|
movie = FFmpegCore::Movie.new("input.mp4")
|
|
56
63
|
|
|
57
|
-
# Basic transcoding
|
|
58
|
-
movie.transcode("output.mp4", video_codec: "libx264")
|
|
64
|
+
# Basic transcoding with progress
|
|
65
|
+
movie.transcode("output.mp4", video_codec: "libx264") do |progress|
|
|
66
|
+
puts "Progress: #{(progress * 100).round(2)}%"
|
|
67
|
+
end
|
|
59
68
|
|
|
60
|
-
#
|
|
69
|
+
# Advanced options (Filters & Quality)
|
|
61
70
|
movie.transcode("output.mp4", {
|
|
62
71
|
video_codec: "libx264",
|
|
63
72
|
audio_codec: "aac",
|
|
64
73
|
video_bitrate: "1000k",
|
|
65
|
-
audio_bitrate: "128k",
|
|
66
74
|
resolution: "1280x720",
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
# Custom FFmpeg flags
|
|
71
|
-
movie.transcode("output.mp4", {
|
|
72
|
-
video_codec: "libx264",
|
|
73
|
-
custom: ["-preset", "fast", "-crf", "23"]
|
|
75
|
+
video_filter: "scale=1280:-1,transpose=1", # Resize and rotate
|
|
76
|
+
preset: "slow", # ffmpeg preset (ultrafast, fast, medium, slow, etc.)
|
|
77
|
+
crf: 23 # Constant Rate Factor (0-51)
|
|
74
78
|
})
|
|
75
79
|
```
|
|
76
80
|
|
|
@@ -125,14 +129,14 @@ end
|
|
|
125
129
|
|
|
126
130
|
### Error Classes
|
|
127
131
|
|
|
128
|
-
| Error
|
|
129
|
-
|
|
130
|
-
| `FFmpegCore::Error`
|
|
131
|
-
| `FFmpegCore::BinaryNotFoundError` | FFmpeg/FFprobe not found
|
|
132
|
-
| `FFmpegCore::InvalidInputError`
|
|
133
|
-
| `FFmpegCore::ProbeError`
|
|
134
|
-
| `FFmpegCore::TranscodingError`
|
|
135
|
-
| `FFmpegCore::ScreenshotError`
|
|
132
|
+
| Error | Description |
|
|
133
|
+
| --------------------------------- | -------------------------------------- |
|
|
134
|
+
| `FFmpegCore::Error` | Base error class |
|
|
135
|
+
| `FFmpegCore::BinaryNotFoundError` | FFmpeg/FFprobe not found |
|
|
136
|
+
| `FFmpegCore::InvalidInputError` | Input file doesn't exist or unreadable |
|
|
137
|
+
| `FFmpegCore::ProbeError` | Failed to extract metadata |
|
|
138
|
+
| `FFmpegCore::TranscodingError` | FFmpeg transcoding failed |
|
|
139
|
+
| `FFmpegCore::ScreenshotError` | Screenshot extraction failed |
|
|
136
140
|
|
|
137
141
|
## Development
|
|
138
142
|
|
|
@@ -149,4 +153,4 @@ bundle exec rubocop
|
|
|
149
153
|
|
|
150
154
|
## License
|
|
151
155
|
|
|
152
|
-
MIT License. See [LICENSE.txt](LICENSE.txt) for details.
|
|
156
|
+
MIT License. See [LICENSE.txt](LICENSE.txt) for details.
|
data/lib/ffmpeg_core/errors.rb
CHANGED
|
@@ -27,7 +27,7 @@ module FFmpegCore
|
|
|
27
27
|
class OutputError < Error; end
|
|
28
28
|
|
|
29
29
|
# Raised when probe fails to extract metadata
|
|
30
|
-
class ProbeError <
|
|
30
|
+
class ProbeError < ExecutionError; end
|
|
31
31
|
|
|
32
32
|
# Raised when transcoding fails
|
|
33
33
|
class TranscodingError < ExecutionError; end
|
data/lib/ffmpeg_core/movie.rb
CHANGED
|
@@ -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
|
data/lib/ffmpeg_core/probe.rb
CHANGED
|
@@ -68,9 +68,29 @@ module FFmpegCore
|
|
|
68
68
|
|
|
69
69
|
def resolution
|
|
70
70
|
return nil unless width && height
|
|
71
|
+
|
|
71
72
|
"#{width}x#{height}"
|
|
72
73
|
end
|
|
73
74
|
|
|
75
|
+
def rotation
|
|
76
|
+
return nil unless video_stream
|
|
77
|
+
|
|
78
|
+
# Try to find rotation in tags (common in MP4/MOV)
|
|
79
|
+
tags = video_stream.fetch("tags", {})
|
|
80
|
+
return tags["rotate"].to_i if tags["rotate"]
|
|
81
|
+
|
|
82
|
+
# Default to 0 if not found
|
|
83
|
+
0
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def aspect_ratio
|
|
87
|
+
video_stream&.dig("display_aspect_ratio")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def audio_streams
|
|
91
|
+
streams.select { |s| s["codec_type"] == "audio" }
|
|
92
|
+
end
|
|
93
|
+
|
|
74
94
|
def valid?
|
|
75
95
|
!video_stream.nil?
|
|
76
96
|
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,12 +14,12 @@ 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
|
|
@@ -40,39 +40,37 @@ module FFmpegCore
|
|
|
40
40
|
cmd += ["-i", input_path]
|
|
41
41
|
|
|
42
42
|
# Video codec
|
|
43
|
-
if options[:video_codec]
|
|
44
|
-
cmd += ["-c:v", options[:video_codec]]
|
|
45
|
-
end
|
|
43
|
+
cmd += ["-c:v", options[:video_codec]] if options[:video_codec]
|
|
46
44
|
|
|
47
45
|
# Audio codec
|
|
48
|
-
if options[:audio_codec]
|
|
49
|
-
cmd += ["-c:a", options[:audio_codec]]
|
|
50
|
-
end
|
|
46
|
+
cmd += ["-c:a", options[:audio_codec]] if options[:audio_codec]
|
|
51
47
|
|
|
52
48
|
# Video bitrate
|
|
53
|
-
if options[:video_bitrate]
|
|
54
|
-
cmd += ["-b:v", normalize_bitrate(options[:video_bitrate])]
|
|
55
|
-
end
|
|
49
|
+
cmd += ["-b:v", normalize_bitrate(options[:video_bitrate])] if options[:video_bitrate]
|
|
56
50
|
|
|
57
51
|
# Audio bitrate
|
|
58
|
-
if options[:audio_bitrate]
|
|
59
|
-
cmd += ["-b:a", normalize_bitrate(options[:audio_bitrate])]
|
|
60
|
-
end
|
|
52
|
+
cmd += ["-b:a", normalize_bitrate(options[:audio_bitrate])] if options[:audio_bitrate]
|
|
61
53
|
|
|
62
54
|
# Resolution
|
|
63
|
-
if options[:resolution]
|
|
64
|
-
cmd += ["-s", options[:resolution]]
|
|
65
|
-
end
|
|
55
|
+
cmd += ["-s", options[:resolution]] if options[:resolution]
|
|
66
56
|
|
|
67
57
|
# Frame rate
|
|
68
|
-
if options[:frame_rate]
|
|
69
|
-
|
|
70
|
-
|
|
58
|
+
cmd += ["-r", options[:frame_rate].to_s] if options[:frame_rate]
|
|
59
|
+
|
|
60
|
+
# Video filter
|
|
61
|
+
cmd += ["-vf", options[:video_filter]] if options[:video_filter]
|
|
62
|
+
|
|
63
|
+
# Audio filter
|
|
64
|
+
cmd += ["-af", options[:audio_filter]] if options[:audio_filter]
|
|
65
|
+
|
|
66
|
+
# Quality preset
|
|
67
|
+
cmd += ["-preset", options[:preset]] if options[:preset]
|
|
68
|
+
|
|
69
|
+
# Constant Rate Factor (CRF)
|
|
70
|
+
cmd += ["-crf", options[:crf].to_s] if options[:crf]
|
|
71
71
|
|
|
72
72
|
# Custom options (array of strings)
|
|
73
|
-
if options[:custom]
|
|
74
|
-
cmd += options[:custom]
|
|
75
|
-
end
|
|
73
|
+
cmd += options[:custom] if options[:custom]
|
|
76
74
|
|
|
77
75
|
# Overwrite output file
|
|
78
76
|
cmd += ["-y"]
|
|
@@ -89,23 +87,55 @@ module FFmpegCore
|
|
|
89
87
|
# 1000 -> "1000k"
|
|
90
88
|
# "1M" -> "1M"
|
|
91
89
|
return bitrate.to_s if bitrate.to_s.match?(/\d+[kKmM]/)
|
|
90
|
+
|
|
92
91
|
"#{bitrate}k"
|
|
93
92
|
end
|
|
94
93
|
|
|
95
|
-
def execute_command(command)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
94
|
+
def execute_command(command, &block)
|
|
95
|
+
# Keep track of last few lines for error reporting
|
|
96
|
+
error_log = []
|
|
97
|
+
|
|
98
|
+
Open3.popen3(*command) do |_stdin, _stdout, stderr, wait_thr|
|
|
99
|
+
# FFmpeg uses \r to update progress line, so we split by \r
|
|
100
|
+
# We also handle \n just in case
|
|
101
|
+
stderr.each_line("\r") do |line|
|
|
102
|
+
clean_line = line.strip
|
|
103
|
+
next if clean_line.empty?
|
|
104
|
+
|
|
105
|
+
error_log << clean_line
|
|
106
|
+
error_log.shift if error_log.size > 20 # Keep last 20 lines
|
|
107
|
+
|
|
108
|
+
parse_progress(clean_line, &block) if block_given?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
status = wait_thr.value
|
|
112
|
+
|
|
113
|
+
unless status.success?
|
|
114
|
+
raise TranscodingError.new(
|
|
115
|
+
"FFmpeg transcoding failed",
|
|
116
|
+
command: command.join(" "),
|
|
117
|
+
exit_status: status.exitstatus,
|
|
118
|
+
stdout: "",
|
|
119
|
+
stderr: error_log.join("\n")
|
|
120
|
+
)
|
|
121
|
+
end
|
|
106
122
|
end
|
|
107
123
|
|
|
108
124
|
output_path
|
|
109
125
|
end
|
|
126
|
+
|
|
127
|
+
def parse_progress(line)
|
|
128
|
+
# Match time=HH:MM:SS.ms
|
|
129
|
+
if line =~ /time=(\d{2}):(\d{2}):(\d{2}\.\d{2})/
|
|
130
|
+
hours, minutes, seconds = $1.to_i, $2.to_i, $3.to_f
|
|
131
|
+
current_time = (hours * 3600) + (minutes * 60) + seconds
|
|
132
|
+
|
|
133
|
+
duration = options[:duration]
|
|
134
|
+
if duration && duration.to_f > 0
|
|
135
|
+
progress = [current_time / duration.to_f, 1.0].min
|
|
136
|
+
yield(progress)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
110
140
|
end
|
|
111
141
|
end
|
data/lib/ffmpeg_core/version.rb
CHANGED
data/sig/ffmpeg_core.rbs
CHANGED