mp4-rb 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.
Files changed (155) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +24 -0
  3. data/Gemfile.lock +149 -0
  4. data/README.md +195 -0
  5. data/Rakefile +8 -0
  6. data/bin/build_mp4_xlsx.rb +35 -0
  7. data/bin/divide_mp4_to_segments.rb +52 -0
  8. data/lib/m4s/binary/ftyp.rb +11 -0
  9. data/lib/m4s/binary/mdat.rb +0 -0
  10. data/lib/m4s/binary/mfhd.rb +18 -0
  11. data/lib/m4s/binary/mvhd.rb +39 -0
  12. data/lib/m4s/binary/sidx.rb +38 -0
  13. data/lib/m4s/binary/styp.rb +17 -0
  14. data/lib/m4s/binary/tfdt.rb +25 -0
  15. data/lib/m4s/binary/tfhd.rb +38 -0
  16. data/lib/m4s/binary/trex.rb +25 -0
  17. data/lib/m4s/binary/trun.rb +30 -0
  18. data/lib/m4s/dash/manifest.rb +149 -0
  19. data/lib/m4s/layout/atom.rb +44 -0
  20. data/lib/m4s/layout/builder.rb +32 -0
  21. data/lib/m4s/layout/container.rb +59 -0
  22. data/lib/m4s/layout/ctts.rb +20 -0
  23. data/lib/m4s/layout/dinf.rb +17 -0
  24. data/lib/m4s/layout/elst.rb +33 -0
  25. data/lib/m4s/layout/ftyp.rb +21 -0
  26. data/lib/m4s/layout/hdlr.rb +22 -0
  27. data/lib/m4s/layout/init_mp4_builder.rb +57 -0
  28. data/lib/m4s/layout/mdat.rb +61 -0
  29. data/lib/m4s/layout/mdhd.rb +25 -0
  30. data/lib/m4s/layout/mfhd.rb +17 -0
  31. data/lib/m4s/layout/mvhd.rb +32 -0
  32. data/lib/m4s/layout/sidx.rb +36 -0
  33. data/lib/m4s/layout/stco.rb +21 -0
  34. data/lib/m4s/layout/stsc.rb +20 -0
  35. data/lib/m4s/layout/stsd.rb +17 -0
  36. data/lib/m4s/layout/stss.rb +20 -0
  37. data/lib/m4s/layout/stsz.rb +19 -0
  38. data/lib/m4s/layout/stts.rb +20 -0
  39. data/lib/m4s/layout/styp.rb +17 -0
  40. data/lib/m4s/layout/tfdt.rb +21 -0
  41. data/lib/m4s/layout/tfhd.rb +17 -0
  42. data/lib/m4s/layout/tkhd.rb +34 -0
  43. data/lib/m4s/layout/trex.rb +33 -0
  44. data/lib/m4s/layout/trun.rb +89 -0
  45. data/lib/m4s/layout/vsmhd.rb +51 -0
  46. data/lib/m4s/m3u8/general_playlist.rb +52 -0
  47. data/lib/m4s/m3u8/media_playlist.rb +32 -0
  48. data/lib/mp4/binary/avc1.rb +98 -0
  49. data/lib/mp4/binary/box_head.rb +8 -0
  50. data/lib/mp4/binary/co64.rb +11 -0
  51. data/lib/mp4/binary/codec.rb +8 -0
  52. data/lib/mp4/binary/ctts.rb +14 -0
  53. data/lib/mp4/binary/ctts_reader.rb +5 -0
  54. data/lib/mp4/binary/dinf.rb +5 -0
  55. data/lib/mp4/binary/dref.rb +5 -0
  56. data/lib/mp4/binary/elst.rb +16 -0
  57. data/lib/mp4/binary/fixed_point16.rb +19 -0
  58. data/lib/mp4/binary/fixed_point8.rb +19 -0
  59. data/lib/mp4/binary/ftyp.rb +9 -0
  60. data/lib/mp4/binary/gmhd.rb +5 -0
  61. data/lib/mp4/binary/hdlr.rb +13 -0
  62. data/lib/mp4/binary/hev1.rb +73 -0
  63. data/lib/mp4/binary/hmhd.rb +14 -0
  64. data/lib/mp4/binary/iods.rb +5 -0
  65. data/lib/mp4/binary/mdhd.rb +18 -0
  66. data/lib/mp4/binary/mp4a.rb +76 -0
  67. data/lib/mp4/binary/mvhd.rb +39 -0
  68. data/lib/mp4/binary/nmhd.rb +8 -0
  69. data/lib/mp4/binary/saio.rb +25 -0
  70. data/lib/mp4/binary/saiz.rb +17 -0
  71. data/lib/mp4/binary/sbgp.rb +5 -0
  72. data/lib/mp4/binary/sdtp.rb +18 -0
  73. data/lib/mp4/binary/senc.rb +12 -0
  74. data/lib/mp4/binary/sgpd.rb +5 -0
  75. data/lib/mp4/binary/smhd.rb +11 -0
  76. data/lib/mp4/binary/stco.rb +13 -0
  77. data/lib/mp4/binary/stco_reader.rb +5 -0
  78. data/lib/mp4/binary/sthd.rb +8 -0
  79. data/lib/mp4/binary/stsc.rb +15 -0
  80. data/lib/mp4/binary/stsc_reader.rb +5 -0
  81. data/lib/mp4/binary/stsd.rb +5 -0
  82. data/lib/mp4/binary/stsd_atom.rb +11 -0
  83. data/lib/mp4/binary/stsd_reader.rb +5 -0
  84. data/lib/mp4/binary/stss.rb +13 -0
  85. data/lib/mp4/binary/stss_reader.rb +5 -0
  86. data/lib/mp4/binary/stsz.rb +18 -0
  87. data/lib/mp4/binary/stsz_reader.rb +5 -0
  88. data/lib/mp4/binary/stts.rb +14 -0
  89. data/lib/mp4/binary/stts_reader.rb +5 -0
  90. data/lib/mp4/binary/tkhd.rb +46 -0
  91. data/lib/mp4/binary/udta.rb +5 -0
  92. data/lib/mp4/binary/vmhd.rb +11 -0
  93. data/lib/mp4/cache/memory.rb +328 -0
  94. data/lib/mp4/cache/sqlite.rb +348 -0
  95. data/lib/mp4/cache.rb +13 -0
  96. data/lib/mp4/constants.rb +87 -0
  97. data/lib/mp4/ext/refinements.rb +19 -0
  98. data/lib/mp4/layout/atom.rb +47 -0
  99. data/lib/mp4/layout/base.rb +10 -0
  100. data/lib/mp4/layout/builder.rb +16 -0
  101. data/lib/mp4/layout/container.rb +59 -0
  102. data/lib/mp4/layout/ctts.rb +35 -0
  103. data/lib/mp4/layout/dinf.rb +15 -0
  104. data/lib/mp4/layout/elst.rb +33 -0
  105. data/lib/mp4/layout/free.rb +20 -0
  106. data/lib/mp4/layout/ftyp.rb +21 -0
  107. data/lib/mp4/layout/hdlr.rb +20 -0
  108. data/lib/mp4/layout/mdat.rb +33 -0
  109. data/lib/mp4/layout/mdhd.rb +27 -0
  110. data/lib/mp4/layout/moov.rb +58 -0
  111. data/lib/mp4/layout/mvhd.rb +46 -0
  112. data/lib/mp4/layout/stco.rb +37 -0
  113. data/lib/mp4/layout/stsc.rb +27 -0
  114. data/lib/mp4/layout/stsd.rb +15 -0
  115. data/lib/mp4/layout/stss.rb +22 -0
  116. data/lib/mp4/layout/stsz.rb +39 -0
  117. data/lib/mp4/layout/stts.rb +35 -0
  118. data/lib/mp4/layout/tkhd.rb +45 -0
  119. data/lib/mp4/layout/vsmhd.rb +49 -0
  120. data/lib/mp4/layout.rb +3 -0
  121. data/lib/mp4/metadata/memoize/ftyp.rb +13 -0
  122. data/lib/mp4/metadata/memoize/stbl/binary_stsd.rb +13 -0
  123. data/lib/mp4/metadata/memoize/stbl/chunks.rb +46 -0
  124. data/lib/mp4/metadata/memoize/stbl/codec.rb +82 -0
  125. data/lib/mp4/metadata/memoize/stbl/composition_offset_calculator.rb +31 -0
  126. data/lib/mp4/metadata/memoize/stbl/delta_calculator.rb +29 -0
  127. data/lib/mp4/metadata/memoize/stbl/sample_to_chunk_calculator.rb +63 -0
  128. data/lib/mp4/metadata/memoize/stbl/samples.rb +80 -0
  129. data/lib/mp4/metadata/memoize/track.rb +98 -0
  130. data/lib/mp4/metadata/memoize/tracks.rb +11 -0
  131. data/lib/mp4/metadata/service.rb +30 -0
  132. data/lib/mp4/metadata/summary.rb +11 -0
  133. data/lib/mp4/nolan/service/sound.rb +36 -0
  134. data/lib/mp4/nolan/service/track.rb +9 -0
  135. data/lib/mp4/nolan/service/video.rb +35 -0
  136. data/lib/mp4/nolan/service.rb +84 -0
  137. data/lib/mp4/nolan.rb +3 -0
  138. data/lib/mp4/parser/main_boxes.rb +43 -0
  139. data/lib/mp4/parser/moov.rb +40 -0
  140. data/lib/mp4/parser/service.rb +47 -0
  141. data/lib/mp4/parser/track.rb +56 -0
  142. data/lib/mp4/rb/version.rb +7 -0
  143. data/lib/mp4/rb.rb +10 -0
  144. data/lib/mp4/source/base.rb +20 -0
  145. data/lib/mp4/source/local.rb +79 -0
  146. data/lib/mp4/source/s3.rb +118 -0
  147. data/lib/mp4/source/url.rb +130 -0
  148. data/lib/mp4/source.rb +23 -0
  149. data/lib/mp4/structure/service.rb +13 -0
  150. data/lib/mp4/xlsx/builder.rb +44 -0
  151. data/lib/mp4.rb +222 -0
  152. data/mp4-rb.gemspec +68 -0
  153. data/sig/mp4/rb.rbs +6 -0
  154. data/todo.txt +4 -0
  155. metadata +323 -0
@@ -0,0 +1,36 @@
1
+ module MP4::Nolan
2
+ module Service::Sound
3
+ module_function
4
+
5
+ def compute(cache, segment_index, track:, from_timestamp:, to_timestamp:, sample_timescale:)
6
+ track_id = track.fetch('track_id')
7
+ relative_from = from_timestamp || 0
8
+
9
+ min_chunk_offset = 0
10
+ current_chunk_index = 0
11
+
12
+ cache.samples_between_ms(track_id, relative_from, to_timestamp).each_with_index do |original_sample, sample_index_incr|
13
+ if current_chunk_index != original_sample.fetch('chunk_index')
14
+ current_chunk_index = original_sample.fetch('chunk_index')
15
+ min_chunk_offset = original_sample.fetch('offset')
16
+ end
17
+
18
+ cache.add_segment_sample(
19
+ track_id: track_id,
20
+ segment_index: segment_index,
21
+ chunk_index: original_sample.fetch('chunk_index'),
22
+ sample_index: sample_index_incr.succ,
23
+ sample_size: original_sample.fetch('size'),
24
+ sample_offset: original_sample.fetch('offset'),
25
+ chunk_offset: min_chunk_offset,
26
+ original_chunk_offset: min_chunk_offset,
27
+ sample_delta: original_sample.fetch('delta'),
28
+ delta_sum: original_sample.fetch('delta_sum'),
29
+ absolute_delta_sum: 0,
30
+ composition_offset: original_sample.fetch('c_delta'),
31
+ is_keyframe: original_sample.fetch('keyframe'),
32
+ )
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,9 @@
1
+ module MP4::Nolan
2
+ module Service::Track
3
+ module_function
4
+
5
+ def copy(dest_cache, track)
6
+ dest_cache.add_track(**track.transform_keys(&:to_sym))
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,35 @@
1
+ module MP4::Nolan
2
+ module Service::Video
3
+ module_function
4
+
5
+ def compute(cache, segment_index, track:, from_timestamp:, to_timestamp:, sample_timescale:)
6
+ track_id = track.fetch('track_id')
7
+
8
+ min_chunk_offset = 0
9
+ current_chunk_index = 0
10
+
11
+ cache.samples_between_ms(track_id, from_timestamp, to_timestamp).each_with_index do |original_sample, sample_index_incr|
12
+ if current_chunk_index != original_sample.fetch('chunk_index')
13
+ current_chunk_index = original_sample.fetch('chunk_index')
14
+ min_chunk_offset = original_sample.fetch('offset')
15
+ end
16
+
17
+ cache.add_segment_sample(
18
+ track_id: track_id,
19
+ segment_index: segment_index,
20
+ chunk_index: original_sample.fetch('chunk_index'),
21
+ sample_index: sample_index_incr.succ,
22
+ sample_size: original_sample.fetch('size'),
23
+ sample_offset: original_sample.fetch('offset'),
24
+ chunk_offset: min_chunk_offset,
25
+ original_chunk_offset: min_chunk_offset,
26
+ sample_delta: original_sample.fetch('delta'),
27
+ delta_sum: original_sample.fetch('delta_sum'),
28
+ absolute_delta_sum: 0,
29
+ composition_offset: original_sample.fetch('c_delta'),
30
+ is_keyframe: original_sample.fetch('keyframe'),
31
+ )
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,84 @@
1
+ module MP4::Nolan
2
+ module Service
3
+ module_function
4
+
5
+ def divide_into_segments(cache)
6
+ segments = compute_segments(cache)
7
+
8
+ segments.each_with_index do |segment_args, segment_index|
9
+ compile_segment(cache, segment_index, *segment_args)
10
+ end
11
+
12
+ nil
13
+ end
14
+
15
+ def compute_segments(cache)
16
+ track_id = cache.first_video_track_id
17
+ return [] if track_id.nil?
18
+
19
+ video_track = cache.track(track_id)
20
+ timescale = video_track.fetch('timescale')
21
+
22
+ divide_video_by_keyframes(cache, track_id, timescale)
23
+ end
24
+
25
+ def compile_segment(cache, segment_index, _from_keyframe, _to_keyframe, segment_start_timestamp, segment_end_timestamp, _duration, previous_chunk_end_delta_sum, sample_timescale)
26
+ cache.tracks.each do |track|
27
+ case track['handler_type']
28
+ when 'vide'
29
+ Video.compute(cache, segment_index,
30
+ track: track,
31
+ from_timestamp: segment_start_timestamp,
32
+ to_timestamp: segment_end_timestamp,
33
+ sample_timescale: sample_timescale,)
34
+ when 'soun'
35
+ Sound.compute(cache, segment_index,
36
+ track: track,
37
+ from_timestamp: previous_chunk_end_delta_sum,
38
+ to_timestamp: segment_end_timestamp,
39
+ sample_timescale: sample_timescale,)
40
+ else
41
+ MP4.logger.warn "Nolan::Service: skipping unsupported track #{track['handler_type']}"
42
+ end
43
+ end
44
+ end
45
+
46
+ def divide_video_by_keyframes(cache, track_id, timescale)
47
+ first_keyframe = cache.first_sample(track_id)
48
+ latest_keyframe = cache.last_sample(track_id)
49
+ return [] if first_keyframe.nil? || latest_keyframe.nil?
50
+
51
+ original_keyframes = cache.keyframes_grouped_by_second(track_id)
52
+ return [] if original_keyframes.empty?
53
+
54
+ chunks = []
55
+ last_index = original_keyframes.size - 1
56
+
57
+ original_keyframes.each_with_index do |keyframe, idx|
58
+ keyframes =
59
+ if idx == last_index
60
+ [keyframe.fetch('sample_index'), latest_keyframe.fetch('sample_index')]
61
+ elsif idx.zero? && keyframe.fetch('delta_sum').to_i.nonzero?
62
+ [first_keyframe.fetch('sample_index'), keyframe.fetch('sample_index')]
63
+ else
64
+ [keyframe.fetch('sample_index'), original_keyframes[idx + 1].fetch('sample_index') - 1]
65
+ end
66
+
67
+ previous_chunk_end_delta_sum = chunks.dig(-1, 3)
68
+ duration = cache.sum_delta_between(track_id, keyframes.first, keyframes.last).to_f
69
+
70
+ chunks << [
71
+ keyframes.fetch(0),
72
+ keyframes.fetch(1),
73
+ cache.sample_delta_sum_ms(track_id, keyframes.first).to_i,
74
+ cache.sample_delta_sum_ms(track_id, keyframes.last).to_i,
75
+ (duration / timescale.to_f).round(3),
76
+ previous_chunk_end_delta_sum,
77
+ timescale,
78
+ ]
79
+ end
80
+
81
+ chunks
82
+ end
83
+ end
84
+ end
data/lib/mp4/nolan.rb ADDED
@@ -0,0 +1,3 @@
1
+ module MP4::Nolan
2
+ # Namespace method
3
+ end
@@ -0,0 +1,43 @@
1
+ module MP4::Parser
2
+ module MainBoxes
3
+ class BoxAlreadyDefinedError < StandardError; end
4
+ class UnnamedBoxFoundError < StandardError; end
5
+
6
+ module_function
7
+
8
+ def parse(source, _offset = 0, _nested = [])
9
+ struct = {}
10
+
11
+ 10.times do
12
+ break if source.eof?
13
+
14
+ current_offset = source.pos
15
+ header = get_header(source)
16
+ box_name = header[:box_name]
17
+ box_size = header[:box_size]
18
+
19
+ raise BoxAlreadyDefinedError.new(message: "Box #{box_name} already defined") if struct.key?(box_name)
20
+ raise UnnamedBoxFoundError.new(message: "Box #{box_name} already defined") if box_name.empty?
21
+
22
+ struct[box_name.to_sym] = [current_offset, box_size]
23
+
24
+ source.set_seek(current_offset + box_size)
25
+ end
26
+
27
+ struct
28
+ end
29
+
30
+ def get_header(source)
31
+ bytes = source.read(8)
32
+ box_size = BinData::Uint32be.read(bytes[0..4])
33
+ box_name = bytes[4..8]
34
+ ::MP4.logger.debug { "top-level box: #{box_name}/#{box_size}" } if ::MP4.verbose?
35
+ if box_size == 1
36
+ extra_size_bytes = source.read(8)
37
+ box_size = BinData::Uint64be.read(extra_size_bytes)
38
+ end
39
+
40
+ { box_size: box_size, box_name: box_name }
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,40 @@
1
+ module MP4::Parser
2
+ class Moov
3
+ attr_reader :bytes, :mvhd, :tracks
4
+
5
+ def self.parse(bytes)
6
+ metadata = new(bytes)
7
+ metadata.parse
8
+ metadata
9
+ end
10
+
11
+ def initialize(bytes)
12
+ @bytes = bytes
13
+ @tracks = []
14
+ end
15
+
16
+ def parse
17
+ _header = bytes.read(8)
18
+
19
+ until bytes.eof?
20
+ box_size = BinData::Uint32be.read(bytes.read(4))
21
+ box_type = bytes.read(4)
22
+ box_bytes = StringIO.new(bytes.read(box_size - MP4::Constants::GENERIC_HEADER_SIZE))
23
+
24
+ send("parse_#{box_type}", box_bytes)
25
+ end
26
+ end
27
+
28
+ def parse_mvhd(bytes)
29
+ @mvhd = MP4::Binary::Mvhd.read(bytes).snapshot
30
+ end
31
+
32
+ def parse_trak(bytes)
33
+ @tracks << MP4::Parser::Track.parse(bytes)
34
+ end
35
+
36
+ def parse_iods(_bytes); end
37
+
38
+ def parse_udta(_bytes); end
39
+ end
40
+ end
@@ -0,0 +1,47 @@
1
+ module MP4::Parser
2
+ class Service
3
+ attr_reader :source, :ftyp, :moov, :mdat
4
+
5
+ def initialize(source:)
6
+ @source = source
7
+ end
8
+
9
+ def call
10
+ parse
11
+ end
12
+
13
+ def parse
14
+ main_boxes = MainBoxes.parse(source)
15
+ parse_ftyp(*main_boxes.fetch(:ftyp))
16
+ parse_moov(*main_boxes.fetch(:moov))
17
+ parse_mdat(*main_boxes.fetch(:mdat))
18
+
19
+ self
20
+ end
21
+
22
+ def parse_ftyp(offset, box_size)
23
+ source.set_seek(offset + MP4::Constants::GENERIC_HEADER_SIZE)
24
+
25
+ @ftyp = MP4::Binary::Ftyp.read(
26
+ StringIO.new(source.read(box_size - MP4::Constants::GENERIC_HEADER_SIZE)),
27
+ ).snapshot
28
+ end
29
+
30
+ def parse_moov(offset, box_size)
31
+ source.set_seek(offset)
32
+
33
+ @moov = Moov.parse(
34
+ StringIO.new(source.read(box_size)),
35
+ )
36
+ end
37
+
38
+ def parse_mdat(offset, _box_size)
39
+ @mdat = { offset: offset }
40
+ end
41
+ end
42
+ end
43
+
44
+ # require_relative './lib/constants'
45
+ # require_relative './lib/main_boxes'
46
+ # require_relative './lib/moov'
47
+ # require_relative './lib/recursive_parser'
@@ -0,0 +1,56 @@
1
+ module MP4::Parser
2
+ class Track
3
+ attr_reader :bytes
4
+
5
+ def self.parse(bytes)
6
+ new(bytes).parse
7
+ end
8
+
9
+ def initialize(bytes)
10
+ @bytes = bytes
11
+ end
12
+
13
+ def parse
14
+ parse_recursive
15
+ end
16
+
17
+ private
18
+
19
+ def parse_recursive(nested_bytes = nil, list = [])
20
+ structure = []
21
+ nested_bytes ||= bytes
22
+
23
+ loop do
24
+ box_bytes = nested_bytes
25
+ break if box_bytes.eof? || box_bytes.nil?
26
+
27
+ box_offset = box_bytes.pos
28
+ box_size = BinData::Uint32be.read(box_bytes.read(4))
29
+ box_type = box_bytes.read(4)
30
+
31
+ box_bytes = StringIO.new(box_bytes.read(box_size - MP4::Constants::GENERIC_HEADER_SIZE))
32
+
33
+ structure << [box_type, box_offset, box_size, box_bytes]
34
+ end
35
+
36
+ structure.each_with_object({}) do |(box_type, _box_offset, _box_size, box_bytes), buffer|
37
+ ::MP4.logger.debug { "parsing atom: #{list.join('->')} #{box_type}" } if ::MP4.verbose?
38
+
39
+ buffer[box_type.to_sym] = if MP4::Constants::PARSERS.key?(box_type)
40
+ parse_atom(box_type, box_bytes)
41
+ else
42
+ parse_recursive(box_bytes, [*list, box_type])
43
+ end
44
+ end
45
+ end
46
+
47
+ def parse_atom(box_type, box_bytes)
48
+ MP4::Constants::PARSERS.fetch(box_type)
49
+
50
+ serializer_klass = Kernel.const_get(MP4::Constants::PARSERS.fetch(box_type))
51
+
52
+ serializer_klass.read(box_bytes).snapshot
53
+ end
54
+
55
+ end
56
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mp4
4
+ module Rb
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/mp4/rb.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rb/version"
4
+
5
+ module Mp4
6
+ module Rb
7
+ class Error < StandardError; end
8
+ # Your code goes here...
9
+ end
10
+ end
@@ -0,0 +1,20 @@
1
+ module MP4::Source
2
+ module Base
3
+ REQUIRED_METHODS = %i[read set_seek reset_seek open! eof? pos uri].freeze
4
+
5
+ def self.included(klass)
6
+ klass.extend(ClassMethods)
7
+ end
8
+
9
+ module ClassMethods
10
+ def from_uri(_uri)
11
+ raise NotImplementedError, "#{name}.from_uri must be implemented"
12
+ end
13
+ end
14
+
15
+ def fetch(from, to)
16
+ set_seek(from)
17
+ read(to - from + 1)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,79 @@
1
+ module MP4::Source
2
+ class Local
3
+ include Base
4
+
5
+ attr_reader :path_to_file
6
+
7
+ def self.from_uri(uri, base_dir: nil)
8
+ raise ArgumentError, "expected local: URI, got #{uri.inspect}" unless uri.start_with?('local:')
9
+
10
+ path = uri.sub(/\Alocal:/, '')
11
+ resolved = resolve_path(path, base_dir)
12
+ new(resolved.to_s)
13
+ end
14
+
15
+ def self.resolve_path(path, base_dir)
16
+ candidate = Pathname.new(path)
17
+ return candidate if base_dir.nil?
18
+
19
+ root = Pathname.new(base_dir).expand_path
20
+ absolute = (candidate.absolute? ? candidate : root.join(candidate)).expand_path
21
+
22
+ unless absolute.to_s.start_with?(root.to_s + File::SEPARATOR) || absolute.to_s == root.to_s
23
+ raise ArgumentError, "path escapes base_dir: #{path.inspect}"
24
+ end
25
+
26
+ absolute
27
+ end
28
+
29
+ def initialize(path_to_file)
30
+ @path_to_file = path_to_file.to_s
31
+ end
32
+
33
+ def uri
34
+ "local:#{@path_to_file}"
35
+ end
36
+
37
+ def read(n)
38
+ file.read(n)
39
+ end
40
+
41
+ def set_seek(n)
42
+ file.seek(n)
43
+ end
44
+
45
+ def reset_seek
46
+ file.seek(0)
47
+ end
48
+
49
+ def open!
50
+ file
51
+ self
52
+ end
53
+
54
+ def eof?
55
+ file.eof?
56
+ end
57
+
58
+ def pos
59
+ file.pos
60
+ end
61
+
62
+ def mtime
63
+ File.mtime(@path_to_file)
64
+ rescue Errno::ENOENT
65
+ nil
66
+ end
67
+
68
+ def close
69
+ @file&.close
70
+ @file = nil
71
+ end
72
+
73
+ private
74
+
75
+ def file
76
+ @file ||= File.open(@path_to_file, 'rb')
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,118 @@
1
+ module MP4::Source
2
+ class S3
3
+ include Base
4
+
5
+ attr_reader :bucket, :key
6
+
7
+ class << self
8
+ def from_uri(uri, client: nil)
9
+ match = uri.match(%r{\As3://(?<bucket>[^/]+)/(?<key>.+)\z})
10
+ raise ArgumentError, "expected s3://BUCKET/KEY URI, got #{uri.inspect}" unless match
11
+
12
+ new(bucket: match[:bucket], key: match[:key], client: client)
13
+ end
14
+
15
+ def shared_client
16
+ @shared_client_mutex ||= Mutex.new
17
+ @shared_client_mutex.synchronize do
18
+ @shared_client ||= build_client
19
+ end
20
+ end
21
+
22
+ def reset_shared_client!
23
+ @shared_client_mutex&.synchronize { @shared_client = nil }
24
+ end
25
+
26
+ private
27
+
28
+ def build_client
29
+ require 'aws-sdk-s3'
30
+ options = {
31
+ region: ENV.fetch('AWS_REGION', 'us-east-1'),
32
+ http_open_timeout: Integer(ENV.fetch('S3_HTTP_OPEN_TIMEOUT', '5')),
33
+ http_read_timeout: Integer(ENV.fetch('S3_HTTP_READ_TIMEOUT', '30')),
34
+ http_idle_timeout: Integer(ENV.fetch('S3_HTTP_IDLE_TIMEOUT', '30')),
35
+ }
36
+ endpoint = ENV['MINIO_ENDPOINT'] || ENV['S3_ENDPOINT']
37
+ if endpoint
38
+ options[:endpoint] = endpoint
39
+ options[:force_path_style] = true
40
+ end
41
+ access = ENV['MINIO_ACCESS_KEY_ID'] || ENV['AWS_ACCESS_KEY_ID']
42
+ secret = ENV['MINIO_SECRET_ACCESS_KEY'] || ENV['AWS_SECRET_ACCESS_KEY']
43
+ if access && secret
44
+ options[:access_key_id] = access
45
+ options[:secret_access_key] = secret
46
+ end
47
+ Aws::S3::Client.new(**options)
48
+ end
49
+ end
50
+
51
+ def initialize(bucket:, key:, client: nil)
52
+ @bucket = bucket
53
+ @key = key
54
+ @client = client
55
+ @position = 0
56
+ @file_size = nil
57
+ end
58
+
59
+ def uri
60
+ "s3://#{@bucket}/#{@key}"
61
+ end
62
+
63
+ def open!
64
+ size
65
+ self
66
+ end
67
+
68
+ def read(n)
69
+ return ''.b if eof?
70
+
71
+ to = [@position + n - 1, size - 1].min
72
+ data = get_range(@position, to)
73
+ @position += data.bytesize
74
+ data
75
+ end
76
+
77
+ def fetch(from, to)
78
+ get_range(from, to)
79
+ end
80
+
81
+ def set_seek(n)
82
+ @position = n.clamp(0, size)
83
+ end
84
+
85
+ def reset_seek
86
+ @position = 0
87
+ end
88
+
89
+ def eof?
90
+ @position >= size
91
+ end
92
+
93
+ def pos
94
+ @position
95
+ end
96
+
97
+ def size
98
+ @file_size ||= client.head_object(bucket: @bucket, key: @key).content_length.to_i
99
+ end
100
+
101
+ def close
102
+ # No per-instance connection to close; connections live in the shared
103
+ # client's connection pool and are reused across sources.
104
+ nil
105
+ end
106
+
107
+ private
108
+
109
+ def client
110
+ @client ||= self.class.shared_client
111
+ end
112
+
113
+ def get_range(from, to)
114
+ resp = client.get_object(bucket: @bucket, key: @key, range: "bytes=#{from}-#{to}")
115
+ resp.body.read.b
116
+ end
117
+ end
118
+ end