hls 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/.rspec +3 -0
- data/README.md +55 -0
- data/Rakefile +8 -0
- data/examples/directory.rb +65 -0
- data/lib/hls/version.rb +5 -0
- data/lib/hls.rb +212 -0
- data/sig/hls.rbs +4 -0
- metadata +80 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: bc3a9e8f4508dbdc33d12b7ce2665d5e455421992520bf6ee36834bf936e2c8c
|
4
|
+
data.tar.gz: 86aa27e58a352a2eb6cab7d368ecc546db4b6b8a2e70b14e827d4185bc42e6df
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 44c7aa81f15a22e0edf70821a506c3b87102bd05140814ff9ecf0f15153264cc7d5ea68c2cfb4610426084df37fea91398125f1f0b95a31b126328211b69f464
|
7
|
+
data.tar.gz: 172a06401b9c59d6e5b3781895893406f2ebdae6d78e55ece3643de3a9648438c0699e7caf382420de1fed78cf8335d37cf8f579d6ecb6112a023b6527fccc56
|
data/.rspec
ADDED
data/README.md
ADDED
@@ -0,0 +1,55 @@
|
|
1
|
+
# HLS
|
2
|
+
|
3
|
+
When I started working on the [Phlex on Rails video course](https://beautifulruby.com/phlex), I tried streaming mp4 files from an S3 compatible object store and quickly found out from users they were running into issues watching the video. I added to use [HLS](https://en.wikipedia.org/wiki/HTTP_Live_Streaming), but I quickly found out it's a bit of a pain setting that up on a private object store.
|
4
|
+
|
5
|
+
## Why?
|
6
|
+
|
7
|
+
Creating & serving HLS videos from private object stores is tricky.
|
8
|
+
|
9
|
+
### Sane encoding defaults
|
10
|
+
|
11
|
+
When you encode a video into HLS format, it cranks out different resolutions and bitrates that play on everything from mobile phones to TVs. You give it an input video and it writes out all the chunks into a directory.
|
12
|
+
|
13
|
+
### Generates pre-signed URLs in m3u8 playlists
|
14
|
+
|
15
|
+
The most annoying part about serving HLS videos from private object stores is generating pre-signed URLs for each chunk. This gem generates pre-signed URLs for each chunk in the m3u8 playlist, making it easy to serve HLS videos from private object stores.
|
16
|
+
|
17
|
+
### Rails integration
|
18
|
+
|
19
|
+
When a user requests a video, you probably want a controller that determines whether they have access to the video. If they do, the Rails integration will request a manifest file stored along side your video to generate the pre-signed URLs for each chunk.
|
20
|
+
|
21
|
+
Generators will also be included that pin an HLS polyfill for browsers that don't support HLS natively, like Chrome and Firefox.
|
22
|
+
|
23
|
+
## Support
|
24
|
+
|
25
|
+
Consider [buying a video course from Beautiful Ruby](https://beautifulruby.com) and learn a thing or two to keep the machine going that originally built this gem.
|
26
|
+
|
27
|
+
[](https://beautifulruby.com/phlex/forms/overview)
|
28
|
+
|
29
|
+
## Installation
|
30
|
+
|
31
|
+
Install the gem and add to the application's Gemfile by executing:
|
32
|
+
|
33
|
+
```bash
|
34
|
+
bundle add hls
|
35
|
+
```
|
36
|
+
|
37
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
38
|
+
|
39
|
+
```bash
|
40
|
+
gem install hls
|
41
|
+
```
|
42
|
+
|
43
|
+
## Usage
|
44
|
+
|
45
|
+
TODO: Write usage instructions here when the gem is finished
|
46
|
+
|
47
|
+
## Development
|
48
|
+
|
49
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
50
|
+
|
51
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
52
|
+
|
53
|
+
## Contributing
|
54
|
+
|
55
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/beautifulruby/hls.
|
data/Rakefile
ADDED
@@ -0,0 +1,65 @@
|
|
1
|
+
require "bundler/inline"
|
2
|
+
|
3
|
+
gemfile do
|
4
|
+
source "https://rubygems.org"
|
5
|
+
|
6
|
+
gem "hls", path: ".."
|
7
|
+
gem "parallel"
|
8
|
+
end
|
9
|
+
|
10
|
+
require "fileutils"
|
11
|
+
require "pathname"
|
12
|
+
require "etc"
|
13
|
+
require "shellwords"
|
14
|
+
require "uri"
|
15
|
+
|
16
|
+
storage = Pathname.new("/Users/bradgessler/Desktop")
|
17
|
+
source = storage.join("Exports")
|
18
|
+
destination = storage.join("Uploads")
|
19
|
+
|
20
|
+
CONCURRENCY = Etc.nprocessors / 2
|
21
|
+
|
22
|
+
class Jobs
|
23
|
+
include Enumerable
|
24
|
+
|
25
|
+
def initialize
|
26
|
+
@jobs = []
|
27
|
+
end
|
28
|
+
|
29
|
+
def schedule(&job)
|
30
|
+
@jobs << job
|
31
|
+
end
|
32
|
+
|
33
|
+
def render(rendition)
|
34
|
+
schedule do
|
35
|
+
ffmpeg rendition
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def process(in_processes: CONCURRENCY, **options)
|
40
|
+
Parallel.each(@jobs, in_processes: in_processes, &:call)
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def ffmpeg(task)
|
46
|
+
cmd = task.command.map(&:to_s)
|
47
|
+
puts "[#{task.class.name}] Running: #{Shellwords.join(cmd)}"
|
48
|
+
system(*cmd)
|
49
|
+
puts "[#{task.class.name}] Done"
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
jobs = Jobs.new
|
54
|
+
|
55
|
+
HLS::Directory.new(source).glob("**/*.mp4").each do |input, path|
|
56
|
+
output = destination.join(path)
|
57
|
+
FileUtils.mkdir_p(output)
|
58
|
+
|
59
|
+
package = HLS::Video::Web.new(input:, output:)
|
60
|
+
puts "Processing renditions for: #{input}"
|
61
|
+
|
62
|
+
jobs.render package
|
63
|
+
end
|
64
|
+
|
65
|
+
jobs.process
|
data/lib/hls/version.rb
ADDED
data/lib/hls.rb
ADDED
@@ -0,0 +1,212 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "hls/version"
|
4
|
+
require "shellwords"
|
5
|
+
require "pathname"
|
6
|
+
require "json"
|
7
|
+
require "m3u8"
|
8
|
+
|
9
|
+
module HLS
|
10
|
+
class Error < StandardError; end
|
11
|
+
|
12
|
+
class Poster
|
13
|
+
attr_reader :input, :output, :width, :height
|
14
|
+
|
15
|
+
def initialize(input:, output:, width:, height:)
|
16
|
+
@input = input
|
17
|
+
@output = output.join("poster.jpg")
|
18
|
+
@width = width
|
19
|
+
@height = height
|
20
|
+
end
|
21
|
+
|
22
|
+
def command
|
23
|
+
[
|
24
|
+
# invoke ffmpeg
|
25
|
+
"ffmpeg",
|
26
|
+
# overwrite output files without confirmation
|
27
|
+
"-y",
|
28
|
+
# input video file
|
29
|
+
"-i", input,
|
30
|
+
# scale video to target resolution
|
31
|
+
"-vf", "scale=w=#{width}:h=#{height}:force_original_aspect_ratio=decrease",
|
32
|
+
# extract only one frame
|
33
|
+
"-frames:v", "1",
|
34
|
+
# output file path
|
35
|
+
output
|
36
|
+
]
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
module Video
|
41
|
+
class Base
|
42
|
+
attr_accessor :input, :output, :renditions
|
43
|
+
|
44
|
+
PLAYLIST = "index.m3u8".freeze
|
45
|
+
|
46
|
+
Rendition = Data.define(:width, :height, :bitrate)
|
47
|
+
|
48
|
+
def initialize(input:, output:)
|
49
|
+
@input = Input.new(input)
|
50
|
+
@output = output
|
51
|
+
@renditions = []
|
52
|
+
end
|
53
|
+
|
54
|
+
def downscaleable_renditions
|
55
|
+
@renditions.select { |r| r.width <= input.width }
|
56
|
+
end
|
57
|
+
|
58
|
+
def rendition(...)
|
59
|
+
@renditions << Rendition.new(...)
|
60
|
+
end
|
61
|
+
|
62
|
+
def command
|
63
|
+
[
|
64
|
+
"ffmpeg",
|
65
|
+
"-y",
|
66
|
+
"-i", @input.path,
|
67
|
+
"-filter_complex", filter_complex
|
68
|
+
] + \
|
69
|
+
video_maps + \
|
70
|
+
audio_maps + \
|
71
|
+
[
|
72
|
+
"-f", "hls",
|
73
|
+
"-var_stream_map", stream_map,
|
74
|
+
"-master_pl_name", PLAYLIST,
|
75
|
+
"-hls_time", "4",
|
76
|
+
"-hls_playlist_type", "vod",
|
77
|
+
"-hls_segment_filename", segment,
|
78
|
+
playlist
|
79
|
+
]
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
def filter_complex
|
85
|
+
n = downscaleable_renditions.size
|
86
|
+
split = "[0:v]split=#{n}#{(1..n).map { |i| "[v#{i}]" }.join}"
|
87
|
+
scaled = downscaleable_renditions.each_with_index.map do |rendition, i|
|
88
|
+
"[v#{i + 1}]scale='if(gt(iw,#{rendition.width}),#{rendition.width},iw)':'if(gt(iw,#{rendition.width}),-2,ih)'[v#{i + 1}out]"
|
89
|
+
end
|
90
|
+
([split] + scaled).join("; ")
|
91
|
+
end
|
92
|
+
|
93
|
+
def video_maps(codec: "h264_videotoolbox")
|
94
|
+
downscaleable_renditions.each_with_index.flat_map do |rendition, i|
|
95
|
+
[
|
96
|
+
"-map", "[v#{i + 1}out]",
|
97
|
+
"-c:v:#{i}", codec,
|
98
|
+
"-b:v:#{i}", "#{rendition.bitrate}k"
|
99
|
+
]
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def audio_maps(codec: "aac", bitrate: 128)
|
104
|
+
downscaleable_renditions.each_with_index.flat_map do |_, i|
|
105
|
+
[
|
106
|
+
"-map", "a:0",
|
107
|
+
"-c:a:#{i}", codec,
|
108
|
+
"-b:a:#{i}", "#{bitrate}k",
|
109
|
+
"-ac", "2"
|
110
|
+
]
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def stream_map
|
115
|
+
downscaleable_renditions.each_index.map { |i| "v:#{i},a:#{i}" }.join(" ")
|
116
|
+
end
|
117
|
+
|
118
|
+
def variant
|
119
|
+
@output.join("%v")
|
120
|
+
end
|
121
|
+
|
122
|
+
def segment
|
123
|
+
variant.join("%d.ts").to_s
|
124
|
+
end
|
125
|
+
|
126
|
+
def playlist
|
127
|
+
variant.join(PLAYLIST).to_s
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
class Web < Base
|
132
|
+
def initialize(...)
|
133
|
+
super(...)
|
134
|
+
# 360p - Low quality for mobile/slow connections
|
135
|
+
rendition width: 640, height: 360, bitrate: 500
|
136
|
+
# 480p - Standard definition for basic streaming
|
137
|
+
rendition width: 854, height: 480, bitrate: 1000
|
138
|
+
# 720p - High definition for most desktop viewing
|
139
|
+
rendition width: 1280, height: 720, bitrate: 3000
|
140
|
+
# 1080p - Full HD for high-quality streaming
|
141
|
+
rendition width: 1920, height: 1080, bitrate: 6000
|
142
|
+
# 4K - Ultra HD for premium viewing experience
|
143
|
+
rendition width: 3840, height: 2160, bitrate: 12000
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# Do very little work on vidoes so I can get more dev cycles in.
|
148
|
+
class Dev < Base
|
149
|
+
def initialize(...)
|
150
|
+
super(...)
|
151
|
+
# 360p - Low quality for mobile/slow connections
|
152
|
+
rendition width: 640, height: 360, bitrate: 500
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
class Input
|
158
|
+
attr_reader :path, :json
|
159
|
+
|
160
|
+
def initialize(path)
|
161
|
+
@path = Pathname(path)
|
162
|
+
end
|
163
|
+
|
164
|
+
def json
|
165
|
+
@json ||= probe
|
166
|
+
end
|
167
|
+
|
168
|
+
def width = stream.dig(:width)
|
169
|
+
def height = stream.dig(:height)
|
170
|
+
def codec = stream.dig(:codec_name)
|
171
|
+
def duration = json.dig(:format, :duration)&.to_f
|
172
|
+
def framerate = parse_rate(stream.dig(:r_frame_rate))
|
173
|
+
|
174
|
+
private
|
175
|
+
|
176
|
+
def stream
|
177
|
+
json.dig(:streams, 0)
|
178
|
+
end
|
179
|
+
|
180
|
+
def probe
|
181
|
+
raw = `ffprobe -v error -select_streams v:0 \
|
182
|
+
-show_entries stream=width,height,r_frame_rate,codec_name \
|
183
|
+
-show_entries format=duration \
|
184
|
+
-of json "#{@path}"`
|
185
|
+
|
186
|
+
JSON.parse(raw, symbolize_names: true)
|
187
|
+
end
|
188
|
+
|
189
|
+
def parse_rate(rate)
|
190
|
+
return unless rate
|
191
|
+
num, den = rate.split("/").map(&:to_f)
|
192
|
+
return if den.zero?
|
193
|
+
(num / den).round(3)
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
class Directory
|
198
|
+
def initialize(source)
|
199
|
+
@source = Pathname.new(source)
|
200
|
+
end
|
201
|
+
|
202
|
+
def glob(glob)
|
203
|
+
Enumerator.new do |y|
|
204
|
+
@source.glob(glob).each do |input|
|
205
|
+
relative = input.relative_path_from(@source)
|
206
|
+
output = relative.dirname.join(relative.basename(input.extname))
|
207
|
+
y << [ input, output ]
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
data/sig/hls.rbs
ADDED
metadata
ADDED
@@ -0,0 +1,80 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: hls
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Brad Gessler
|
8
|
+
bindir: exe
|
9
|
+
cert_chain: []
|
10
|
+
date: 2025-07-25 00:00:00.000000000 Z
|
11
|
+
dependencies:
|
12
|
+
- !ruby/object:Gem::Dependency
|
13
|
+
name: bigdecimal
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
15
|
+
requirements:
|
16
|
+
- - "~>"
|
17
|
+
- !ruby/object:Gem::Version
|
18
|
+
version: '3.0'
|
19
|
+
type: :runtime
|
20
|
+
prerelease: false
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
22
|
+
requirements:
|
23
|
+
- - "~>"
|
24
|
+
- !ruby/object:Gem::Version
|
25
|
+
version: '3.0'
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: m3u8
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
29
|
+
requirements:
|
30
|
+
- - "~>"
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: 0.8.0
|
33
|
+
type: :runtime
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - "~>"
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: 0.8.0
|
40
|
+
description: Create sane HLS video with a few commands and serve it up from an S3
|
41
|
+
object store.
|
42
|
+
email:
|
43
|
+
- bradgessler@gmail.com
|
44
|
+
executables: []
|
45
|
+
extensions: []
|
46
|
+
extra_rdoc_files: []
|
47
|
+
files:
|
48
|
+
- ".rspec"
|
49
|
+
- README.md
|
50
|
+
- Rakefile
|
51
|
+
- examples/directory.rb
|
52
|
+
- lib/hls.rb
|
53
|
+
- lib/hls/version.rb
|
54
|
+
- sig/hls.rbs
|
55
|
+
homepage: https://github.com/beautifulruby/hls
|
56
|
+
licenses: []
|
57
|
+
metadata:
|
58
|
+
allowed_push_host: https://rubygems.org
|
59
|
+
homepage_uri: https://github.com/beautifulruby/hls
|
60
|
+
source_code_uri: https://github.com/beautifulruby/hls
|
61
|
+
changelog_uri: https://github.com/beautifulruby/hls
|
62
|
+
rdoc_options: []
|
63
|
+
require_paths:
|
64
|
+
- lib
|
65
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
66
|
+
requirements:
|
67
|
+
- - ">="
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: 3.1.0
|
70
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
71
|
+
requirements:
|
72
|
+
- - ">="
|
73
|
+
- !ruby/object:Gem::Version
|
74
|
+
version: '0'
|
75
|
+
requirements: []
|
76
|
+
rubygems_version: 3.6.2
|
77
|
+
specification_version: 4
|
78
|
+
summary: Create sane HLS video with a few commands and serve it up from an S3 object
|
79
|
+
store.
|
80
|
+
test_files: []
|