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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8605d887cd2bf7b03dc1cc1dc06f004b797a694467f5376c361e432fdc648e50
4
- data.tar.gz: 6d3308730d6ca3eceb6de5aa90053c39bcbcb0438a0faf4bc4a2a5b035ba7c66
3
+ metadata.gz: 4efe5152c9dd01b8ebb2d34c9f6d6877371d017845242297f57f65044c968648
4
+ data.tar.gz: a1b09735ebc0acc9c21b252e3fa32b4168e0c5694eab8d72fa299e7c8c88dc9f
5
5
  SHA512:
6
- metadata.gz: 4bd3cc9dcdbae4104798cc0c6bd5ceef7f151f8a07aad0c33c6793f5c37928762f0c81baf0ce9ff943a4af17594ae4c9675db92e695ca73fd23ae0f2d93cfc43
7
- data.tar.gz: 60e15449132609d881dfe136d7fe7d8e6777089a396578d79cbd0034bcd142e032c94a818ffc78d819fb724c49b7870451cabe2644e0d573e820afaf9d8815ad
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
- ## [Unreleased]
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
+ [![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**
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
- # With options
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
- frame_rate: 30
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 | 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 |
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.
@@ -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 < Error; end
30
+ class ProbeError < ExecutionError; end
31
31
 
32
32
  # Raised when transcoding fails
33
33
  class TranscodingError < ExecutionError; end
@@ -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
@@ -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
- cmd += ["-r", options[:frame_rate].to_s]
70
- end
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
- 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
- )
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FFmpegCore
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
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.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexey Poimtsev