ffmpeg_core 0.4.1 → 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 +40 -0
- data/README.md +126 -8
- data/lib/ffmpeg_core/audio_extractor.rb +61 -0
- data/lib/ffmpeg_core/clipper.rb +69 -0
- data/lib/ffmpeg_core/compositor.rb +142 -0
- data/lib/ffmpeg_core/movie.rb +42 -0
- data/lib/ffmpeg_core/probe.rb +48 -0
- data/lib/ffmpeg_core/transcoder.rb +12 -2
- data/lib/ffmpeg_core/version.rb +1 -1
- data/lib/ffmpeg_core.rb +3 -0
- metadata +5 -2
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,46 @@ 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
|
+
|
|
21
|
+
## [0.5.0] - 2026-04-13
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **Probe — extended metadata:**
|
|
26
|
+
- `subtitle_streams` — array of subtitle streams
|
|
27
|
+
- `chapters` — file chapters (requires ffprobe `-show_chapters`)
|
|
28
|
+
- `format_name` — container format name (mp4, mkv, avi…)
|
|
29
|
+
- `tags` — file-level tags (title, artist, etc.)
|
|
30
|
+
- `audio_sample_rate` — sample rate of the first audio stream
|
|
31
|
+
- `audio_channels` — channel count
|
|
32
|
+
- `audio_channel_layout` — channel layout string (stereo, 5.1…)
|
|
33
|
+
- `pixel_format` — video pixel format (yuv420p, etc.)
|
|
34
|
+
- `has_video?` / `has_audio?` — stream presence predicates
|
|
35
|
+
- `exif` — EXIF tags merged from format and video stream tags (FFmpeg 8.1)
|
|
36
|
+
- **Movie — new operations:**
|
|
37
|
+
- `cut(output, start_time:, duration:)` / `cut(output, start_time:, end_time:)` — lossless trim via `-c copy`
|
|
38
|
+
- `extract_audio(output, codec:)` — extract audio track to file
|
|
39
|
+
- `screenshots(output_dir, count:)` — extract multiple screenshots at equal intervals
|
|
40
|
+
- **Hardware acceleration — AV1 and D3D12 support (FFmpeg 8.0/8.1):**
|
|
41
|
+
- AV1 via `:nvenc` (`av1_nvenc`), `:vaapi` (`av1_vaapi`), `:vulkan` (`av1_vulkan`)
|
|
42
|
+
- D3D12 via `:d3d12` (`h264_d3d12va`) for Windows
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- `Probe#probe!` now passes `-show_chapters` to ffprobe
|
|
47
|
+
|
|
8
48
|
## [0.4.1] - 2026-04-09
|
|
9
49
|
|
|
10
50
|
### Fixed
|
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,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module FFmpegCore
|
|
6
|
+
# Extract audio track from video files
|
|
7
|
+
class AudioExtractor
|
|
8
|
+
attr_reader :input_path, :output_path, :options
|
|
9
|
+
|
|
10
|
+
def initialize(input_path, output_path, options = {})
|
|
11
|
+
@input_path = input_path.to_s
|
|
12
|
+
@output_path = output_path.to_s
|
|
13
|
+
@options = options
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
validate_input!
|
|
18
|
+
ensure_output_directory!
|
|
19
|
+
|
|
20
|
+
command = build_command
|
|
21
|
+
execute_command(command)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def validate_input!
|
|
27
|
+
return if %r{^(https?|rtmp|rtsp)://}.match?(input_path)
|
|
28
|
+
|
|
29
|
+
raise InvalidInputError, "Input file does not exist: #{input_path}" unless File.exist?(input_path)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def ensure_output_directory!
|
|
33
|
+
output_dir = File.dirname(output_path)
|
|
34
|
+
FileUtils.mkdir_p(output_dir) unless Dir.exist?(output_dir)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def build_command
|
|
38
|
+
cmd = [FFmpegCore.configuration.ffmpeg_binary, "-i", input_path]
|
|
39
|
+
cmd += ["-vn"]
|
|
40
|
+
cmd += ["-c:a", options[:codec]] if options[:codec]
|
|
41
|
+
cmd += ["-y", output_path]
|
|
42
|
+
cmd
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def execute_command(command)
|
|
46
|
+
_stdout, stderr, status = Open3.capture3(*command)
|
|
47
|
+
|
|
48
|
+
unless status.success?
|
|
49
|
+
raise TranscodingError.new(
|
|
50
|
+
"FFmpeg audio extraction failed",
|
|
51
|
+
command: command.join(" "),
|
|
52
|
+
exit_status: status.exitstatus,
|
|
53
|
+
stdout: "",
|
|
54
|
+
stderr: stderr
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
output_path
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module FFmpegCore
|
|
6
|
+
# Cut/trim video segments
|
|
7
|
+
class Clipper
|
|
8
|
+
attr_reader :input_path, :output_path, :options
|
|
9
|
+
|
|
10
|
+
def initialize(input_path, output_path, options = {})
|
|
11
|
+
@input_path = input_path.to_s
|
|
12
|
+
@output_path = output_path.to_s
|
|
13
|
+
@options = options
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
validate_input!
|
|
18
|
+
ensure_output_directory!
|
|
19
|
+
|
|
20
|
+
command = build_command
|
|
21
|
+
execute_command(command)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def validate_input!
|
|
27
|
+
return if %r{^(https?|rtmp|rtsp)://}.match?(input_path)
|
|
28
|
+
|
|
29
|
+
raise InvalidInputError, "Input file does not exist: #{input_path}" unless File.exist?(input_path)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def ensure_output_directory!
|
|
33
|
+
output_dir = File.dirname(output_path)
|
|
34
|
+
FileUtils.mkdir_p(output_dir) unless Dir.exist?(output_dir)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def build_command
|
|
38
|
+
cmd = [FFmpegCore.configuration.ffmpeg_binary]
|
|
39
|
+
|
|
40
|
+
cmd += ["-ss", options[:start_time].to_s] if options[:start_time]
|
|
41
|
+
cmd += ["-i", input_path]
|
|
42
|
+
|
|
43
|
+
if options[:duration]
|
|
44
|
+
cmd += ["-t", options[:duration].to_s]
|
|
45
|
+
elsif options[:end_time]
|
|
46
|
+
cmd += ["-to", options[:end_time].to_s]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
cmd += ["-c", "copy", "-y", output_path]
|
|
50
|
+
cmd
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def execute_command(command)
|
|
54
|
+
_stdout, stderr, status = Open3.capture3(*command)
|
|
55
|
+
|
|
56
|
+
unless status.success?
|
|
57
|
+
raise TranscodingError.new(
|
|
58
|
+
"FFmpeg cut failed",
|
|
59
|
+
command: command.join(" "),
|
|
60
|
+
exit_status: status.exitstatus,
|
|
61
|
+
stdout: "",
|
|
62
|
+
stderr: stderr
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
output_path
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -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/movie.rb
CHANGED
|
@@ -52,5 +52,47 @@ module FFmpegCore
|
|
|
52
52
|
screenshotter = Screenshot.new(path, output_path, options)
|
|
53
53
|
screenshotter.extract
|
|
54
54
|
end
|
|
55
|
+
|
|
56
|
+
# Cut/trim a segment from video
|
|
57
|
+
#
|
|
58
|
+
# @param output_path [String] Path to output file
|
|
59
|
+
# @param options [Hash] Cut options
|
|
60
|
+
# @option options [Integer, Float] :start_time Start time in seconds
|
|
61
|
+
# @option options [Integer, Float] :duration Duration in seconds
|
|
62
|
+
# @option options [Integer, Float] :end_time End time in seconds (alternative to :duration)
|
|
63
|
+
# @return [String] Path to output file
|
|
64
|
+
def cut(output_path, options = {})
|
|
65
|
+
clipper = Clipper.new(path, output_path, options)
|
|
66
|
+
clipper.run
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Extract audio track from video
|
|
70
|
+
#
|
|
71
|
+
# @param output_path [String] Path to output audio file
|
|
72
|
+
# @param options [Hash] Extraction options
|
|
73
|
+
# @option options [String] :codec Audio codec (e.g., "libmp3lame", "aac")
|
|
74
|
+
# @return [String] Path to output file
|
|
75
|
+
def extract_audio(output_path, options = {})
|
|
76
|
+
extractor = AudioExtractor.new(path, output_path, options)
|
|
77
|
+
extractor.run
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Extract multiple screenshots at equal intervals
|
|
81
|
+
#
|
|
82
|
+
# @param output_dir [String] Directory to save screenshots
|
|
83
|
+
# @param count [Integer] Number of screenshots to extract (default: 5)
|
|
84
|
+
# @return [Array<String>] Paths to screenshot files
|
|
85
|
+
def screenshots(output_dir, count: 5)
|
|
86
|
+
FileUtils.mkdir_p(output_dir)
|
|
87
|
+
total = duration || 0
|
|
88
|
+
interval = total / (count + 1).to_f
|
|
89
|
+
|
|
90
|
+
(1..count).map do |i|
|
|
91
|
+
seek = (interval * i).round(2)
|
|
92
|
+
output_path = File.join(output_dir, format("screenshot_%03d.jpg", i))
|
|
93
|
+
Screenshot.new(path, output_path, seek_time: seek).extract
|
|
94
|
+
output_path
|
|
95
|
+
end
|
|
96
|
+
end
|
|
55
97
|
end
|
|
56
98
|
end
|
data/lib/ffmpeg_core/probe.rb
CHANGED
|
@@ -111,6 +111,53 @@ module FFmpegCore
|
|
|
111
111
|
streams.select { |s| s["codec_type"] == "audio" }
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
+
def subtitle_streams
|
|
115
|
+
streams.select { |s| s["codec_type"] == "subtitle" }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def chapters
|
|
119
|
+
@metadata.fetch("chapters", [])
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def format_name
|
|
123
|
+
@metadata.dig("format", "format_name")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def tags
|
|
127
|
+
@metadata.dig("format", "tags") || {}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def audio_sample_rate
|
|
131
|
+
audio_stream&.dig("sample_rate")&.to_i
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def audio_channels
|
|
135
|
+
audio_stream&.dig("channels")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def audio_channel_layout
|
|
139
|
+
audio_stream&.dig("channel_layout")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def pixel_format
|
|
143
|
+
video_stream&.dig("pix_fmt")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def has_video?
|
|
147
|
+
!video_stream.nil?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def has_audio?
|
|
151
|
+
!audio_stream.nil?
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# EXIF metadata: merges format-level and video stream tags (FFmpeg 8.1+)
|
|
155
|
+
def exif
|
|
156
|
+
format_tags = @metadata.dig("format", "tags") || {}
|
|
157
|
+
stream_tags = video_stream&.dig("tags") || {}
|
|
158
|
+
format_tags.merge(stream_tags)
|
|
159
|
+
end
|
|
160
|
+
|
|
114
161
|
def valid?
|
|
115
162
|
!video_stream.nil?
|
|
116
163
|
end
|
|
@@ -135,6 +182,7 @@ module FFmpegCore
|
|
|
135
182
|
"-print_format", "json",
|
|
136
183
|
"-show_format",
|
|
137
184
|
"-show_streams",
|
|
185
|
+
"-show_chapters",
|
|
138
186
|
path
|
|
139
187
|
]
|
|
140
188
|
|
|
@@ -189,25 +189,35 @@ module FFmpegCore
|
|
|
189
189
|
:h264
|
|
190
190
|
when /x265|hevc/i
|
|
191
191
|
:hevc
|
|
192
|
+
when /av1|libaom/i
|
|
193
|
+
:av1
|
|
192
194
|
end
|
|
193
195
|
end
|
|
194
196
|
|
|
195
197
|
HW_FLAGS = {
|
|
196
198
|
vaapi: ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi"],
|
|
197
199
|
qsv: ["-hwaccel", "qsv"],
|
|
198
|
-
nvenc: ["-hwaccel", "cuda", "-hwaccel_output_format", "cuda"]
|
|
200
|
+
nvenc: ["-hwaccel", "cuda", "-hwaccel_output_format", "cuda"],
|
|
201
|
+
vulkan: ["-hwaccel", "vulkan"],
|
|
202
|
+
d3d12: ["-hwaccel", "d3d12va"]
|
|
199
203
|
}.freeze
|
|
200
204
|
|
|
201
205
|
HW_ENCODERS = {
|
|
202
206
|
h264: {
|
|
203
207
|
nvenc: "h264_nvenc",
|
|
204
208
|
vaapi: "h264_vaapi",
|
|
205
|
-
qsv: "h264_qsv"
|
|
209
|
+
qsv: "h264_qsv",
|
|
210
|
+
d3d12: "h264_d3d12va"
|
|
206
211
|
},
|
|
207
212
|
hevc: {
|
|
208
213
|
nvenc: "hevc_nvenc",
|
|
209
214
|
vaapi: "hevc_vaapi",
|
|
210
215
|
qsv: "hevc_qsv"
|
|
216
|
+
},
|
|
217
|
+
av1: {
|
|
218
|
+
nvenc: "av1_nvenc",
|
|
219
|
+
vaapi: "av1_vaapi",
|
|
220
|
+
vulkan: "av1_vulkan"
|
|
211
221
|
}
|
|
212
222
|
}.freeze
|
|
213
223
|
end
|
data/lib/ffmpeg_core/version.rb
CHANGED
data/lib/ffmpeg_core.rb
CHANGED
|
@@ -24,4 +24,7 @@ require_relative "ffmpeg_core/configuration"
|
|
|
24
24
|
require_relative "ffmpeg_core/probe"
|
|
25
25
|
require_relative "ffmpeg_core/transcoder"
|
|
26
26
|
require_relative "ffmpeg_core/screenshot"
|
|
27
|
+
require_relative "ffmpeg_core/clipper"
|
|
28
|
+
require_relative "ffmpeg_core/audio_extractor"
|
|
29
|
+
require_relative "ffmpeg_core/compositor"
|
|
27
30
|
require_relative "ffmpeg_core/movie"
|
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
|
|
@@ -37,6 +37,9 @@ files:
|
|
|
37
37
|
- Rakefile
|
|
38
38
|
- lefthook.yml
|
|
39
39
|
- lib/ffmpeg_core.rb
|
|
40
|
+
- lib/ffmpeg_core/audio_extractor.rb
|
|
41
|
+
- lib/ffmpeg_core/clipper.rb
|
|
42
|
+
- lib/ffmpeg_core/compositor.rb
|
|
40
43
|
- lib/ffmpeg_core/configuration.rb
|
|
41
44
|
- lib/ffmpeg_core/errors.rb
|
|
42
45
|
- lib/ffmpeg_core/movie.rb
|
|
@@ -67,7 +70,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
67
70
|
- !ruby/object:Gem::Version
|
|
68
71
|
version: '0'
|
|
69
72
|
requirements: []
|
|
70
|
-
rubygems_version: 4.0.
|
|
73
|
+
rubygems_version: 4.0.10
|
|
71
74
|
specification_version: 4
|
|
72
75
|
summary: Modern Ruby wrapper for FFmpeg
|
|
73
76
|
test_files: []
|