format_parser 2.3.0 → 2.4.4

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.
@@ -1,353 +0,0 @@
1
- # Handles decoding of MOV/MPEG4 atoms/boxes in a stream. Will recursively
2
- # read atoms and parse their data fields if applicable. Also contains
3
- # a few utility functions for finding atoms in a list etc.
4
- # To know more about Atoms: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html
5
- class FormatParser::MOOVParser::Decoder
6
- include FormatParser::IOUtils
7
-
8
- class Atom < Struct.new(:at, :atom_size, :atom_type, :path, :children, :atom_fields)
9
- def to_s
10
- '%s (%s): %d bytes at offset %d' % [atom_type, path.join('.'), atom_size, at]
11
- end
12
-
13
- def field_value(data_field)
14
- (atom_fields || {}).fetch(data_field)
15
- end
16
-
17
- def as_json(*a)
18
- members.each_with_object({}) do |member_name, o|
19
- o[member_name] = public_send(member_name).as_json(*a)
20
- end
21
- end
22
- end
23
-
24
- # Atoms (boxes) that are known to only contain children, no data fields.
25
- # Avoid including udta or udta.meta here since we do not have methods
26
- # for dealing with them yet.
27
- KNOWN_BRANCH_ATOM_TYPES = %w(moov mdia trak clip edts minf dinf stbl)
28
-
29
- # Mark that udta may contain both
30
- KNOWN_BRANCH_AND_LEAF_ATOM_TYPES = [] # %w(udta) # the udta.meta thing used by iTunes
31
-
32
- # Limit how many atoms we scan in sequence, to prevent derailments
33
- MAX_ATOMS_AT_LEVEL = 128
34
-
35
- # Finds the first atom in the given Array of Atom structs that
36
- # matches the type, drilling down if a list of atom names is given
37
- def find_first_atom_by_path(atoms, *atom_types)
38
- type_to_find = atom_types.shift
39
- requisite = atoms.find { |e| e.atom_type == type_to_find }
40
-
41
- # Return if we found our match
42
- return requisite if atom_types.empty?
43
-
44
- # Return nil if we didn't find the match at this nesting level
45
- return unless requisite
46
-
47
- # ...otherwise drill further down
48
- find_first_atom_by_path(requisite.children || [], *atom_types)
49
- end
50
-
51
- def find_atoms_by_path(atoms, atom_types)
52
- type_to_find = atom_types.shift
53
- requisites = atoms.select { |e| e.atom_type == type_to_find }
54
-
55
- # Return if we found our match
56
- return requisites if atom_types.empty?
57
-
58
- # Return nil if we didn't find the match at this nesting level
59
- return unless requisites
60
-
61
- # ...otherwise drill further down
62
- find_atoms_by_path(requisites.flat_map(&:children).compact || [], atom_types)
63
- end
64
-
65
- # A file can have multiple tracks. To identify the type it is necessary to check
66
- # the fields `component_subtype` in hdlr atom under the trak atom
67
- # More details in https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-DontLinkElementID_147
68
- def find_video_trak_atom(atoms)
69
- trak_atoms = find_atoms_by_path(atoms, ['moov', 'trak'])
70
-
71
- return if trak_atoms.empty?
72
-
73
- trak_atoms.find do |trak_atom|
74
- hdlr_atom = find_first_atom_by_path([trak_atom], 'trak', 'mdia', 'hdlr')
75
- next if hdlr_atom.nil?
76
- hdlr_atom.atom_fields[:component_type] == 'mhlr' && hdlr_atom.atom_fields[:component_subtype] == 'vide'
77
- end
78
- end
79
-
80
- def parse_ftyp_atom(io, atom_size)
81
- # Subtract 8 for the atom_size+atom_type,
82
- # and 8 once more for the major_brand and minor_version. The remaining
83
- # numbr of bytes is reserved for the compatible brands, 4 bytes per
84
- # brand.
85
- num_brands = (atom_size - 8 - 8) / 4
86
- {
87
- major_brand: read_bytes(io, 4),
88
- minor_version: read_binary_coded_decimal(io),
89
- compatible_brands: (1..num_brands).map { read_bytes(io, 4) },
90
- }
91
- end
92
-
93
- def parse_tkhd_atom(io, _)
94
- version = read_byte_value(io)
95
- is_v1 = version == 1
96
- {
97
- version: version,
98
- flags: read_chars(io, 3),
99
- ctime: is_v1 ? read_64bit_uint(io) : read_32bit_uint(io),
100
- mtime: is_v1 ? read_64bit_uint(io) : read_32bit_uint(io),
101
- trak_id: read_32bit_uint(io),
102
- reserved_1: read_chars(io, 4),
103
- duration: is_v1 ? read_64bit_uint(io) : read_32bit_uint(io),
104
- reserved_2: read_chars(io, 8),
105
- layer: read_16bit_uint(io),
106
- alternate_group: read_16bit_uint(io),
107
- volume: read_16bit_uint(io),
108
- reserved_3: read_chars(io, 2),
109
- matrix_structure: (1..9).map { read_32bit_fixed_point(io) },
110
- track_width: read_32bit_fixed_point(io),
111
- track_height: read_32bit_fixed_point(io),
112
- }
113
- end
114
-
115
- def parse_stts_atom(io, _)
116
- version = read_byte_value(io)
117
- is_v1 = version == 1
118
- stts = {
119
- version: version,
120
- flags: read_bytes(io, 3),
121
- number_of_entries: is_v1 ? read_64bit_uint(io) : read_32bit_uint(io),
122
- entries: []
123
- }
124
- stts[:number_of_entries].times {
125
- stts[:entries] << {
126
- sample_count: read_32bit_uint(io),
127
- sample_duration: read_32bit_uint(io)
128
- }
129
- }
130
- stts
131
- end
132
-
133
- def parse_stsd_atom(io, _)
134
- version = read_byte_value(io)
135
- is_v1 = version == 1
136
- stsd = {
137
- version: version,
138
- flags: read_bytes(io, 3),
139
- number_of_entries: is_v1 ? read_64bit_uint(io) : read_32bit_uint(io),
140
- codecs: []
141
- }
142
- stsd[:number_of_entries].times {
143
- codec_length = read_32bit_uint(io)
144
- stsd[:codecs] << read_bytes(io, 4)
145
- io.seek(io.pos + codec_length - 8) # 8 bytes is the header length containing the codec length and the codec name that we just did read
146
- }
147
- stsd
148
- end
149
-
150
- def parse_mdhd_atom(io, _)
151
- version = read_byte_value(io)
152
- is_v1 = version == 1
153
- {
154
- version: version,
155
- flags: read_bytes(io, 3),
156
- ctime: is_v1 ? read_64bit_uint(io) : read_32bit_uint(io),
157
- mtime: is_v1 ? read_64bit_uint(io) : read_32bit_uint(io),
158
- tscale: read_32bit_uint(io),
159
- duration: is_v1 ? read_64bit_uint(io) : read_32bit_uint(io),
160
- language: read_32bit_uint(io),
161
- quality: read_32bit_uint(io),
162
- }
163
- end
164
-
165
- def parse_vmhd_atom(io, _)
166
- {
167
- version: read_byte_value(io),
168
- flags: read_bytes(io, 3),
169
- graphics_mode: read_bytes(io, 2),
170
- opcolor_r: read_32bit_uint(io),
171
- opcolor_g: read_32bit_uint(io),
172
- opcolor_b: read_32bit_uint(io),
173
- }
174
- end
175
-
176
- def parse_mvhd_atom(io, _)
177
- version = read_byte_value(io)
178
- is_v1 = version == 1
179
- {
180
- version: version,
181
- flags: read_bytes(io, 3),
182
- ctime: is_v1 ? read_64bit_uint(io) : read_32bit_uint(io),
183
- mtime: is_v1 ? read_64bit_uint(io) : read_32bit_uint(io),
184
- tscale: read_32bit_uint(io),
185
- duration: is_v1 ? read_64bit_uint(io) : read_32bit_uint(io),
186
- preferred_rate: read_32bit_uint(io),
187
- reserved: read_bytes(io, 10),
188
- matrix_structure: (1..9).map { read_32bit_fixed_point(io) },
189
- preview_time: read_32bit_uint(io),
190
- preview_duration: read_32bit_uint(io),
191
- poster_time: read_32bit_uint(io),
192
- selection_time: read_32bit_uint(io),
193
- selection_duration: read_32bit_uint(io),
194
- current_time: read_32bit_uint(io),
195
- next_trak_id: read_32bit_uint(io),
196
- }
197
- end
198
-
199
- def parse_dref_atom(io, _)
200
- dict = {
201
- version: read_byte_value(io),
202
- flags: read_bytes(io, 3),
203
- num_entries: read_32bit_uint(io),
204
- }
205
- num_entries = dict[:num_entries]
206
- entries = (1..num_entries).map do
207
- entry = {
208
- size: read_32bit_uint(io),
209
- type: read_bytes(io, 4),
210
- version: read_bytes(io, 1),
211
- flags: read_bytes(io, 3),
212
- }
213
- entry[:data] = read_bytes(io, entry[:size] - 12)
214
- entry
215
- end
216
- dict[:entries] = entries
217
- dict
218
- end
219
-
220
- def parse_elst_atom(io, _)
221
- dict = {
222
- version: read_byte_value(io),
223
- flags: read_bytes(io, 3),
224
- num_entries: read_32bit_uint(io),
225
- }
226
- is_v1 = dict[:version] == 1 # Usual is 0, version 1 has 64bit durations
227
- num_entries = dict[:num_entries]
228
- entries = (1..num_entries).map do
229
- {
230
- track_duration: is_v1 ? read_64bit_uint(io) : read_32bit_uint(io),
231
- media_time: is_v1 ? read_64bit_uint(io) : read_32bit_uint(io),
232
- media_rate: read_32bit_uint(io),
233
- }
234
- end
235
- dict[:entries] = entries
236
- dict
237
- end
238
-
239
- def parse_hdlr_atom(io, atom_size)
240
- sub_io = StringIO.new(io.read(atom_size - 8))
241
- version = read_byte_value(sub_io)
242
- base_fields = {
243
- version: version,
244
- flags: read_bytes(sub_io, 3),
245
- component_type: read_bytes(sub_io, 4),
246
- component_subtype: read_bytes(sub_io, 4),
247
- component_manufacturer: read_bytes(sub_io, 4),
248
- }
249
- if version == 1
250
- version1_fields = {
251
- component_flags: read_bytes(sub_io, 4),
252
- component_flags_mask: read_bytes(sub_io, 4),
253
- component_name: sub_io.read,
254
- }
255
- base_fields.merge(version1_fields)
256
- else
257
- base_fields
258
- end
259
- end
260
-
261
- def parse_meta_atom(io, atom_size)
262
- return if atom_size == 0 # this atom can be empty
263
-
264
- parse_hdlr_atom(io, atom_size)
265
- end
266
-
267
- def parse_atom_fields_per_type(io, atom_size, atom_type)
268
- if respond_to?("parse_#{atom_type}_atom", true)
269
- send("parse_#{atom_type}_atom", io, atom_size)
270
- else
271
- nil # We can't look inside this leaf atom
272
- end
273
- end
274
-
275
- def parse_atom_children_and_data_fields(io, atom_size_sans_header, atom_type, current_branch)
276
- parse_atom_fields_per_type(io, atom_size_sans_header, atom_type)
277
- extract_atom_stream(io, atom_size_sans_header, current_branch + [atom_type])
278
- end
279
-
280
- # Recursive descent parser - will drill down to atoms which
281
- # we know are permitted to have leaf/branch atoms within itself,
282
- # and will attempt to recover the data fields for leaf atoms
283
- def extract_atom_stream(io, max_read, current_branch = [])
284
- initial_pos = io.pos
285
- atoms = []
286
- MAX_ATOMS_AT_LEVEL.times do
287
- atom_pos = io.pos
288
-
289
- break if atom_pos - initial_pos >= max_read
290
-
291
- size_and_type = io.read(4 + 4)
292
- break if size_and_type.to_s.bytesize < 8
293
-
294
- atom_size, atom_type = size_and_type.unpack('Na4')
295
-
296
- # If atom_size is specified to be 1, it is larger than what fits into the
297
- # 4 bytes and we need to read it right after the atom type
298
- atom_size = read_64bit_uint(io) if atom_size == 1
299
- atom_header_size = io.pos - atom_pos
300
- atom_size_sans_header = atom_size - atom_header_size
301
-
302
- children, fields = if KNOWN_BRANCH_AND_LEAF_ATOM_TYPES.include?(atom_type)
303
- parse_atom_children_and_data_fields(io, atom_size_sans_header, atom_type, current_branch)
304
- elsif KNOWN_BRANCH_ATOM_TYPES.include?(atom_type)
305
- [extract_atom_stream(io, atom_size_sans_header, current_branch + [atom_type]), nil]
306
- else # Assume leaf atom
307
- [nil, parse_atom_fields_per_type(io, atom_size_sans_header, atom_type)]
308
- end
309
-
310
- atoms << Atom.new(atom_pos, atom_size, atom_type, current_branch + [atom_type], children, fields)
311
-
312
- io.seek(atom_pos + atom_size)
313
- end
314
- atoms
315
- end
316
-
317
- def read_16bit_fixed_point(io)
318
- _whole, _fraction = safe_read(io, 2).unpack('CC')
319
- end
320
-
321
- def read_32bit_fixed_point(io)
322
- _whole, _fraction = safe_read(io, 4).unpack('nn')
323
- end
324
-
325
- def read_chars(io, n)
326
- safe_read(io, n)
327
- end
328
-
329
- def read_byte_value(io)
330
- safe_read(io, 1).unpack('C').first
331
- end
332
-
333
- def read_bytes(io, n)
334
- safe_read(io, n)
335
- end
336
-
337
- def read_16bit_uint(io)
338
- safe_read(io, 2).unpack('n').first
339
- end
340
-
341
- def read_32bit_uint(io)
342
- safe_read(io, 4).unpack('N').first
343
- end
344
-
345
- def read_64bit_uint(io)
346
- safe_read(io, 8).unpack('Q>').first
347
- end
348
-
349
- def read_binary_coded_decimal(io)
350
- bcd_string = safe_read(io, 4)
351
- [bcd_string].pack('H*').unpack('C*')
352
- end
353
- end
@@ -1,165 +0,0 @@
1
- class FormatParser::MOOVParser
2
- include FormatParser::IOUtils
3
- require_relative 'moov_parser/decoder'
4
-
5
- # Maps values of the "ftyp" atom to something
6
- # we can reasonably call "file type" (something
7
- # usable as a filename extension)
8
- FTYP_MAP = {
9
- 'm4a ' => :m4a,
10
- 'mp4 ' => :mp4,
11
- 'qt ' => :mov,
12
- }
13
-
14
- # https://tools.ietf.org/html/rfc4337#section-2
15
- # There is also video/quicktime which we should be able to capture
16
- # here, but there is currently no detection for MOVs versus MP4s
17
- MP4_AU_MIME_TYPE = 'audio/mp4'
18
- MP4_MIXED_MIME_TYPE = 'video/mp4'
19
-
20
- def likely_match?(filename)
21
- filename =~ /\.(m4a|m4v|ma4|mov|mp4)$/i
22
- end
23
-
24
- def call(io)
25
- return unless matches_moov_definition?(io)
26
-
27
- # Now we know we are in a MOOV, so go back and parse out the atom structure.
28
- # Parsing out the atoms does not read their contents - at least it doesn't
29
- # for the atoms we consider opaque (one of which is the "mdat" atom which
30
- # will be the prevalent part of the file body). We do not parse these huge
31
- # atoms - we skip over them and note where they are located.
32
- io.seek(0)
33
-
34
- # We have to tell the parser how far we are willing to go within the stream.
35
- # Knowing that we will bail out early anyway we will permit a large read. The
36
- # branch parse calls will know the maximum size to read from the parent atom
37
- # size that gets parsed just before.
38
- max_read_offset = 0xFFFFFFFF
39
- decoder = Decoder.new
40
- atom_tree = Measurometer.instrument('format_parser.decoder.extract_atom_stream') do
41
- decoder.extract_atom_stream(io, max_read_offset)
42
- end
43
-
44
- ftyp_atom = decoder.find_first_atom_by_path(atom_tree, 'ftyp')
45
- file_type = ftyp_atom&.field_value(:major_brand)
46
- format = format_from_moov_type(file_type)
47
-
48
- # Try to find the width and height in the tkhd
49
- width, height = parse_dimensions(decoder, atom_tree)
50
-
51
- # Try to find the "topmost" duration (respecting edits)
52
- if mvhd = decoder.find_first_atom_by_path(atom_tree, 'moov', 'mvhd')
53
- timescale = mvhd.field_value(:tscale)
54
- duration = mvhd.field_value(:duration)
55
- media_duration_s = duration / timescale.to_f
56
- end
57
-
58
- # M4A only contains audio, while MP4 and friends can contain video.
59
- if format == :m4a
60
- FormatParser::Audio.new(
61
- format: format,
62
- media_duration_seconds: media_duration_s,
63
- content_type: MP4_AU_MIME_TYPE,
64
- intrinsics: atom_tree,
65
- )
66
- else
67
- FormatParser::Video.new(
68
- format: format,
69
- width_px: width,
70
- height_px: height,
71
- frame_rate: parse_time_to_sample_atom(decoder, atom_tree)&.truncate(2),
72
- media_duration_seconds: media_duration_s,
73
- content_type: MP4_MIXED_MIME_TYPE,
74
- codecs: parse_sample_description_atom(decoder, atom_tree),
75
- intrinsics: atom_tree
76
- )
77
- end
78
- end
79
-
80
- private
81
-
82
- def format_from_moov_type(file_type)
83
- FTYP_MAP.fetch(file_type.downcase, :mov)
84
- end
85
-
86
- # The dimensions are located in tkhd atom, but in some files it is necessary
87
- # to get it below the video track, because it can have other tracks such as
88
- # audio which does not have the dimensions.
89
- # More details in https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-DontLinkElementID_147
90
- #
91
- # Returns [width, height] if the dimension is found
92
- # Returns [nil, nil] if the dimension is not found
93
- def parse_dimensions(decoder, atom_tree)
94
- video_trak_atom = decoder.find_video_trak_atom(atom_tree)
95
-
96
- tkhd = if video_trak_atom
97
- decoder.find_first_atom_by_path([video_trak_atom], 'trak', 'tkhd')
98
- else
99
- decoder.find_first_atom_by_path(atom_tree, 'moov', 'trak', 'tkhd')
100
- end
101
-
102
- if tkhd
103
- [tkhd.field_value(:track_width).first, tkhd.field_value(:track_height).first]
104
- else
105
- [nil, nil]
106
- end
107
- end
108
-
109
- # An MPEG4/MOV/M4A will start with the "ftyp" atom. The atom must have a length
110
- # of at least 16 (to accomodate the atom size and the atom type itself) plus the major brand
111
- # and minor version fields. If we cannot find it we can be certain this is not our file.
112
- def matches_moov_definition?(io)
113
- maybe_atom_size, maybe_ftyp_atom_signature, maybe_major_brand = safe_read(io, 12).unpack('N1a4a4')
114
- minimum_ftyp_atom_size = 4 + 4 + 4 + 4
115
- maybe_atom_size >= minimum_ftyp_atom_size && maybe_ftyp_atom_signature == 'ftyp' && maybe_major_brand != 'crx '
116
- end
117
-
118
- # Sample information is found in the 'time-to-sample' stts atom.
119
- # The media atom mdhd is needed too in order to get the movie timescale
120
- def parse_time_to_sample_atom(decoder, atom_tree)
121
- video_trak_atom = decoder.find_video_trak_atom(atom_tree)
122
-
123
- stts = if video_trak_atom
124
- decoder.find_first_atom_by_path([video_trak_atom], 'trak', 'mdia', 'minf', 'stbl', 'stts')
125
- else
126
- decoder.find_first_atom_by_path(atom_tree, 'moov', 'trak', 'mdia', 'minf', 'stbl', 'stts')
127
- end
128
-
129
- mdhd = if video_trak_atom
130
- decoder.find_first_atom_by_path([video_trak_atom], 'trak', 'mdia', 'mdhd')
131
- else
132
- decoder.find_first_atom_by_path(atom_tree, 'moov', 'trak', 'mdia', 'mdhd')
133
- end
134
-
135
- if stts && mdhd
136
- timescale = mdhd.atom_fields[:tscale]
137
- sample_duration = stts.field_value(:entries).dig(0, :sample_duration)
138
- if timescale.nil? || timescale == 0 || sample_duration.nil? || sample_duration == 0
139
- nil
140
- else
141
- timescale.to_f / sample_duration
142
- end
143
- else
144
- nil
145
- end
146
- end
147
-
148
- def parse_sample_description_atom(decoder, atom_tree)
149
- video_trak_atom = decoder.find_video_trak_atom(atom_tree)
150
-
151
- stsd = if video_trak_atom
152
- decoder.find_first_atom_by_path([video_trak_atom], 'trak', 'mdia', 'minf', 'stbl', 'stsd')
153
- else
154
- decoder.find_first_atom_by_path(atom_tree, 'moov', 'trak', 'mdia', 'minf', 'stbl', 'stsd')
155
- end
156
-
157
- if stsd
158
- stsd.field_value(:codecs)
159
- else
160
- nil
161
- end
162
- end
163
-
164
- FormatParser.register_parser new, natures: [:audio, :video], formats: FTYP_MAP.values, priority: 3
165
- end
@@ -1,144 +0,0 @@
1
-
2
- require 'spec_helper'
3
-
4
- describe FormatParser::MOOVParser do
5
- def deep_print_atoms(atoms, output, swimlanes = [])
6
- return unless atoms
7
-
8
- mid = '├'
9
- last = '└'
10
- horz = '─'
11
- vert = '│'
12
- cdn = '┬'
13
- n_atoms = atoms.length
14
-
15
- atoms.each_with_index do |atom, i|
16
- is_last_child = i == (n_atoms - 1)
17
- has_children = atom.children && atom.children.any?
18
- connector = is_last_child ? last : mid
19
- connector_down = has_children ? cdn : horz
20
- connector_left = is_last_child ? ' ' : vert
21
-
22
- output << swimlanes.join << connector << connector_down << horz << atom.to_s << "\n"
23
- if af = atom.atom_fields
24
- af.each do |(field, value)|
25
- output << swimlanes.join << connector_left << (' %s: %s' % [field, value.inspect]) << "\n"
26
- end
27
- end
28
- deep_print_atoms(atom.children, output, swimlanes + [connector_left])
29
- end
30
- end
31
-
32
- Dir.glob(fixtures_dir + '/MOOV/**/*.m4a').sort.each do |m4a_path|
33
- it "is able to parse #{File.basename(m4a_path)}" do
34
- result = subject.call(File.open(m4a_path, 'rb'))
35
-
36
- expect(result).not_to be_nil
37
- expect(result.nature).to eq(:audio)
38
- expect(result.media_duration_seconds).to be_kind_of(Float)
39
- expect(result.media_duration_seconds).to be > 0
40
- expect(result.content_type).to be_kind_of(String)
41
- expect(result.intrinsics).not_to be_nil
42
- end
43
- end
44
-
45
- Dir.glob(fixtures_dir + '/MOOV/**/*.mov').sort.each do |mov_path|
46
- it "is able to parse #{File.basename(mov_path)}" do
47
- result = subject.call(File.open(mov_path, 'rb'))
48
-
49
- expect(result).not_to be_nil
50
- expect(result.nature).to eq(:video)
51
- expect(result.width_px).to be > 0
52
- expect(result.height_px).to be > 0
53
- expect(result.media_duration_seconds).to be_kind_of(Float)
54
- expect(result.media_duration_seconds).to be > 0
55
- expect(result.content_type).to eq('video/mp4')
56
-
57
- expect(result.intrinsics).not_to be_nil
58
- end
59
- end
60
-
61
- Dir.glob(fixtures_dir + '/MOOV/**/*.mp4').sort.each do |mp4_path|
62
- it "is able to parse #{File.basename(mp4_path)}" do
63
- result = subject.call(File.open(mp4_path, 'rb'))
64
-
65
- expect(result).not_to be_nil
66
- expect(result.nature).to eq(:video)
67
- expect(result.width_px).to be > 0
68
- expect(result.height_px).to be > 0
69
- expect(result.media_duration_seconds).to be_kind_of(Float)
70
- expect(result.media_duration_seconds).to be > 0
71
- expect(result.content_type).to eq('video/mp4')
72
-
73
- expect(result.intrinsics).not_to be_nil
74
- end
75
- end
76
-
77
- it 'parses an M4A file and provides the necessary metadata' do
78
- m4a_path = fixtures_dir + '/MOOV/M4A/fixture.m4a'
79
-
80
- result = subject.call(File.open(m4a_path, 'rb'))
81
- expect(result).not_to be_nil
82
- expect(result.nature).to eq(:audio)
83
- expect(result.format).to eq(:m4a)
84
- expect(result.content_type).to eq('audio/mp4')
85
- end
86
-
87
- it 'parses a MOV file and provides the necessary metadata' do
88
- mov_path = fixtures_dir + '/MOOV/MOV/Test_Circular_ProRes422.mov'
89
-
90
- result = subject.call(File.open(mov_path, 'rb'))
91
-
92
- expect(result).not_to be_nil
93
- expect(result.nature).to eq(:video)
94
- expect(result.format).to eq(:mov)
95
- expect(result.width_px).to eq(1920)
96
- expect(result.height_px).to eq(1080)
97
- expect(result.codecs).to eq(['apcn'])
98
- end
99
-
100
- it 'parses an MP4 video file and provides the necessary metadata' do
101
- mov_path = fixtures_dir + '/MOOV/MP4/bmff.mp4'
102
-
103
- result = subject.call(File.open(mov_path, 'rb'))
104
-
105
- expect(result).not_to be_nil
106
- expect(result.nature).to eq(:video)
107
- expect(result.format).to eq(:mov)
108
- expect(result.width_px).to eq(160)
109
- expect(result.height_px).to eq(90)
110
- expect(result.frame_rate).to eq(14.98)
111
- expect(result.codecs).to eq(['avc1'])
112
- end
113
-
114
- it 'provides filename hints' do
115
- expect(subject).to be_likely_match('file.m4v')
116
- end
117
-
118
- it 'reads correctly the video dimensions' do
119
- mov_path = fixtures_dir + '/MOOV/MOV/Test_Dimensions.mov'
120
-
121
- result = subject.call(File.open(mov_path, 'rb'))
122
-
123
- expect(result).not_to be_nil
124
- expect(result.nature).to eq(:video)
125
- expect(result.format).to eq(:mov)
126
- expect(result.width_px).to eq(640)
127
- expect(result.height_px).to eq(360)
128
- expect(result.frame_rate).to eq(30)
129
- end
130
-
131
- it 'does not raise error when a meta atom has size 0' do
132
- mov_path = fixtures_dir + '/MOOV/MOV/Test_Meta_Atom_With_Size_Zero.mov'
133
-
134
- result = subject.call(File.open(mov_path, 'rb'))
135
- expect(result).not_to be_nil
136
- expect(result.format).to eq(:mov)
137
- end
138
-
139
- it 'does not parse CR3 files' do
140
- cr3_path = fixtures_dir + '/CR3/Canon EOS R10 (RAW).CR3'
141
- result = subject.call(File.open(cr3_path, 'rb'))
142
- expect(result).to be_nil
143
- end
144
- end