speedrun 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 +21 -0
- data/Guardfile +14 -0
- data/LICENSE +21 -0
- data/README.md +140 -0
- data/Rakefile +8 -0
- data/exe/speedrun +6 -0
- data/lib/speedrun/cli.rb +39 -0
- data/lib/speedrun/ffmpeg.rb +95 -0
- data/lib/speedrun/formatter.rb +46 -0
- data/lib/speedrun/trimmer.rb +111 -0
- data/lib/speedrun/version.rb +5 -0
- data/lib/speedrun.rb +11 -0
- data/sig/speedrun.rbs +4 -0
- metadata +141 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c0622185252d91812dedda26e692722bca73497fb9ef979cab8d4af602391867
|
|
4
|
+
data.tar.gz: 8c8d1ffb726b10f5aa0bf6c948713af211f710617fe348a9174f0cf57bd2a689
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b46d265c87d2679cef58df69c092bf72b297b27ca1663dfb5b72dd8f41a4208d9ab982cb0e26d8bab53aded0f6cdaf6a84b4fdb6753bef419faf1cc7ceb0a35c
|
|
7
|
+
data.tar.gz: cd11c55079861ada32b8eca148fe3c022b628b6d36b682d6a38126330f10c687bde7fdd66f787c82ddf8592118d47f429e30681837113cf46f90e1d368330796
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
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.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2025-11-03
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Initial release of speedrun gem
|
|
12
|
+
- Core video trimming functionality to detect and remove freeze/low-motion regions
|
|
13
|
+
- FFmpeg integration for video processing
|
|
14
|
+
- CLI interface with Thor framework
|
|
15
|
+
- Time, duration, and filesize formatters
|
|
16
|
+
- Freeze detection using motion vectors
|
|
17
|
+
- Video segment extraction and concatenation
|
|
18
|
+
- Dry-run mode for testing without processing
|
|
19
|
+
- Configurable noise threshold for motion detection
|
|
20
|
+
- Progress feedback during video processing
|
|
21
|
+
- Comprehensive test suite with mocked FFmpeg calls
|
data/Guardfile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
guard :minitest, autorun: false do
|
|
4
|
+
# Watch lib files and run all tests
|
|
5
|
+
watch(%r{^lib/(.+)\.rb$}) { 'test' }
|
|
6
|
+
|
|
7
|
+
# Watch test files and run corresponding test
|
|
8
|
+
watch(%r{^test/test_(.+)\.rb$})
|
|
9
|
+
watch(%r{^test/speedrun/test_(.+)\.rb$})
|
|
10
|
+
|
|
11
|
+
# Watch test_helper and run all tests
|
|
12
|
+
watch(%r{^test/test_helper\.rb$}) { 'test' }
|
|
13
|
+
watch(%r{^test/support/.+\.rb$}) { 'test' }
|
|
14
|
+
end
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Benjamin Jackson
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# speedrun
|
|
2
|
+
|
|
3
|
+
Automatically detect and remove freeze/low-motion regions from videos using ffmpeg.
|
|
4
|
+
|
|
5
|
+
## Description
|
|
6
|
+
|
|
7
|
+
`speedrun` analyzes videos for frozen or low-motion segments (using ffmpeg's `freezedetect` filter) and removes them, stitching together only the active parts. Perfect for cleaning up screen recordings, presentation videos, or any footage with long static periods.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Install the gem:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
gem install speedrun
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or add to your Gemfile:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem 'speedrun'
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Requirements
|
|
24
|
+
|
|
25
|
+
- Ruby >= 3.2.0
|
|
26
|
+
- ffmpeg (with freezedetect filter support)
|
|
27
|
+
- ffprobe
|
|
28
|
+
|
|
29
|
+
Install ffmpeg via your package manager:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# macOS
|
|
33
|
+
brew install ffmpeg
|
|
34
|
+
|
|
35
|
+
# Ubuntu/Debian
|
|
36
|
+
apt-get install ffmpeg
|
|
37
|
+
|
|
38
|
+
# Arch Linux
|
|
39
|
+
pacman -S ffmpeg
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
Basic usage:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
speedrun trim input.mp4
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
This creates `input-trimmed.mp4` with frozen segments removed.
|
|
51
|
+
|
|
52
|
+
### Options
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
speedrun trim INPUT [OUTPUT] [options]
|
|
56
|
+
|
|
57
|
+
Options:
|
|
58
|
+
-n, --noise THRESHOLD # Noise tolerance in dB (default: -70)
|
|
59
|
+
-d, --duration SECONDS # Minimum freeze duration in seconds (default: 1.0)
|
|
60
|
+
--dry-run # Preview without processing
|
|
61
|
+
-q, --quiet # Minimal output
|
|
62
|
+
|
|
63
|
+
Examples:
|
|
64
|
+
speedrun trim video.mp4 # Creates video-trimmed.mp4
|
|
65
|
+
speedrun trim video.mp4 output.mp4 # Custom output name
|
|
66
|
+
speedrun trim video.mp4 --noise -60 # More sensitive detection
|
|
67
|
+
speedrun trim video.mp4 --duration 2.0 # Only remove freezes >= 2s
|
|
68
|
+
speedrun trim video.mp4 --dry-run # Preview analysis only
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
#### Understanding the Noise Threshold
|
|
72
|
+
|
|
73
|
+
The `--noise` parameter controls how sensitive freeze detection is to small changes in the video:
|
|
74
|
+
|
|
75
|
+
- **Less negative values** (like `-60 dB`) = **More sensitive**
|
|
76
|
+
Detects freezes even when there's subtle motion or slight changes
|
|
77
|
+
Use when you want to catch nearly-static sections
|
|
78
|
+
|
|
79
|
+
- **More negative values** (like `-80 dB`) = **Less sensitive**
|
|
80
|
+
Only detects freezes when frames are nearly identical
|
|
81
|
+
Use when you want to preserve sections with minimal motion
|
|
82
|
+
|
|
83
|
+
The default of `-70 dB` works well for most screen recordings. If you're getting false positives (motion incorrectly flagged as frozen), try `-80 dB`. If freezes are being missed, try `-60 dB`.
|
|
84
|
+
|
|
85
|
+
### Other Commands
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
speedrun version # Show version
|
|
89
|
+
speedrun help # Show help
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## How It Works
|
|
93
|
+
|
|
94
|
+
1. **Detect:** Uses ffmpeg's `freezedetect` filter to find frozen/low-motion segments
|
|
95
|
+
2. **Analyze:** Calculates which portions to keep vs. remove
|
|
96
|
+
3. **Extract:** Extracts active segments using ffmpeg
|
|
97
|
+
4. **Stitch:** Concatenates segments into final output
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
After checking out the repo:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
bin/setup # Install dependencies
|
|
105
|
+
bundle exec rake test # Run test suite
|
|
106
|
+
bundle exec guard # Auto-run tests on file changes
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Testing
|
|
110
|
+
|
|
111
|
+
The codebase follows strict TDD with 100% test coverage. All ffmpeg calls are mocked for fast, isolated testing.
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
bundle exec rake test # Run full test suite
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Test Structure
|
|
118
|
+
|
|
119
|
+
- **Unit tests:** All components tested in isolation with mocks
|
|
120
|
+
- **Integration tests:** Full workflow with mocked FFmpeg
|
|
121
|
+
- **Fixtures:** Sample ffmpeg/ffprobe outputs for realistic testing
|
|
122
|
+
|
|
123
|
+
## Architecture
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
lib/speedrun/
|
|
127
|
+
├── version.rb # Version constant
|
|
128
|
+
├── formatter.rb # Time/duration/filesize formatters
|
|
129
|
+
├── ffmpeg.rb # FFmpeg command wrappers & parsers
|
|
130
|
+
├── trimmer.rb # Core video processing logic
|
|
131
|
+
└── cli.rb # Thor-based CLI interface
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Contributing
|
|
135
|
+
|
|
136
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/benjaminjackson/speedrun.
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/exe/speedrun
ADDED
data/lib/speedrun/cli.rb
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thor'
|
|
4
|
+
|
|
5
|
+
module Speedrun
|
|
6
|
+
class CLI < Thor
|
|
7
|
+
desc "version", "Display version"
|
|
8
|
+
def version
|
|
9
|
+
puts "speedrun version #{Speedrun::VERSION}"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
desc "trim INPUT [OUTPUT]", "Trim freeze/low-motion regions from video"
|
|
13
|
+
option :noise, type: :numeric, aliases: '-n', default: -70, desc: "Noise tolerance in dB (less negative = more cuts)"
|
|
14
|
+
option :duration, type: :numeric, aliases: '-d', default: 1.0, desc: "Minimum freeze duration to remove in seconds"
|
|
15
|
+
option :'dry-run', type: :boolean, default: false, desc: "Preview without processing"
|
|
16
|
+
option :quiet, type: :boolean, aliases: '-q', default: false, desc: "Minimal output"
|
|
17
|
+
def trim(input_file, output_file = nil)
|
|
18
|
+
unless File.exist?(input_file)
|
|
19
|
+
puts "Error: File not found: #{input_file}"
|
|
20
|
+
exit 1
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
trimmer = Trimmer.new(
|
|
24
|
+
input_file,
|
|
25
|
+
output_file,
|
|
26
|
+
noise_threshold: options[:noise],
|
|
27
|
+
min_duration: options[:duration]
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
trimmer.dry_run = options[:'dry-run']
|
|
31
|
+
trimmer.run
|
|
32
|
+
rescue => e
|
|
33
|
+
puts "Error: #{e.message}"
|
|
34
|
+
exit 1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
default_task :trim
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
require 'tempfile'
|
|
5
|
+
|
|
6
|
+
module Speedrun
|
|
7
|
+
module FFmpeg
|
|
8
|
+
FREEZE_START_PATTERN = /freeze_start:\s*([\d.]+)/
|
|
9
|
+
FREEZE_END_PATTERN = /freeze_end:\s*([\d.]+)/
|
|
10
|
+
|
|
11
|
+
def self.parse_duration(output)
|
|
12
|
+
output.strip.to_f
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.parse_freezes(output)
|
|
16
|
+
regions = []
|
|
17
|
+
freeze_start = nil
|
|
18
|
+
|
|
19
|
+
output.each_line do |line|
|
|
20
|
+
if line =~ FREEZE_START_PATTERN
|
|
21
|
+
freeze_start = ::Regexp.last_match(1).to_f
|
|
22
|
+
elsif line =~ FREEZE_END_PATTERN && freeze_start
|
|
23
|
+
freeze_end = ::Regexp.last_match(1).to_f
|
|
24
|
+
regions << [freeze_start, freeze_end]
|
|
25
|
+
freeze_start = nil
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
regions
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.get_duration(file_path)
|
|
33
|
+
raise ArgumentError, "File not found: #{file_path}" unless File.exist?(file_path)
|
|
34
|
+
|
|
35
|
+
cmd = [
|
|
36
|
+
'ffprobe',
|
|
37
|
+
'-v', 'error',
|
|
38
|
+
'-show_entries', 'format=duration',
|
|
39
|
+
'-of', 'default=noprint_wrappers=1:nokey=1',
|
|
40
|
+
file_path
|
|
41
|
+
].shelljoin
|
|
42
|
+
|
|
43
|
+
output = `#{cmd}`
|
|
44
|
+
parse_duration(output)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.detect_freezes(file_path, noise_threshold: -70, min_duration: 1.0)
|
|
48
|
+
raise ArgumentError, "File not found: #{file_path}" unless File.exist?(file_path)
|
|
49
|
+
|
|
50
|
+
cmd = [
|
|
51
|
+
'ffmpeg',
|
|
52
|
+
'-i', file_path,
|
|
53
|
+
'-vf', "freezedetect=n=#{noise_threshold}dB:d=#{min_duration}",
|
|
54
|
+
'-f', 'null',
|
|
55
|
+
'-'
|
|
56
|
+
].shelljoin
|
|
57
|
+
|
|
58
|
+
output = `#{cmd} 2>&1`
|
|
59
|
+
parse_freezes(output)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.extract_and_concat(input_file, output_file, keep_regions)
|
|
63
|
+
raise ArgumentError, "File not found: #{input_file}" unless File.exist?(input_file)
|
|
64
|
+
raise ArgumentError, "No regions to keep" if keep_regions.empty?
|
|
65
|
+
|
|
66
|
+
abs_input = File.absolute_path(input_file)
|
|
67
|
+
|
|
68
|
+
Tempfile.create(['concat', '.txt']) do |concat_file|
|
|
69
|
+
keep_regions.each do |start_time, end_time|
|
|
70
|
+
concat_file.puts "file '#{abs_input}'"
|
|
71
|
+
concat_file.puts "inpoint #{start_time}"
|
|
72
|
+
concat_file.puts "outpoint #{end_time}"
|
|
73
|
+
end
|
|
74
|
+
concat_file.flush
|
|
75
|
+
|
|
76
|
+
cmd = [
|
|
77
|
+
'ffmpeg',
|
|
78
|
+
'-f', 'concat',
|
|
79
|
+
'-safe', '0',
|
|
80
|
+
'-i', concat_file.path,
|
|
81
|
+
'-c', 'copy',
|
|
82
|
+
'-y',
|
|
83
|
+
output_file
|
|
84
|
+
].shelljoin
|
|
85
|
+
|
|
86
|
+
output = `#{cmd} 2>&1`
|
|
87
|
+
success = $?.success?
|
|
88
|
+
|
|
89
|
+
raise "FFmpeg failed: #{output}" unless success
|
|
90
|
+
|
|
91
|
+
success
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Speedrun
|
|
4
|
+
module Formatter
|
|
5
|
+
SECONDS_PER_MINUTE = 60
|
|
6
|
+
SECONDS_PER_HOUR = 3600
|
|
7
|
+
BYTES_PER_KB = 1024
|
|
8
|
+
BYTES_PER_MB = 1024 * 1024
|
|
9
|
+
BYTES_PER_GB = 1024 * 1024 * 1024
|
|
10
|
+
|
|
11
|
+
def self.format_time(seconds)
|
|
12
|
+
hours = (seconds / SECONDS_PER_HOUR).to_i
|
|
13
|
+
minutes = ((seconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE).to_i
|
|
14
|
+
secs = seconds % SECONDS_PER_MINUTE
|
|
15
|
+
|
|
16
|
+
format('%02d:%02d:%06.3f', hours, minutes, secs)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.format_duration(seconds)
|
|
20
|
+
if seconds < SECONDS_PER_MINUTE
|
|
21
|
+
format('%.2fs', seconds)
|
|
22
|
+
elsif seconds < SECONDS_PER_HOUR
|
|
23
|
+
minutes = (seconds / SECONDS_PER_MINUTE).to_i
|
|
24
|
+
secs = seconds % SECONDS_PER_MINUTE
|
|
25
|
+
format('%dm %.1fs', minutes, secs)
|
|
26
|
+
else
|
|
27
|
+
hours = (seconds / SECONDS_PER_HOUR).to_i
|
|
28
|
+
minutes = ((seconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE).to_i
|
|
29
|
+
secs = seconds % SECONDS_PER_MINUTE
|
|
30
|
+
format('%dh %dm %ds', hours, minutes, secs.to_i)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.format_filesize(bytes)
|
|
35
|
+
if bytes < BYTES_PER_KB
|
|
36
|
+
format('%d B', bytes)
|
|
37
|
+
elsif bytes < BYTES_PER_MB
|
|
38
|
+
format('%.1f KB', bytes / BYTES_PER_KB.to_f)
|
|
39
|
+
elsif bytes < BYTES_PER_GB
|
|
40
|
+
format('%.1f MB', bytes / BYTES_PER_MB.to_f)
|
|
41
|
+
else
|
|
42
|
+
format('%.1f GB', bytes / BYTES_PER_GB.to_f)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module Speedrun
|
|
6
|
+
class Trimmer
|
|
7
|
+
attr_reader :input_file, :output_file
|
|
8
|
+
attr_accessor :dry_run
|
|
9
|
+
|
|
10
|
+
def initialize(input_file, output_file = nil, noise_threshold: -70, min_duration: 1.0)
|
|
11
|
+
raise ArgumentError, "File not found: #{input_file}" unless File.exist?(input_file)
|
|
12
|
+
|
|
13
|
+
@input_file = input_file
|
|
14
|
+
@output_file = output_file || generate_output_filename(input_file)
|
|
15
|
+
@noise_threshold = noise_threshold
|
|
16
|
+
@min_duration = min_duration
|
|
17
|
+
@dry_run = false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def dry_run?
|
|
21
|
+
@dry_run
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def run
|
|
25
|
+
puts "Input: #{@input_file}"
|
|
26
|
+
puts "Output: #{@output_file}"
|
|
27
|
+
puts
|
|
28
|
+
|
|
29
|
+
# Step 1: Detect freeze regions
|
|
30
|
+
freeze_regions = FFmpeg.detect_freezes(
|
|
31
|
+
@input_file,
|
|
32
|
+
noise_threshold: @noise_threshold,
|
|
33
|
+
min_duration: @min_duration
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if freeze_regions.empty?
|
|
37
|
+
puts "No freeze regions detected. Copying original video..."
|
|
38
|
+
FileUtils.cp(@input_file, @output_file) unless dry_run?
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Step 2: Calculate keep regions
|
|
43
|
+
video_duration = FFmpeg.get_duration(@input_file)
|
|
44
|
+
keep_regions = calculate_keep_regions(freeze_regions, video_duration)
|
|
45
|
+
|
|
46
|
+
if keep_regions.empty?
|
|
47
|
+
raise "All video content would be removed!"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Step 3: Print summary
|
|
51
|
+
print_summary(freeze_regions, keep_regions, video_duration)
|
|
52
|
+
|
|
53
|
+
# Step 4: Process or dry-run
|
|
54
|
+
if dry_run?
|
|
55
|
+
puts
|
|
56
|
+
puts "DRY RUN - No files were modified"
|
|
57
|
+
else
|
|
58
|
+
puts
|
|
59
|
+
puts "Processing video..."
|
|
60
|
+
FFmpeg.extract_and_concat(@input_file, @output_file, keep_regions)
|
|
61
|
+
|
|
62
|
+
output_size = File.size(@output_file)
|
|
63
|
+
puts "Complete! Output saved to #{@output_file}"
|
|
64
|
+
puts " File size: #{Formatter.format_filesize(output_size)}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def generate_output_filename(input_file)
|
|
71
|
+
ext = File.extname(input_file)
|
|
72
|
+
base = File.basename(input_file, ext)
|
|
73
|
+
dir = File.dirname(input_file)
|
|
74
|
+
|
|
75
|
+
output = "#{base}-trimmed#{ext}"
|
|
76
|
+
dir == "." ? output : File.join(dir, output)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def calculate_keep_regions(freeze_regions, video_duration)
|
|
80
|
+
return [[0.0, video_duration]] if freeze_regions.empty?
|
|
81
|
+
|
|
82
|
+
keep_regions = []
|
|
83
|
+
current_time = 0.0
|
|
84
|
+
|
|
85
|
+
freeze_regions.each do |start_time, end_time|
|
|
86
|
+
if start_time > current_time
|
|
87
|
+
keep_regions << [current_time, start_time]
|
|
88
|
+
end
|
|
89
|
+
current_time = end_time
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
keep_regions << [current_time, video_duration] if current_time < video_duration
|
|
93
|
+
|
|
94
|
+
keep_regions
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def print_summary(freeze_regions, keep_regions, video_duration)
|
|
98
|
+
total_removed = freeze_regions.sum { |s, e| e - s }
|
|
99
|
+
total_kept = keep_regions.sum { |s, e| e - s }
|
|
100
|
+
|
|
101
|
+
puts "Analysis complete:"
|
|
102
|
+
puts " Found #{freeze_regions.length} freeze region#{freeze_regions.length == 1 ? '' : 's'}"
|
|
103
|
+
puts " Keeping #{keep_regions.length} segment#{keep_regions.length == 1 ? '' : 's'} with motion"
|
|
104
|
+
puts
|
|
105
|
+
puts "Summary:"
|
|
106
|
+
puts " Original duration: #{Formatter.format_duration(video_duration)}"
|
|
107
|
+
puts " Keeping: #{Formatter.format_duration(total_kept)} (#{'%.1f' % (total_kept / video_duration * 100)}%)"
|
|
108
|
+
puts " Removing: #{Formatter.format_duration(total_removed)} (#{'%.1f' % (total_removed / video_duration * 100)}%)"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
data/lib/speedrun.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "speedrun/version"
|
|
4
|
+
require_relative "speedrun/formatter"
|
|
5
|
+
require_relative "speedrun/ffmpeg"
|
|
6
|
+
require_relative "speedrun/trimmer"
|
|
7
|
+
require_relative "speedrun/cli"
|
|
8
|
+
|
|
9
|
+
module Speedrun
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
end
|
data/sig/speedrun.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: speedrun
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Benjamin Jackson
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: thor
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.3'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.3'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: ruby-progressbar
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.13'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.13'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: pastel
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.8'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0.8'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: guard
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '2.18'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '2.18'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: guard-minitest
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '2.4'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '2.4'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: minitest-stub_any_instance
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '1.0'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '1.0'
|
|
96
|
+
description: CLI tool that uses ffmpeg to automatically detect frozen or low-motion
|
|
97
|
+
segments in videos and remove them, stitching together the active parts.
|
|
98
|
+
email:
|
|
99
|
+
- ben@hearmeout.co
|
|
100
|
+
executables:
|
|
101
|
+
- speedrun
|
|
102
|
+
extensions: []
|
|
103
|
+
extra_rdoc_files: []
|
|
104
|
+
files:
|
|
105
|
+
- CHANGELOG.md
|
|
106
|
+
- Guardfile
|
|
107
|
+
- LICENSE
|
|
108
|
+
- README.md
|
|
109
|
+
- Rakefile
|
|
110
|
+
- exe/speedrun
|
|
111
|
+
- lib/speedrun.rb
|
|
112
|
+
- lib/speedrun/cli.rb
|
|
113
|
+
- lib/speedrun/ffmpeg.rb
|
|
114
|
+
- lib/speedrun/formatter.rb
|
|
115
|
+
- lib/speedrun/trimmer.rb
|
|
116
|
+
- lib/speedrun/version.rb
|
|
117
|
+
- sig/speedrun.rbs
|
|
118
|
+
homepage: https://github.com/benjaminjackson/speedrun
|
|
119
|
+
licenses:
|
|
120
|
+
- MIT
|
|
121
|
+
metadata:
|
|
122
|
+
source_code_uri: https://github.com/benjaminjackson/speedrun
|
|
123
|
+
changelog_uri: https://github.com/benjaminjackson/speedrun/blob/main/CHANGELOG.md
|
|
124
|
+
rdoc_options: []
|
|
125
|
+
require_paths:
|
|
126
|
+
- lib
|
|
127
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
128
|
+
requirements:
|
|
129
|
+
- - ">="
|
|
130
|
+
- !ruby/object:Gem::Version
|
|
131
|
+
version: 3.2.0
|
|
132
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
133
|
+
requirements:
|
|
134
|
+
- - ">="
|
|
135
|
+
- !ruby/object:Gem::Version
|
|
136
|
+
version: '0'
|
|
137
|
+
requirements: []
|
|
138
|
+
rubygems_version: 3.7.2
|
|
139
|
+
specification_version: 4
|
|
140
|
+
summary: Detect and remove freeze/low-motion regions from videos
|
|
141
|
+
test_files: []
|