ffmpeg_core 0.5.0 → 0.6.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 +13 -0
- data/README.md +126 -8
- data/lib/ffmpeg_core/compositor.rb +142 -0
- data/lib/ffmpeg_core/version.rb +1 -1
- data/lib/ffmpeg_core.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 83185cde1635bd7362c737cba6da5c723be5407cd7457ea5bbac2e0b43206365
|
|
4
|
+
data.tar.gz: bf4dea2ee0d08d1e8af7b8193cef697f2b8dc1fcda8f5d86efed89e1d23c8f4e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fbe0cf4fd2b09c72c143aeeb1281889f7291e816d7602c6d12852503f647fbc71100e106cf41c33c5e76b9728258d3ecc547b1407632748b6512e92086376265
|
|
7
|
+
data.tar.gz: 056ea62e71626ac7969cee7242fb14936b15aa9098311b631c36f14c87c61289f6da48a41e99e7745fb7032965a3550d9f1c59e56969b43bba6af33f4b25977f
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ 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
|
+
## [0.6.0] - 2026-04-14
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Compositor — multi-input video composition:**
|
|
13
|
+
- New `FFmpegCore::Compositor` class for operations requiring multiple input files in a single FFmpeg command
|
|
14
|
+
- Accepts an array of local paths or remote URLs as `input_paths`
|
|
15
|
+
- `:filter_complex` option (String or Array — array elements are joined with `;`)
|
|
16
|
+
- `:maps` option for stream mapping
|
|
17
|
+
- Progress reporting via block (requires `:duration`)
|
|
18
|
+
- Typical use cases: overlay (picture-in-picture), concatenation, side-by-side (`hstack`)
|
|
19
|
+
- README updated with usage examples
|
|
20
|
+
|
|
8
21
|
## [0.5.0] - 2026-04-13
|
|
9
22
|
|
|
10
23
|
### Added
|
data/README.md
CHANGED
|
@@ -11,12 +11,25 @@ Modern Ruby wrapper for FFmpeg with clean API and proper error handling.
|
|
|
11
11
|
- Zero runtime dependencies
|
|
12
12
|
- **Real-time progress reporting**
|
|
13
13
|
- **Support for video/audio filters and quality presets**
|
|
14
|
-
- **Hardware Acceleration (NVENC, VAAPI, QSV)**
|
|
14
|
+
- **Hardware Acceleration (NVENC, VAAPI, QSV, Vulkan AV1, D3D12)**
|
|
15
15
|
- **Remote input support (HTTP/HTTPS/RTMP/RTSP)**
|
|
16
|
+
- **Rich metadata: chapters, subtitles, EXIF, audio properties**
|
|
17
|
+
- **Video operations: cut, audio extraction, batch screenshots**
|
|
18
|
+
- **Multi-input composition: overlay, concatenation, side-by-side**
|
|
16
19
|
- Proper error handling with detailed context
|
|
17
20
|
- Thread-safe configuration
|
|
18
21
|
- Simple, intuitive API
|
|
19
22
|
|
|
23
|
+
## FFmpeg Version Requirements
|
|
24
|
+
|
|
25
|
+
| Feature | Minimum FFmpeg version |
|
|
26
|
+
|---|---|
|
|
27
|
+
| Core transcoding, probing | Any recent version |
|
|
28
|
+
| AV1 hardware encoding (Vulkan) | 8.0+ |
|
|
29
|
+
| EXIF metadata parsing | 8.1+ |
|
|
30
|
+
| D3D12 hardware acceleration | 8.1+ |
|
|
31
|
+
| Chapter metadata (`chapters`) | Any (via `-show_chapters`) |
|
|
32
|
+
|
|
20
33
|
## Requirements
|
|
21
34
|
|
|
22
35
|
- Ruby 3.2+
|
|
@@ -47,7 +60,7 @@ require "ffmpeg_core"
|
|
|
47
60
|
movie = FFmpegCore::Movie.new("input.mp4")
|
|
48
61
|
# movie = FFmpegCore::Movie.new("http://example.com/video.mp4")
|
|
49
62
|
|
|
50
|
-
#
|
|
63
|
+
# Basic metadata
|
|
51
64
|
movie.duration # => 120.5 (seconds)
|
|
52
65
|
movie.resolution # => "1920x1080" (automatically swapped if rotated)
|
|
53
66
|
movie.width # => 1920
|
|
@@ -57,12 +70,32 @@ movie.audio_codec # => "aac"
|
|
|
57
70
|
movie.frame_rate # => 29.97
|
|
58
71
|
movie.bitrate # => 5000 (kb/s)
|
|
59
72
|
movie.valid? # => true
|
|
73
|
+
movie.has_video? # => true
|
|
74
|
+
movie.has_audio? # => true
|
|
60
75
|
|
|
61
|
-
#
|
|
76
|
+
# Video stream details
|
|
62
77
|
movie.probe.rotation # => 90 (degrees)
|
|
63
78
|
movie.probe.aspect_ratio # => "16:9"
|
|
64
79
|
movie.probe.video_profile # => "High"
|
|
65
80
|
movie.probe.video_level # => 41
|
|
81
|
+
movie.probe.pixel_format # => "yuv420p"
|
|
82
|
+
|
|
83
|
+
# Audio stream details
|
|
84
|
+
movie.probe.audio_sample_rate # => 48000
|
|
85
|
+
movie.probe.audio_channels # => 2
|
|
86
|
+
movie.probe.audio_channel_layout # => "stereo"
|
|
87
|
+
movie.probe.audio_streams # => [{ "codec_type" => "audio", ... }, ...]
|
|
88
|
+
|
|
89
|
+
# Subtitles and chapters
|
|
90
|
+
movie.probe.subtitle_streams # => [{ "codec_name" => "subrip", ... }]
|
|
91
|
+
movie.probe.chapters # => [{ "tags" => { "title" => "Intro" }, ... }]
|
|
92
|
+
|
|
93
|
+
# File-level tags and EXIF (FFmpeg 8.1+ for full EXIF support)
|
|
94
|
+
movie.probe.tags # => { "title" => "My Video", "artist" => "Author" }
|
|
95
|
+
movie.probe.exif # => { "creation_time" => "2024-06-15T14:30:00Z", ... }
|
|
96
|
+
|
|
97
|
+
# Container format
|
|
98
|
+
movie.probe.format_name # => "mov,mp4,m4a,3gp,3g2,mj2"
|
|
66
99
|
```
|
|
67
100
|
|
|
68
101
|
### Transcoding
|
|
@@ -104,18 +137,103 @@ movie.transcode("out.mp4", {
|
|
|
104
137
|
})
|
|
105
138
|
```
|
|
106
139
|
|
|
140
|
+
### Multi-Input Composition
|
|
141
|
+
|
|
142
|
+
Use `FFmpegCore::Compositor` when you need multiple input files in a single FFmpeg command — overlay, concatenation, side-by-side, and any other `-filter_complex` operation.
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
# Overlay (picture-in-picture): place overlay.mp4 on top of background.mp4
|
|
146
|
+
FFmpegCore::Compositor.new(
|
|
147
|
+
["background.mp4", "overlay.mp4"],
|
|
148
|
+
"output.mp4",
|
|
149
|
+
filter_complex: "[0:v][1:v]overlay=10:10[v]",
|
|
150
|
+
maps: ["[v]", "0:a"]
|
|
151
|
+
).run
|
|
152
|
+
|
|
153
|
+
# Concatenate two clips sequentially
|
|
154
|
+
FFmpegCore::Compositor.new(
|
|
155
|
+
["part1.mp4", "part2.mp4"],
|
|
156
|
+
"output.mp4",
|
|
157
|
+
filter_complex: "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1[v][a]",
|
|
158
|
+
maps: ["[v]", "[a]"]
|
|
159
|
+
).run
|
|
160
|
+
|
|
161
|
+
# Side-by-side (horizontal stack)
|
|
162
|
+
FFmpegCore::Compositor.new(
|
|
163
|
+
["left.mp4", "right.mp4"],
|
|
164
|
+
"output.mp4",
|
|
165
|
+
filter_complex: "[0:v][1:v]hstack[v]",
|
|
166
|
+
maps: ["[v]", "0:a"]
|
|
167
|
+
).run
|
|
168
|
+
|
|
169
|
+
# With progress reporting (requires :duration)
|
|
170
|
+
FFmpegCore::Compositor.new(
|
|
171
|
+
["a.mp4", "b.mp4"],
|
|
172
|
+
"output.mp4",
|
|
173
|
+
filter_complex: "[0:v][1:v]overlay[v]",
|
|
174
|
+
maps: ["[v]", "0:a"],
|
|
175
|
+
duration: 120.0
|
|
176
|
+
).run do |progress|
|
|
177
|
+
puts "#{(progress * 100).round}%"
|
|
178
|
+
end
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
> **Note:** Stream indices (`[0:v]`, `[1:v]`, etc.) correspond to the position of each file in the input array.
|
|
182
|
+
|
|
183
|
+
### Cutting / Trimming
|
|
184
|
+
|
|
185
|
+
Lossless trim using stream copy — no re-encoding, nearly instant:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
# Trim by start time + duration
|
|
189
|
+
movie.cut("clip.mp4", start_time: 30, duration: 60)
|
|
190
|
+
|
|
191
|
+
# Trim by start and end time
|
|
192
|
+
movie.cut("clip.mp4", start_time: 30, end_time: 90)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
> **Note:** `-c copy` seeks to the nearest keyframe. For frame-accurate trimming, use `transcode` with `custom: ["-ss", "30", "-to", "90"]`.
|
|
196
|
+
|
|
197
|
+
### Audio Extraction
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
# Extract audio with automatic codec detection from file extension
|
|
201
|
+
movie.extract_audio("audio.aac")
|
|
202
|
+
|
|
203
|
+
# Specify codec explicitly
|
|
204
|
+
movie.extract_audio("audio.mp3", codec: "libmp3lame")
|
|
205
|
+
movie.extract_audio("audio.opus", codec: "libopus")
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Multiple Screenshots
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
# Extract 5 screenshots distributed evenly across the video
|
|
212
|
+
paths = movie.screenshots("thumbs/", count: 5)
|
|
213
|
+
# => ["thumbs/screenshot_001.jpg", ..., "thumbs/screenshot_005.jpg"]
|
|
214
|
+
```
|
|
215
|
+
|
|
107
216
|
### Hardware Acceleration
|
|
108
217
|
|
|
109
218
|
Opt-in to hardware-accelerated encoding with automatic encoder detection and graceful fallback.
|
|
110
219
|
|
|
111
220
|
```ruby
|
|
112
|
-
#
|
|
113
|
-
movie.transcode("out.mp4", hwaccel: :nvenc)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
221
|
+
# H.264 / HEVC — classic accelerators
|
|
222
|
+
movie.transcode("out.mp4", hwaccel: :nvenc) # NVIDIA CUDA
|
|
223
|
+
movie.transcode("out.mp4", hwaccel: :vaapi) # Linux VAAPI
|
|
224
|
+
movie.transcode("out.mp4", hwaccel: :qsv) # Intel Quick Sync
|
|
225
|
+
|
|
226
|
+
# AV1 — requires FFmpeg 8.0+
|
|
227
|
+
movie.transcode("out.mp4", video_codec: "libaom-av1", hwaccel: :nvenc) # NVIDIA
|
|
228
|
+
movie.transcode("out.mp4", video_codec: "libaom-av1", hwaccel: :vaapi) # VAAPI
|
|
229
|
+
movie.transcode("out.mp4", video_codec: "libaom-av1", hwaccel: :vulkan) # Vulkan compute
|
|
230
|
+
|
|
231
|
+
# D3D12 — Windows only, requires FFmpeg 8.1+
|
|
232
|
+
movie.transcode("out.mp4", hwaccel: :d3d12)
|
|
117
233
|
```
|
|
118
234
|
|
|
235
|
+
All accelerators gracefully fall back to software encoding if the hardware encoder is not available.
|
|
236
|
+
|
|
119
237
|
### Using Filters
|
|
120
238
|
|
|
121
239
|
FFmpegCore supports raw FFmpeg filter strings for both video (`video_filter` or `-vf`) and audio (`audio_filter` or `-af`).
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module FFmpegCore
|
|
6
|
+
# Execute FFmpeg operations with multiple input files
|
|
7
|
+
#
|
|
8
|
+
# @example Overlay (picture-in-picture)
|
|
9
|
+
# FFmpegCore::Compositor.new(
|
|
10
|
+
# ["background.mp4", "overlay.mp4"],
|
|
11
|
+
# "output.mp4",
|
|
12
|
+
# filter_complex: "[0:v][1:v]overlay=10:10[v]",
|
|
13
|
+
# maps: ["[v]", "0:a"]
|
|
14
|
+
# ).run
|
|
15
|
+
#
|
|
16
|
+
# @example Concatenate
|
|
17
|
+
# FFmpegCore::Compositor.new(
|
|
18
|
+
# ["part1.mp4", "part2.mp4"],
|
|
19
|
+
# "output.mp4",
|
|
20
|
+
# filter_complex: "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1[v][a]",
|
|
21
|
+
# maps: ["[v]", "[a]"]
|
|
22
|
+
# ).run
|
|
23
|
+
class Compositor
|
|
24
|
+
attr_reader :input_paths, :output_path, :options
|
|
25
|
+
|
|
26
|
+
# @param input_paths [Array<String>] Paths or URLs to input files (at least one required)
|
|
27
|
+
# @param output_path [String] Path to output file
|
|
28
|
+
# @param options [Hash] Compositing options
|
|
29
|
+
# @option options [String] :video_codec Video codec (e.g., "libx264")
|
|
30
|
+
# @option options [String] :audio_codec Audio codec (e.g., "aac")
|
|
31
|
+
# @option options [String, Integer] :video_bitrate Video bitrate (e.g., "1000k" or 1000)
|
|
32
|
+
# @option options [String, Integer] :audio_bitrate Audio bitrate (e.g., "128k" or 128)
|
|
33
|
+
# @option options [Array<String>, String] :filter_complex FFmpeg filter graph referencing input streams by index
|
|
34
|
+
# (e.g., "[0:v][1:v]overlay=0:0[v]"). Array elements are joined with semicolons.
|
|
35
|
+
# @option options [Array<String>, String] :maps Stream maps selecting outputs from the filter graph
|
|
36
|
+
# (e.g., ["[v]", "0:a"])
|
|
37
|
+
# @option options [Float] :duration Total duration in seconds, used for progress reporting
|
|
38
|
+
# @option options [Array<String>] :custom Raw FFmpeg flags appended verbatim (e.g., ["-shortest"])
|
|
39
|
+
# @raise [ArgumentError] if input_paths is empty
|
|
40
|
+
def initialize(input_paths, output_path, options = {})
|
|
41
|
+
raise ArgumentError, "At least one input path is required" if Array(input_paths).empty?
|
|
42
|
+
|
|
43
|
+
@input_paths = Array(input_paths).map(&:to_s)
|
|
44
|
+
@output_path = output_path.to_s
|
|
45
|
+
@options = options
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Run the FFmpeg compositing command
|
|
49
|
+
#
|
|
50
|
+
# @yield [Float] Progress ratio from 0.0 to 1.0 (requires :duration option)
|
|
51
|
+
# @return [String] Path to the output file
|
|
52
|
+
# @raise [InvalidInputError] if a local input file does not exist
|
|
53
|
+
# @raise [TranscodingError] if FFmpeg exits with a non-zero status
|
|
54
|
+
def run(&block)
|
|
55
|
+
validate_inputs!
|
|
56
|
+
ensure_output_directory!
|
|
57
|
+
|
|
58
|
+
command = build_command
|
|
59
|
+
execute_command(command, &block)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def validate_inputs!
|
|
65
|
+
input_paths.each do |path|
|
|
66
|
+
next if %r{^(https?|rtmp|rtsp)://}.match?(path)
|
|
67
|
+
|
|
68
|
+
raise InvalidInputError, "Input file does not exist: #{path}" unless File.exist?(path)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def ensure_output_directory!
|
|
73
|
+
output_dir = File.dirname(output_path)
|
|
74
|
+
FileUtils.mkdir_p(output_dir) unless Dir.exist?(output_dir)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def build_command
|
|
78
|
+
cmd = [FFmpegCore.configuration.ffmpeg_binary]
|
|
79
|
+
|
|
80
|
+
input_paths.each { |path| cmd += ["-i", path] }
|
|
81
|
+
|
|
82
|
+
cmd += ["-c:v", options[:video_codec]] if options[:video_codec]
|
|
83
|
+
cmd += ["-c:a", options[:audio_codec]] if options[:audio_codec]
|
|
84
|
+
cmd += ["-b:v", normalize_bitrate(options[:video_bitrate])] if options[:video_bitrate]
|
|
85
|
+
cmd += ["-b:a", normalize_bitrate(options[:audio_bitrate])] if options[:audio_bitrate]
|
|
86
|
+
|
|
87
|
+
filter = options[:filter_complex] || options[:filter_graph]
|
|
88
|
+
if filter
|
|
89
|
+
cmd += ["-filter_complex", filter.is_a?(Array) ? filter.join(";") : filter]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
maps = options[:maps] || options[:map]
|
|
93
|
+
Array(maps).each { |map| cmd += ["-map", map] } if maps
|
|
94
|
+
|
|
95
|
+
cmd += options[:custom] if options[:custom]
|
|
96
|
+
cmd += ["-y", output_path]
|
|
97
|
+
cmd
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def normalize_bitrate(bitrate)
|
|
101
|
+
return bitrate.to_s if bitrate.to_s.match?(/\d+[kKmM]/)
|
|
102
|
+
|
|
103
|
+
"#{bitrate}k"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def execute_command(command, &block)
|
|
107
|
+
error_log = []
|
|
108
|
+
|
|
109
|
+
Open3.popen3(*command) do |_stdin, _stdout, stderr, wait_thr|
|
|
110
|
+
stderr.each_line("\r") do |line|
|
|
111
|
+
clean_line = line.strip
|
|
112
|
+
next if clean_line.empty?
|
|
113
|
+
|
|
114
|
+
error_log << clean_line
|
|
115
|
+
error_log.shift if error_log.size > 20
|
|
116
|
+
parse_progress(line, &block) if block
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
status = wait_thr.value
|
|
120
|
+
unless status.success?
|
|
121
|
+
raise TranscodingError.new(
|
|
122
|
+
"FFmpeg compositing failed",
|
|
123
|
+
command: command.join(" "),
|
|
124
|
+
exit_status: status.exitstatus,
|
|
125
|
+
stdout: "",
|
|
126
|
+
stderr: error_log.join("\n")
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
output_path
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def parse_progress(line)
|
|
135
|
+
return unless line =~ /time=(\d{2}):(\d{2}):(\d{2}\.\d{2})/
|
|
136
|
+
|
|
137
|
+
current_time = $1.to_i * 3600 + $2.to_i * 60 + $3.to_f
|
|
138
|
+
duration = options[:duration]
|
|
139
|
+
yield([current_time / duration.to_f, 1.0].min) if duration && duration.to_f > 0
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
data/lib/ffmpeg_core/version.rb
CHANGED
data/lib/ffmpeg_core.rb
CHANGED
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.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alexey Poimtsev
|
|
@@ -39,6 +39,7 @@ files:
|
|
|
39
39
|
- lib/ffmpeg_core.rb
|
|
40
40
|
- lib/ffmpeg_core/audio_extractor.rb
|
|
41
41
|
- lib/ffmpeg_core/clipper.rb
|
|
42
|
+
- lib/ffmpeg_core/compositor.rb
|
|
42
43
|
- lib/ffmpeg_core/configuration.rb
|
|
43
44
|
- lib/ffmpeg_core/errors.rb
|
|
44
45
|
- lib/ffmpeg_core/movie.rb
|