ffmpeg_core 0.1.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 +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +152 -0
- data/Rakefile +12 -0
- data/lib/ffmpeg_core/configuration.rb +63 -0
- data/lib/ffmpeg_core/errors.rb +37 -0
- data/lib/ffmpeg_core/movie.rb +49 -0
- data/lib/ffmpeg_core/probe.rb +116 -0
- data/lib/ffmpeg_core/screenshot.rb +83 -0
- data/lib/ffmpeg_core/transcoder.rb +111 -0
- data/lib/ffmpeg_core/version.rb +5 -0
- data/lib/ffmpeg_core.rb +27 -0
- data/sig/ffmpeg_core.rbs +4 -0
- metadata +58 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8605d887cd2bf7b03dc1cc1dc06f004b797a694467f5376c361e432fdc648e50
|
|
4
|
+
data.tar.gz: 6d3308730d6ca3eceb6de5aa90053c39bcbcb0438a0faf4bc4a2a5b035ba7c66
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4bd3cc9dcdbae4104798cc0c6bd5ceef7f151f8a07aad0c33c6793f5c37928762f0c81baf0ce9ff943a4af17594ae4c9675db92e695ca73fd23ae0f2d93cfc43
|
|
7
|
+
data.tar.gz: 60e15449132609d881dfe136d7fe7d8e6777089a396578d79cbd0034bcd142e032c94a818ffc78d819fb724c49b7870451cabe2644e0d573e820afaf9d8815ad
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-01-14
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `FFmpegCore::Movie` - main API for working with video files
|
|
15
|
+
- `FFmpegCore::Probe` - extract video metadata using ffprobe
|
|
16
|
+
- `FFmpegCore::Transcoder` - transcode videos with various options
|
|
17
|
+
- `FFmpegCore::Screenshot` - extract screenshots from videos
|
|
18
|
+
- `FFmpegCore::Configuration` - thread-safe global configuration
|
|
19
|
+
- Automatic ffmpeg/ffprobe binary detection
|
|
20
|
+
- Comprehensive error classes with detailed context
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexey Poimtsev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# FFmpegCore
|
|
2
|
+
|
|
3
|
+
Modern Ruby wrapper for FFmpeg with clean API and proper error handling.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Modern Ruby 3+ conventions
|
|
8
|
+
- Zero runtime dependencies
|
|
9
|
+
- Proper error handling with detailed context
|
|
10
|
+
- Thread-safe configuration
|
|
11
|
+
- Simple, intuitive API
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- Ruby 3.2+
|
|
16
|
+
- FFmpeg installed (`brew install ffmpeg` on macOS)
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
Add to your Gemfile:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
gem "ffmpeg_core"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Then run:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
bundle install
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
### Basic Usage
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
require "ffmpeg_core"
|
|
38
|
+
|
|
39
|
+
# Load a video file
|
|
40
|
+
movie = FFmpegCore::Movie.new("input.mp4")
|
|
41
|
+
|
|
42
|
+
# Get metadata
|
|
43
|
+
movie.duration # => 120.5 (seconds)
|
|
44
|
+
movie.resolution # => "1920x1080"
|
|
45
|
+
movie.video_codec # => "h264"
|
|
46
|
+
movie.audio_codec # => "aac"
|
|
47
|
+
movie.frame_rate # => 29.97
|
|
48
|
+
movie.bitrate # => 5000 (kb/s)
|
|
49
|
+
movie.valid? # => true
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Transcoding
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
movie = FFmpegCore::Movie.new("input.mp4")
|
|
56
|
+
|
|
57
|
+
# Basic transcoding
|
|
58
|
+
movie.transcode("output.mp4", video_codec: "libx264")
|
|
59
|
+
|
|
60
|
+
# With options
|
|
61
|
+
movie.transcode("output.mp4", {
|
|
62
|
+
video_codec: "libx264",
|
|
63
|
+
audio_codec: "aac",
|
|
64
|
+
video_bitrate: "1000k",
|
|
65
|
+
audio_bitrate: "128k",
|
|
66
|
+
resolution: "1280x720",
|
|
67
|
+
frame_rate: 30
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
# Custom FFmpeg flags
|
|
71
|
+
movie.transcode("output.mp4", {
|
|
72
|
+
video_codec: "libx264",
|
|
73
|
+
custom: ["-preset", "fast", "-crf", "23"]
|
|
74
|
+
})
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Screenshots
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
movie = FFmpegCore::Movie.new("input.mp4")
|
|
81
|
+
|
|
82
|
+
# Extract screenshot at specific time
|
|
83
|
+
movie.screenshot("thumbnail.jpg", seek_time: 5)
|
|
84
|
+
|
|
85
|
+
# With resolution
|
|
86
|
+
movie.screenshot("thumbnail.jpg", {
|
|
87
|
+
seek_time: 10,
|
|
88
|
+
resolution: "640x360",
|
|
89
|
+
quality: 2 # 2-31, lower is better
|
|
90
|
+
})
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Configuration
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
FFmpegCore.configure do |config|
|
|
97
|
+
config.ffmpeg_binary = "/usr/local/bin/ffmpeg"
|
|
98
|
+
config.ffprobe_binary = "/usr/local/bin/ffprobe"
|
|
99
|
+
config.timeout = 60
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Error Handling
|
|
104
|
+
|
|
105
|
+
FFmpegCore provides specific error classes for different failure scenarios:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
begin
|
|
109
|
+
movie = FFmpegCore::Movie.new("input.mp4")
|
|
110
|
+
movie.transcode("output.mp4", video_codec: "libx264")
|
|
111
|
+
rescue FFmpegCore::InvalidInputError => e
|
|
112
|
+
# File doesn't exist or is not readable
|
|
113
|
+
puts "Input error: #{e.message}"
|
|
114
|
+
rescue FFmpegCore::TranscodingError => e
|
|
115
|
+
# FFmpeg transcoding failed
|
|
116
|
+
puts "Transcoding failed: #{e.message}"
|
|
117
|
+
puts "Command: #{e.command}"
|
|
118
|
+
puts "Exit status: #{e.exit_status}"
|
|
119
|
+
puts "Stderr: #{e.stderr}"
|
|
120
|
+
rescue FFmpegCore::BinaryNotFoundError => e
|
|
121
|
+
# FFmpeg not installed
|
|
122
|
+
puts "FFmpeg not found: #{e.message}"
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Error Classes
|
|
127
|
+
|
|
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 |
|
|
136
|
+
|
|
137
|
+
## Development
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
# Install dependencies
|
|
141
|
+
bundle install
|
|
142
|
+
|
|
143
|
+
# Run tests
|
|
144
|
+
bundle exec rspec
|
|
145
|
+
|
|
146
|
+
# Run linter
|
|
147
|
+
bundle exec rubocop
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT License. See [LICENSE.txt](LICENSE.txt) for details.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FFmpegCore
|
|
4
|
+
# Configuration for FFmpegCore library
|
|
5
|
+
class Configuration
|
|
6
|
+
attr_accessor :ffmpeg_binary, :ffprobe_binary, :timeout
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@ffmpeg_binary = detect_binary("ffmpeg")
|
|
10
|
+
@ffprobe_binary = detect_binary("ffprobe")
|
|
11
|
+
@timeout = 30 # seconds
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def detect_binary(name)
|
|
17
|
+
# Check common locations
|
|
18
|
+
paths = ENV["PATH"].split(File::PATH_SEPARATOR)
|
|
19
|
+
paths.each do |path|
|
|
20
|
+
binary = File.join(path, name)
|
|
21
|
+
return binary if File.executable?(binary)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Homebrew locations (macOS)
|
|
25
|
+
homebrew_paths = [
|
|
26
|
+
"/opt/homebrew/bin/#{name}", # Apple Silicon
|
|
27
|
+
"/usr/local/bin/#{name}" # Intel
|
|
28
|
+
]
|
|
29
|
+
homebrew_paths.each do |path|
|
|
30
|
+
return path if File.executable?(path)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
raise BinaryNotFoundError, "#{name} binary not found. Please install FFmpeg: brew install ffmpeg"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
def configuration
|
|
39
|
+
@configuration_mutex ||= Mutex.new
|
|
40
|
+
@configuration_mutex.synchronize do
|
|
41
|
+
@configuration ||= Configuration.new
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def configuration=(config)
|
|
46
|
+
@configuration_mutex ||= Mutex.new
|
|
47
|
+
@configuration_mutex.synchronize do
|
|
48
|
+
@configuration = config
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def configure
|
|
53
|
+
yield(configuration)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def reset_configuration!
|
|
57
|
+
@configuration_mutex ||= Mutex.new
|
|
58
|
+
@configuration_mutex.synchronize do
|
|
59
|
+
@configuration = Configuration.new
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FFmpegCore
|
|
4
|
+
# Base error class for all FFmpegCore errors
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when ffmpeg/ffprobe binary is not found
|
|
8
|
+
class BinaryNotFoundError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when ffmpeg/ffprobe execution fails
|
|
11
|
+
class ExecutionError < Error
|
|
12
|
+
attr_reader :command, :exit_status, :stdout, :stderr
|
|
13
|
+
|
|
14
|
+
def initialize(message, command: nil, exit_status: nil, stdout: nil, stderr: nil)
|
|
15
|
+
@command = command
|
|
16
|
+
@exit_status = exit_status
|
|
17
|
+
@stdout = stdout
|
|
18
|
+
@stderr = stderr
|
|
19
|
+
super(message)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Raised when input file is invalid or cannot be read
|
|
24
|
+
class InvalidInputError < Error; end
|
|
25
|
+
|
|
26
|
+
# Raised when output file cannot be written
|
|
27
|
+
class OutputError < Error; end
|
|
28
|
+
|
|
29
|
+
# Raised when probe fails to extract metadata
|
|
30
|
+
class ProbeError < Error; end
|
|
31
|
+
|
|
32
|
+
# Raised when transcoding fails
|
|
33
|
+
class TranscodingError < ExecutionError; end
|
|
34
|
+
|
|
35
|
+
# Raised when screenshot extraction fails
|
|
36
|
+
class ScreenshotError < ExecutionError; end
|
|
37
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FFmpegCore
|
|
4
|
+
# Modern API for working with video files
|
|
5
|
+
class Movie
|
|
6
|
+
extend Forwardable
|
|
7
|
+
|
|
8
|
+
attr_reader :path, :probe
|
|
9
|
+
|
|
10
|
+
def initialize(path)
|
|
11
|
+
@path = path
|
|
12
|
+
@probe = Probe.new(path)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Delegate metadata methods to probe
|
|
16
|
+
def_delegators :probe, :duration, :bitrate, :video_codec, :audio_codec,
|
|
17
|
+
:width, :height, :frame_rate, :resolution, :valid?
|
|
18
|
+
|
|
19
|
+
# Transcode video with modern API
|
|
20
|
+
#
|
|
21
|
+
# @param output_path [String] Path to output file
|
|
22
|
+
# @param options [Hash] Transcoding options
|
|
23
|
+
# @option options [String] :video_codec Video codec (e.g., "libx264")
|
|
24
|
+
# @option options [String] :audio_codec Audio codec (e.g., "aac")
|
|
25
|
+
# @option options [String, Integer] :video_bitrate Video bitrate (e.g., "1000k" or 1000)
|
|
26
|
+
# @option options [String, Integer] :audio_bitrate Audio bitrate (e.g., "128k" or 128)
|
|
27
|
+
# @option options [String] :resolution Resolution (e.g., "1280x720")
|
|
28
|
+
# @option options [Integer, Float] :frame_rate Frame rate (e.g., 30)
|
|
29
|
+
# @option options [Array<String>] :custom Custom FFmpeg flags
|
|
30
|
+
# @return [String] Path to transcoded file
|
|
31
|
+
def transcode(output_path, options = {})
|
|
32
|
+
transcoder = Transcoder.new(path, output_path, options)
|
|
33
|
+
transcoder.run
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Extract screenshot from video
|
|
37
|
+
#
|
|
38
|
+
# @param output_path [String] Path to output image
|
|
39
|
+
# @param options [Hash] Screenshot options
|
|
40
|
+
# @option options [Integer, Float] :seek_time Time in seconds to seek to (default: 0)
|
|
41
|
+
# @option options [String] :resolution Resolution (e.g., "640x360")
|
|
42
|
+
# @option options [Integer] :quality JPEG quality 2-31, lower is better (default: 2)
|
|
43
|
+
# @return [String] Path to screenshot file
|
|
44
|
+
def screenshot(output_path, options = {})
|
|
45
|
+
screenshotter = Screenshot.new(path, output_path, options)
|
|
46
|
+
screenshotter.extract
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module FFmpegCore
|
|
7
|
+
# Probe video metadata using ffprobe
|
|
8
|
+
class Probe
|
|
9
|
+
attr_reader :path, :metadata
|
|
10
|
+
|
|
11
|
+
def initialize(path)
|
|
12
|
+
@path = path.to_s
|
|
13
|
+
@metadata = nil
|
|
14
|
+
validate_file!
|
|
15
|
+
probe!
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Duration in seconds
|
|
19
|
+
def duration
|
|
20
|
+
@metadata.dig("format", "duration")&.to_f
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Bitrate in kb/s
|
|
24
|
+
def bitrate
|
|
25
|
+
@metadata.dig("format", "bit_rate")&.to_i&./(1000)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Video stream metadata
|
|
29
|
+
def video_stream
|
|
30
|
+
@video_stream ||= streams.find { |s| s["codec_type"] == "video" }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Audio stream metadata
|
|
34
|
+
def audio_stream
|
|
35
|
+
@audio_stream ||= streams.find { |s| s["codec_type"] == "audio" }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def video_codec
|
|
39
|
+
video_stream&.dig("codec_name")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def audio_codec
|
|
43
|
+
audio_stream&.dig("codec_name")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def width
|
|
47
|
+
video_stream&.dig("width")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def height
|
|
51
|
+
video_stream&.dig("height")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def frame_rate
|
|
55
|
+
return nil unless video_stream
|
|
56
|
+
|
|
57
|
+
# Parse frame rate (e.g., "30000/1001" or "30")
|
|
58
|
+
r_frame_rate = video_stream["r_frame_rate"]
|
|
59
|
+
return nil unless r_frame_rate
|
|
60
|
+
|
|
61
|
+
if r_frame_rate.include?("/")
|
|
62
|
+
num, den = r_frame_rate.split("/").map(&:to_f)
|
|
63
|
+
num / den
|
|
64
|
+
else
|
|
65
|
+
r_frame_rate.to_f
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def resolution
|
|
70
|
+
return nil unless width && height
|
|
71
|
+
"#{width}x#{height}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def valid?
|
|
75
|
+
!video_stream.nil?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def streams
|
|
81
|
+
@metadata.fetch("streams", [])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def validate_file!
|
|
85
|
+
raise InvalidInputError, "File does not exist: #{path}" unless File.exist?(path)
|
|
86
|
+
raise InvalidInputError, "File is not readable: #{path}" unless File.readable?(path)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def probe!
|
|
90
|
+
command = [
|
|
91
|
+
FFmpegCore.configuration.ffprobe_binary,
|
|
92
|
+
"-v", "quiet",
|
|
93
|
+
"-print_format", "json",
|
|
94
|
+
"-show_format",
|
|
95
|
+
"-show_streams",
|
|
96
|
+
path
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
stdout, stderr, status = Open3.capture3(*command)
|
|
100
|
+
|
|
101
|
+
unless status.success?
|
|
102
|
+
raise ProbeError.new(
|
|
103
|
+
"ffprobe failed for #{path}",
|
|
104
|
+
command: command.join(" "),
|
|
105
|
+
exit_status: status.exitstatus,
|
|
106
|
+
stdout: stdout,
|
|
107
|
+
stderr: stderr
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
@metadata = JSON.parse(stdout)
|
|
112
|
+
rescue JSON::ParserError => e
|
|
113
|
+
raise ProbeError, "Failed to parse ffprobe output: #{e.message}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module FFmpegCore
|
|
6
|
+
# Extract screenshots from video files
|
|
7
|
+
class Screenshot
|
|
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 extract
|
|
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
|
+
raise InvalidInputError, "Input file does not exist: #{input_path}" unless File.exist?(input_path)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def ensure_output_directory!
|
|
31
|
+
output_dir = File.dirname(output_path)
|
|
32
|
+
FileUtils.mkdir_p(output_dir) unless Dir.exist?(output_dir)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def build_command
|
|
36
|
+
cmd = [FFmpegCore.configuration.ffmpeg_binary]
|
|
37
|
+
|
|
38
|
+
# Seek to timestamp (before input for faster processing)
|
|
39
|
+
if options[:seek_time]
|
|
40
|
+
cmd += ["-ss", options[:seek_time].to_s]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Input file
|
|
44
|
+
cmd += ["-i", input_path]
|
|
45
|
+
|
|
46
|
+
# Number of frames to extract (default: 1)
|
|
47
|
+
cmd += ["-vframes", "1"]
|
|
48
|
+
|
|
49
|
+
# Resolution
|
|
50
|
+
if options[:resolution]
|
|
51
|
+
cmd += ["-s", options[:resolution]]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Quality (2-31, lower is better, default: 2)
|
|
55
|
+
quality = options[:quality] || 2
|
|
56
|
+
cmd += ["-q:v", quality.to_s]
|
|
57
|
+
|
|
58
|
+
# Overwrite output file
|
|
59
|
+
cmd += ["-y"]
|
|
60
|
+
|
|
61
|
+
# Output file
|
|
62
|
+
cmd += [output_path]
|
|
63
|
+
|
|
64
|
+
cmd
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def execute_command(command)
|
|
68
|
+
stdout, stderr, status = Open3.capture3(*command)
|
|
69
|
+
|
|
70
|
+
unless status.success?
|
|
71
|
+
raise ScreenshotError.new(
|
|
72
|
+
"Screenshot extraction failed",
|
|
73
|
+
command: command.join(" "),
|
|
74
|
+
exit_status: status.exitstatus,
|
|
75
|
+
stdout: stdout,
|
|
76
|
+
stderr: stderr
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
output_path
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
|
|
6
|
+
module FFmpegCore
|
|
7
|
+
# Execute FFmpeg transcoding operations
|
|
8
|
+
class Transcoder
|
|
9
|
+
attr_reader :input_path, :output_path, :options
|
|
10
|
+
|
|
11
|
+
def initialize(input_path, output_path, options = {})
|
|
12
|
+
@input_path = input_path.to_s
|
|
13
|
+
@output_path = output_path.to_s
|
|
14
|
+
@options = options
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run
|
|
18
|
+
validate_input!
|
|
19
|
+
ensure_output_directory!
|
|
20
|
+
|
|
21
|
+
command = build_command
|
|
22
|
+
execute_command(command)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def validate_input!
|
|
28
|
+
raise InvalidInputError, "Input file does not exist: #{input_path}" unless File.exist?(input_path)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ensure_output_directory!
|
|
32
|
+
output_dir = File.dirname(output_path)
|
|
33
|
+
FileUtils.mkdir_p(output_dir) unless Dir.exist?(output_dir)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build_command
|
|
37
|
+
cmd = [FFmpegCore.configuration.ffmpeg_binary]
|
|
38
|
+
|
|
39
|
+
# Input file
|
|
40
|
+
cmd += ["-i", input_path]
|
|
41
|
+
|
|
42
|
+
# Video codec
|
|
43
|
+
if options[:video_codec]
|
|
44
|
+
cmd += ["-c:v", options[:video_codec]]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Audio codec
|
|
48
|
+
if options[:audio_codec]
|
|
49
|
+
cmd += ["-c:a", options[:audio_codec]]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Video bitrate
|
|
53
|
+
if options[:video_bitrate]
|
|
54
|
+
cmd += ["-b:v", normalize_bitrate(options[:video_bitrate])]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Audio bitrate
|
|
58
|
+
if options[:audio_bitrate]
|
|
59
|
+
cmd += ["-b:a", normalize_bitrate(options[:audio_bitrate])]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Resolution
|
|
63
|
+
if options[:resolution]
|
|
64
|
+
cmd += ["-s", options[:resolution]]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Frame rate
|
|
68
|
+
if options[:frame_rate]
|
|
69
|
+
cmd += ["-r", options[:frame_rate].to_s]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Custom options (array of strings)
|
|
73
|
+
if options[:custom]
|
|
74
|
+
cmd += options[:custom]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Overwrite output file
|
|
78
|
+
cmd += ["-y"]
|
|
79
|
+
|
|
80
|
+
# Output file
|
|
81
|
+
cmd += [output_path]
|
|
82
|
+
|
|
83
|
+
cmd
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def normalize_bitrate(bitrate)
|
|
87
|
+
# Convert various formats to ffmpeg format
|
|
88
|
+
# "1000k" -> "1000k"
|
|
89
|
+
# 1000 -> "1000k"
|
|
90
|
+
# "1M" -> "1M"
|
|
91
|
+
return bitrate.to_s if bitrate.to_s.match?(/\d+[kKmM]/)
|
|
92
|
+
"#{bitrate}k"
|
|
93
|
+
end
|
|
94
|
+
|
|
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
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
output_path
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
data/lib/ffmpeg_core.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "forwardable"
|
|
5
|
+
|
|
6
|
+
# FFmpegCore - Modern Ruby wrapper for FFmpeg
|
|
7
|
+
#
|
|
8
|
+
# A clean, well-tested alternative to streamio-ffmpeg with:
|
|
9
|
+
# - Modern Ruby 3+ conventions
|
|
10
|
+
# - Proper error handling with detailed context
|
|
11
|
+
# - Zero Rails dependencies (gem-ready architecture)
|
|
12
|
+
#
|
|
13
|
+
# @example Basic usage
|
|
14
|
+
# movie = FFmpegCore::Movie.new("input.mp4")
|
|
15
|
+
# movie.transcode("output.mp4", video_codec: "libx264", video_bitrate: "1000k")
|
|
16
|
+
# movie.screenshot("thumb.jpg", seek_time: 1, resolution: "640x360")
|
|
17
|
+
module FFmpegCore
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Load core components
|
|
21
|
+
require_relative "ffmpeg_core/version"
|
|
22
|
+
require_relative "ffmpeg_core/errors"
|
|
23
|
+
require_relative "ffmpeg_core/configuration"
|
|
24
|
+
require_relative "ffmpeg_core/probe"
|
|
25
|
+
require_relative "ffmpeg_core/transcoder"
|
|
26
|
+
require_relative "ffmpeg_core/screenshot"
|
|
27
|
+
require_relative "ffmpeg_core/movie"
|
data/sig/ffmpeg_core.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ffmpeg_core
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Alexey Poimtsev
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: A clean, well-tested FFmpeg wrapper with modern Ruby conventions, proper
|
|
13
|
+
error handling, and zero dependencies.
|
|
14
|
+
email:
|
|
15
|
+
- alexey.poimtsev@gmail.com
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- CHANGELOG.md
|
|
21
|
+
- LICENSE.txt
|
|
22
|
+
- README.md
|
|
23
|
+
- Rakefile
|
|
24
|
+
- lib/ffmpeg_core.rb
|
|
25
|
+
- lib/ffmpeg_core/configuration.rb
|
|
26
|
+
- lib/ffmpeg_core/errors.rb
|
|
27
|
+
- lib/ffmpeg_core/movie.rb
|
|
28
|
+
- lib/ffmpeg_core/probe.rb
|
|
29
|
+
- lib/ffmpeg_core/screenshot.rb
|
|
30
|
+
- lib/ffmpeg_core/transcoder.rb
|
|
31
|
+
- lib/ffmpeg_core/version.rb
|
|
32
|
+
- sig/ffmpeg_core.rbs
|
|
33
|
+
homepage: https://github.com/alec-c4/ffmpeg_core
|
|
34
|
+
licenses:
|
|
35
|
+
- MIT
|
|
36
|
+
metadata:
|
|
37
|
+
homepage_uri: https://github.com/alec-c4/ffmpeg_core
|
|
38
|
+
source_code_uri: https://github.com/alec-c4/ffmpeg_core
|
|
39
|
+
changelog_uri: https://github.com/alec-c4/ffmpeg_core/blob/main/CHANGELOG.md
|
|
40
|
+
rubygems_mfa_required: 'true'
|
|
41
|
+
rdoc_options: []
|
|
42
|
+
require_paths:
|
|
43
|
+
- lib
|
|
44
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: 3.2.0
|
|
49
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
requirements: []
|
|
55
|
+
rubygems_version: 4.0.3
|
|
56
|
+
specification_version: 4
|
|
57
|
+
summary: Modern Ruby wrapper for FFmpeg
|
|
58
|
+
test_files: []
|