Skip to content

Commit 1e39a1c

Browse files
Merge pull request #305 from nbirkbeck/master
Add support for v1 metadata for 3d only file, and add v2 metadata support
2 parents 83c4ffe + eba3bb4 commit 1e39a1c

File tree

5 files changed

+385
-13
lines changed

5 files changed

+385
-13
lines changed

spatialmedia/__main__.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ def main():
4848
help=
4949
"injects spatial media metadata into the first file specified (.mp4 or "
5050
".mov) and saves the result to the second file specified")
51+
parser.add_argument(
52+
"-2",
53+
"--v2",
54+
action="store_true",
55+
help=
56+
"Uses v2 of the video metadata spec")
5157
video_group = parser.add_argument_group("Spherical Video")
5258
video_group.add_argument("-s",
5359
"--stereo",
@@ -57,6 +63,13 @@ def main():
5763
choices=["none", "top-bottom", "left-right"],
5864
default="none",
5965
help="stereo mode (none | top-bottom | left-right)")
66+
video_group.add_argument("-p",
67+
"--projection",
68+
action="store",
69+
dest="projection",
70+
choices=["none", "equirectangular"],
71+
default="equirectangular",
72+
help="projection (none | equirectangular)")
6073
video_group.add_argument(
6174
"-c",
6275
"--crop",
@@ -84,9 +97,13 @@ def main():
8497
console("Injecting metadata requires both an input file and output file.")
8598
return
8699

87-
metadata = metadata_utils.Metadata()
88-
metadata.video = metadata_utils.generate_spherical_xml(args.stereo_mode,
89-
args.crop)
100+
metadata = metadata_utils.Metadata(args.projection, args.stereo_mode)
101+
if not args.v2:
102+
metadata.projection = None
103+
metadata.stereo_mode = None
104+
metadata.video = metadata_utils.generate_spherical_xml(args.projection,
105+
args.stereo_mode,
106+
args.crop)
90107

91108
if args.spatial_audio:
92109
parsed_metadata = metadata_utils.parse_metadata(args.file[0], console)
@@ -102,7 +119,7 @@ def main():
102119
"spatial audio format." % (parsed_metadata.num_audio_channels))
103120
return
104121

105-
if metadata.video:
122+
if metadata.video or metadata.projection or metadata.stereo_mode:
106123
metadata_utils.inject_metadata(args.file[0], args.file[1], metadata,
107124
console)
108125
else:

spatialmedia/metadata_utils.py

Lines changed: 102 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@
4949
"</GSpherical:StitchingSoftware>"\
5050
"<GSpherical:ProjectionType>equirectangular</GSpherical:ProjectionType>"
5151

52+
NOT_SPHERICAL_XML_CONTENTS = \
53+
"<GSpherical:Spherical>false</GSpherical:Spherical>"\
54+
"<GSpherical:Stitched>false</GSpherical:Stitched>"\
55+
"<GSpherical:StitchingSoftware>"\
56+
"Spherical Metadata Tool"\
57+
"</GSpherical:StitchingSoftware>"\
58+
"<GSpherical:ProjectionType>rectangular</GSpherical:ProjectionType>"
59+
5260
SPHERICAL_XML_CONTENTS_TOP_BOTTOM = \
5361
"<GSpherical:StereoMode>top-bottom</GSpherical:StereoMode>"
5462
SPHERICAL_XML_CONTENTS_LEFT_RIGHT = \
@@ -87,7 +95,9 @@
8795
]
8896

8997
class Metadata(object):
90-
def __init__(self):
98+
def __init__(self, projection=None, stereo_mode=None):
99+
self.projection = None if (not projection or projection == "none") else projection
100+
self.stereo_mode = None if (not stereo_mode or stereo_mode == "none") else stereo_mode
91101
self.video = None
92102
self.audio = None
93103

@@ -144,7 +154,7 @@ def spherical_uuid(metadata):
144154
return uuid_leaf
145155

146156

147-
def mpeg4_add_spherical(mpeg4_file, in_fh, metadata):
157+
def mpeg4_add_spherical_xml_v1(mpeg4_file, in_fh, metadata):
148158
"""Adds a spherical uuid box to an mpeg4 file for all video tracks.
149159
150160
Args:
@@ -176,6 +186,73 @@ def mpeg4_add_spherical(mpeg4_file, in_fh, metadata):
176186
mpeg4_file.resize()
177187
return True
178188

189+
def mpeg4_add_spherical_v2(mpeg4_file, in_fh, projection, stereo_mode):
190+
for element in mpeg4_file.moov_box.contents:
191+
if element.name == mpeg.constants.TAG_TRAK:
192+
for sub_element in element.contents:
193+
if sub_element.name != mpeg.constants.TAG_MDIA:
194+
continue
195+
for mdia_sub_element in sub_element.contents:
196+
if mdia_sub_element.name != mpeg.constants.TAG_HDLR:
197+
continue
198+
position = mdia_sub_element.content_start() + 8
199+
in_fh.seek(position)
200+
if in_fh.read(4) == mpeg.constants.TAG_VIDE:
201+
ret = inject_spatial_video_v2_atoms(
202+
in_fh, sub_element, projection, stereo_mode)
203+
mpeg4_file.resize()
204+
return ret
205+
206+
207+
def inject_spatial_video_v2_atoms(in_fh, video_media_atom, projection, stereo_mode):
208+
"""Adds spherical v2 boxes to an mpeg4 file for all video tracks.
209+
210+
Args:
211+
mpeg4_file: mpeg4, Mpeg4 file structure to add metadata.
212+
in_fh: file handle, Source for uncached file contents.
213+
metadata: string, xml metadata to inject into spherical tag.
214+
"""
215+
for atom in video_media_atom.contents:
216+
if atom.name != mpeg.constants.TAG_MINF:
217+
continue
218+
for element in atom.contents:
219+
if element.name != mpeg.constants.TAG_STBL:
220+
continue
221+
for sub_element in element.contents:
222+
if sub_element.name != mpeg.constants.TAG_STSD:
223+
continue
224+
for sample_description in sub_element.contents:
225+
if sample_description.name in\
226+
mpeg.constants.VIDEO_SAMPLE_DESCRIPTIONS:
227+
in_fh.seek(sample_description.position +
228+
sample_description.header_size + 16)
229+
# Should remove any existing boxes...
230+
if stereo_mode:
231+
st3d_atom = mpeg.sv3d.ST3DBox.create()
232+
st3d_atom.name = mpeg.constants.TAG_ST3D
233+
st3d_atom.set_stereo_mode_from_string(stereo_mode)
234+
235+
sample_description.remove(st3d_atom.name)
236+
sample_description.add(st3d_atom)
237+
238+
if projection:
239+
proj_atom = mpeg.container.Container(header_size=8)
240+
proj_atom.name = mpeg.constants.TAG_PROJ
241+
242+
proj_atom.add(mpeg.sv3d.PRHDBox.create())
243+
proj_atom.add(mpeg.sv3d.EQUIBox.create())
244+
245+
sv3d_atom = mpeg.container.Container(header_size=8)
246+
sv3d_atom.name = mpeg.constants.TAG_SV3D
247+
248+
sv3d_atom.add(proj_atom)
249+
250+
sample_description.remove(sv3d_atom.name)
251+
sample_description.add(sv3d_atom)
252+
253+
return True
254+
255+
179256
def mpeg4_add_spatial_audio(mpeg4_file, in_fh, audio_metadata, console):
180257
"""Adds spatial audio metadata to the first audio track of the input
181258
mpeg4_file. Returns False on failure.
@@ -346,6 +423,21 @@ def parse_spherical_mpeg4(mpeg4_file, fh, console):
346423
if sa3d_elem.name == mpeg.constants.TAG_SA3D:
347424
sa3d_elem.print_box(console)
348425
metadata.audio = sa3d_elem
426+
427+
for sv3d_container_elem in stsd_elem.contents:
428+
if sv3d_container_elem.name not in \
429+
mpeg.constants.VIDEO_SAMPLE_DESCRIPTIONS:
430+
continue
431+
for sub_elem in sv3d_container_elem.contents:
432+
if sub_elem.name == mpeg.constants.TAG_SV3D:
433+
console("\t\tSV3D {")
434+
sub_elem.print_box(console)
435+
console("\t\t}")
436+
elif sub_elem.name == mpeg.constants.TAG_ST3D:
437+
console("\t\tST3D {")
438+
sub_elem.print_box(console)
439+
console("\t\t} ")
440+
349441
return metadata
350442

351443
def parse_mpeg4(input_file, console):
@@ -369,9 +461,13 @@ def inject_mpeg4(input_file, output_file, metadata, console):
369461
if mpeg4_file is None:
370462
console("Error file could not be opened.")
371463

372-
if not mpeg4_add_spherical(mpeg4_file, in_fh, metadata.video):
464+
if metadata.video and not mpeg4_add_spherical_xml_v1(mpeg4_file, in_fh, metadata.video):
373465
console("Error failed to insert spherical data")
374466

467+
if ((metadata.projection or metadata.stereo_mode)
468+
and not mpeg4_add_spherical_v2(mpeg4_file, in_fh, metadata.projection, metadata.stereo_mode)):
469+
console("Error failed to insert spherical data v2")
470+
375471
if metadata.audio:
376472
if not mpeg4_add_audio_metadata(
377473
mpeg4_file, in_fh, metadata.audio, console):
@@ -433,7 +529,7 @@ def inject_metadata(src, dest, metadata, console):
433529
console("Unknown file type")
434530

435531

436-
def generate_spherical_xml(stereo=None, crop=None):
532+
def generate_spherical_xml(projection="equiretangular", stereo=None, crop=None):
437533
# Configure inject xml.
438534
additional_xml = ""
439535
if stereo == "top-bottom":
@@ -499,7 +595,8 @@ def generate_spherical_xml(stereo=None, crop=None):
499595
cropped_offset_left_pixels, cropped_offset_top_pixels)
500596

501597
spherical_xml = (SPHERICAL_XML_HEADER +
502-
SPHERICAL_XML_CONTENTS +
598+
(SPHERICAL_XML_CONTENTS if projection == "equirectangular"
599+
else NOT_SPHERICAL_XML_CONTENTS) +
503600
additional_xml +
504601
SPHERICAL_XML_FOOTER)
505602
return spherical_xml

spatialmedia/mpeg/constants.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,14 @@
2929
TAG_FTYP = b"ftyp"
3030
TAG_ESDS = b"esds"
3131
TAG_SOUN = b"soun"
32+
TAG_VIDE = b"vide"
3233
TAG_SA3D = b"SA3D"
3334

35+
TAG_PRHD = b"prhd"
36+
TAG_EQUI = b"equi"
37+
TAG_SVHD = b"svhd"
38+
TAG_ST3D = b"st3d"
39+
3440
# Container types.
3541
TAG_MOOV = b"moov"
3642
TAG_UDTA = b"udta"
@@ -43,6 +49,10 @@
4349
TAG_UUID = b"uuid"
4450
TAG_WAVE = b"wave"
4551

52+
TAG_SV3D = b"sv3d"
53+
TAG_PROJ = b"proj"
54+
55+
4656
# Sound sample descriptions.
4757
TAG_NONE = b"NONE"
4858
TAG_RAW_ = b"raw "
@@ -58,6 +68,14 @@
5868
TAG_MP4A = b"mp4a"
5969
TAG_OPUS = b"Opus"
6070

71+
# Video sample descriptions.
72+
TAG_AVC1 = b"avc1"
73+
TAG_VP09 = b"vp09"
74+
TAG_AV01 = b"av01"
75+
TAV_HEV1 = b"hev1"
76+
TAG_DVH1 = b"dvh1"
77+
TAG_APCN = b"apcn"
78+
6179
SOUND_SAMPLE_DESCRIPTIONS = frozenset([
6280
TAG_NONE,
6381
TAG_RAW_,
@@ -74,6 +92,16 @@
7492
TAG_OPUS,
7593
])
7694

95+
VIDEO_SAMPLE_DESCRIPTIONS = frozenset([
96+
TAG_NONE,
97+
TAG_AVC1,
98+
TAG_VP09,
99+
TAG_AV01,
100+
TAV_HEV1,
101+
TAG_DVH1,
102+
TAG_APCN,
103+
])
104+
77105
CONTAINERS_LIST = frozenset([
78106
TAG_MDIA,
79107
TAG_MINF,
@@ -83,4 +111,6 @@
83111
TAG_TRAK,
84112
TAG_UDTA,
85113
TAG_WAVE,
86-
]).union(SOUND_SAMPLE_DESCRIPTIONS)
114+
TAG_SV3D,
115+
TAG_PROJ
116+
]).union(SOUND_SAMPLE_DESCRIPTIONS).union(VIDEO_SAMPLE_DESCRIPTIONS)

spatialmedia/mpeg/container.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from spatialmedia.mpeg import box
2626
from spatialmedia.mpeg import constants
2727
from spatialmedia.mpeg import sa3d
28+
from spatialmedia.mpeg import sv3d
2829

2930
def load(fh, position, end):
3031
if position is None:
@@ -34,14 +35,15 @@ def load(fh, position, end):
3435
header_size = 8
3536
size = struct.unpack(">I", fh.read(4))[0]
3637
name = fh.read(4)
37-
3838
is_box = name not in constants.CONTAINERS_LIST
3939
# Handle the mp4a decompressor setting (wave -> mp4a).
4040
if name == constants.TAG_MP4A and size == 12:
4141
is_box = True
4242
if is_box:
4343
if name == constants.TAG_SA3D:
4444
return sa3d.load(fh, position, end)
45+
if sv3d.is_supported_box_name(name):
46+
return sv3d.load(fh, position, end)
4547
return box.load(fh, position, end)
4648

4749
if size == 1:
@@ -74,6 +76,17 @@ def load(fh, position, end):
7476
else:
7577
print("Unsupported sample description version:",
7678
sample_description_version)
79+
if name in constants.VIDEO_SAMPLE_DESCRIPTIONS:
80+
current_pos = fh.tell()
81+
fh.seek(current_pos + 8)
82+
sample_description_version = struct.unpack(">h", fh.read(2))[0]
83+
fh.seek(current_pos)
84+
85+
if sample_description_version == 0:
86+
padding = 78
87+
else:
88+
print("Unsupported video sample description version:",
89+
sample_description_version)
7790

7891
new_box = Container()
7992
new_box.name = name
@@ -106,10 +119,10 @@ def load_multiple(fh, position=None, end=None):
106119
class Container(box.Box):
107120
"""MPEG4 container box contents / behaviour."""
108121

109-
def __init__(self, padding=0):
122+
def __init__(self, padding=0, header_size=0):
110123
self.name = ""
111124
self.position = 0
112-
self.header_size = 0
125+
self.header_size = header_size
113126
self.content_size = 0
114127
self.contents = list()
115128
self.padding = padding
@@ -122,6 +135,10 @@ def resize(self):
122135
element.resize()
123136
self.content_size += element.size()
124137

138+
def print_box(self, console):
139+
for child in self.contents:
140+
child.print_box(console)
141+
125142
def print_structure(self, indent=""):
126143
"""Prints the box structure and recurses on contents."""
127144
size1 = self.header_size

0 commit comments

Comments
 (0)