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 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
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
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://immutable.terminalwire.com/NgTt6nzO1aEnExV8j6ODuKt2iZpY74ZF8ecpUSCp4A0tXA0ErpJIS4cdMX0tQQKOWwZSl65jWnpzpgCLJThhhWtZJGr42XKt7WIi.png)](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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HLS
4
+ VERSION = "0.1.0"
5
+ end
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
@@ -0,0 +1,4 @@
1
+ module HLS
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
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: []