-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathgpxmapmovie
executable file
·453 lines (396 loc) · 15 KB
/
gpxmapmovie
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
#!/usr/bin/env python
import argparse
import logging
import os
import re
import shlex
import sys
import tempfile
import textwrap
import traceback
from subprocess import call, check_output, run
import gpxlib
# from ffprobe import FFProbe
GOPRO2GPX = "gopro2gpx"
DURATION_FIX = -40 # milliseconds from calculated total duration
DEFAULT_LOG_LEVEL = "info"
ENVVAR_JAR = "GPXMAPMOVIE_JAR"
ENVVAR_PATH = "GPXMAPMOVIE_PATH"
def main():
parser = argparse.ArgumentParser(
description="A wrapper around GPX Animator [https://gpx-animator.app]",
formatter_class=argparse.RawTextHelpFormatter,
epilog=textwrap.dedent(
"""\
MORE INFORMATION
----------------
Up-to-date documentation at https://github.com/thomergil/gopro-map-sync
"""
),
)
duration_group = parser.add_mutually_exclusive_group()
duration_group.add_argument(
"-d", "--divide", help="Divide computed --total-duration by DIVIDE", type=float
)
duration_group.add_argument(
"-t",
"--total-duration",
help="Force total duration of video (in milliseconds)",
type=int,
)
parser.add_argument("-a", "--args", help="Default args for GPX Animator")
parser.add_argument(
"-p", "--path", help="Make --args, --files, --reference relative to --path"
)
parser.add_argument(
"-s",
"--snap",
help="Optional --snap argument to gpxcomment",
default=gpxlib.DEFAULT_PAUSE_SNAP,
)
parser.add_argument("-f", "--files", help="See FILES below")
parser.add_argument(
"-j",
"--jar",
help="Location of GPX Animator .jar file; can be set with GPXMAPMOVIE_JAR",
)
parser.add_argument(
"-l",
"--log",
help="Log level (INFO, DEBUG, WARNING, ERROR)",
default=DEFAULT_LOG_LEVEL,
)
parser.add_argument(
"-r", "--reference", help="Reference GPX track to unwarp time stamps"
)
parser.add_argument("-i", "--input", help="Path to output", action="append")
parser.add_argument(
"-z",
"--force-timezone",
help="Only when used with --reference: force per-point timezone lookup",
action="store_true",
)
parser.add_argument(
"-k", "--keep", help="Don't trash generated GPX file", action="store_true"
)
args, pass_args = parser.parse_known_args()
# logging
numeric_level = getattr(logging, args.log.upper(), None)
if not isinstance(numeric_level, int):
raise ValueError("Invalid log level: %s" % args.log)
logging.basicConfig(level=numeric_level, format="%(asctime)s -- %(message)s")
jarpath = None
if ENVVAR_JAR in os.environ and os.environ[ENVVAR_JAR]:
jarpath = os.environ[ENVVAR_JAR]
if args.jar:
jarpath = args.jar
if not jarpath:
sys.exit("You must specify either --jar or set $%s" % (ENVVAR_JAR))
if not os.path.isfile(jarpath):
sys.exit("--jar parameter %s not found" % (jarpath))
filepath = None
if ENVVAR_PATH in os.environ and os.environ[ENVVAR_PATH]:
filepath = os.environ[ENVVAR_PATH]
if args.path:
filepath = args.path
args_args = None
if args.args:
args_args = (
os.path.join(filepath, args.args)
if filepath and not os.path.isabs(args.args)
else args.args
)
if not os.path.isfile(args_args):
sys.exit("--args parameter %s does not exist" % (args_args))
args_reference = None
if args.reference:
args_reference = (
os.path.join(filepath, args.reference)
if filepath and not os.path.isabs(args.reference)
else args.reference
)
if not os.path.isfile(args_reference):
sys.exit("--reference parameter %s does not exist" % (args_reference))
args_files = None
if args.files:
args_files = (
os.path.join(filepath, args.files)
if filepath and not os.path.isabs(args.files)
else args.files
)
if not os.path.isfile(args_files):
sys.exit("--files parameter %s does not exist" % (args_files))
#
# Read instructions from --files argument.
#
# Argument to --files is a file with lines consisting of one or two
# columns.
#
# The first column is an absolute or relative path to an .mp4 file.
# (Relative path is relative to the file itself.)
#
# If nothing else is specified on the line, gpxmapmovie will generate a
# .gpx file from the given .mp4 file.
#
# An optional second column is either the absolute or relative path (i.e.,
# relative to the file itself) to a .gpx file OR a string that starts with
# a '|' character. In that case, it specifies one or more functions that
# need to be applied to the .gpx file automatically generated from the .mp4 file.
#
# # 1 column only: path the .mp4 file
# /absolute/path/to/video.mp4
#
# # 2 columns: path to .mp4 file and path to .gpx file
# ./relative/path/to/video.mp4 ./relative/path/to/file.gpx
#
# # 2 colums: path to .mp4 file and a single pipe command
# ./relative/path/to/video.mp4 | gpxdup, duplicate=3, shift=0
#
# # 2 colums: path to .mp4 file and pipe command that consists of two
# # commands
# ./relative/path/to/video.mp4 | gpxdup, duplicate=3, shift=0 | gpxshift, value=-10
#
mp4_files, gpx_files, gpx_pipes, durations, total_duration = [], [], [], [], 0
if args_files:
files_basedir, _ = os.path.split(args_files)
with open(args_files, "r") as f:
lines = f.readlines()
for line in lines:
# remove trailing comments
line = re.sub(r"\s*#[^#]*$", "", line)
line = line.strip()
# skip empty or commented lines
if not line or line.startswith("#"):
continue
words = shlex.split(line)
if not words[0].lower().endswith(".mp4"):
sys.exit("first column in %s needs to be an .mp4 file")
else:
if os.path.isabs(words[0]):
mp4_files.append(words[0])
else:
mp4_files.append(os.path.join(files_basedir, words[0]))
gpx_files.append(None)
gpx_pipes.append(None)
if len(words) > 1:
if words[1].lower().endswith(".gpx"):
if os.path.isabs(words[1]):
gpx_files[-1] = words[1]
else:
gpx_files[-1] = os.path.join(files_basedir, words[1])
elif words[1].startswith("|"):
# join the rest of the line together, remove leading |
cmd = " ".join(words[1:]).strip()[1:]
gpx_pipes[-1] = cmd
else:
sys.exit("optional second column in %s needs to be a .gpx file")
# no --files argument: read files from command line, but stop when encountering something that
# isn't a file.
else:
while len(args.input) > 0:
p0 = args.input[0]
if p0.lower().endswith(".mp4"):
mp4_files.append(args.input.pop(0))
elif p0.lower().endswith(".gpx"):
gpx_files.append(args.input.pop(0))
else:
break
if len(mp4_files) and len(gpx_files):
sys.exit(
"You can either pass .mp4 files or .gpx files, but not both. Consider using --files to pass both"
)
if not mp4_files:
mp4_files = [None] * len(gpx_files)
if not gpx_files:
gpx_files = [None] * len(mp4_files)
if not gpx_pipes:
gpx_pipes = [None] * len(mp4_files)
if not len(mp4_files) and not len(gpx_files):
sys.exit("Expected at least one .mp4 file or .gpx file")
# sanity check: all MP4 files exist
for f in mp4_files:
if f and not os.path.isfile(f):
sys.exit("video file %s does not exist" % (f))
# extract .gpx from .mp4
for idx, mp4_file in enumerate(mp4_files):
if not mp4_file:
continue
gpx_file = gpx_files[idx]
# if None gpx_file, generate it using gopro2gpx [https://github.com/NetworkAndSoftware/gopro2gpx]
#
# $ gopro2gpx foo.mp4
# Input files:
# foo.mp4
# Output file: foo.gpx
#
if not gpx_file:
logging.info("Extract GPX from %s" % (mp4_file))
thunk = run([GOPRO2GPX, "-s", mp4_file], capture_output=True, text=True)
if thunk.returncode:
sys.exit("%s failed:\n%s" % (GOPRO2GPX, thunk.stderr))
lines = thunk.stdout.strip().split("\n")
# output file is last word on last line
words = lines[-1].split()
fname = words[-1]
gpx_files[idx] = fname
else:
logging.info("Use override GPX file %s" % (gpx_files[idx]))
# establish duration of mp4 file using ffprobe
# XXX FFProbe sometimes fails; do it with command line below
# mp4_data = FFProbe(mp4_file)
# https://stackoverflow.com/questions/30977472/python-getting-duration-of-a-video-with-ffprobe
duration_s = (
check_output(
[
"ffprobe",
"-i",
mp4_file,
"-show_entries",
"format=duration",
"-v",
"quiet",
"-of",
"csv=%s" % ("p=0"),
]
)
.decode("utf-8")
.strip()
)
# duration_s = mp4_data.__dict__['metadata']['Duration']
# convert to duration by pretending it's a time since 00:00:00.00
# zero = datetime.datetime.strptime('00:00:00.00', '%H:%M:%S.%f')
# duration = datetime.datetime.strptime(duration_s, '%H:%M:%S.%f') - zero
duration = float(duration_s)
durations.append(duration)
total_duration += duration * 1000
# sanity check: all GPX files exist
for f in gpx_files:
if f and not os.path.isfile(f):
sys.exit("GPX file %s does not exist" % (f))
# sanity check: len(mp4_files) == len(gpx_files) == len(gpx_pipes)
if (
len(mp4_files) != len(gpx_files)
or len(mp4_files) != len(gpx_pipes)
or len(gpx_files) != len(gpx_pipes)
):
sys.exit(
"sanity check failed: mp4_files: %d, gpx_files: %d, gpx_pipes: %d"
% (len(gpx_files), len(mp4_files), len(gpx_pipes))
)
# read GPX files into points
gpx_file_points = []
for gpx_file in gpx_files:
logging.info("Reading GPX file %s" % (gpx_file))
_, points = gpxlib.read(gpx_file)
gpx_file_points.append(points)
# clean up outliers in GPX tracks
for idx, points in enumerate(gpx_file_points):
logging.info("Cleaning GPX file %s" % (gpx_files[idx]))
try:
gpx_file_points[idx] = gpxlib.gpxclean(points)
except Exception:
sys.exit(traceback.format_exc())
# handle pipes (see --files documentation)
#
# for example, given a "| gpxdup, duplicate=3", create a line of code:
#
# gpx_file_points[1] = gpxlib.gpxdup(gpx_file_points[1], duplicate=3)
#
# ...and call exec() on it
#
for idx, gpx_pipe in enumerate(gpx_pipes):
if not gpx_pipe:
continue
for xargs in gpx_pipe.split("|"):
xargs = [arg.strip() for arg in xargs.split(",")]
func = xargs.pop(0)
exec_s = "gpx_file_points[%d] = gpxlib.%s(gpx_file_points[%d], %s)" % (
idx,
func,
idx,
", ".join(xargs),
)
logging.info("Piping GPX file %s through %s" % (gpx_files[idx], exec_s))
try:
exec(exec_s)
except Exception:
sys.exit(traceback.format_exc())
# informative: list gpx files and durations
for idx, gpx_file in enumerate(gpx_files):
logging.info(
"Duration %s : %s"
% (gpx_file, str(durations[idx]) if len(durations) else "?")
)
# cat all files together
try:
logging.info("Concatenating all GPX files")
points = gpxlib.gpxcat(gpx_file_points, killgap=True)
except Exception:
sys.exit(traceback.format_exc())
# optionally run gpxcomment against reference file
gpx_in = None
if args_reference:
gpx_in, ref_points = gpxlib.read(args_reference)
try:
logging.info("Apply gpxcomment with reference %s" % (args_reference))
points = gpxlib.gpxcomment(
points,
ref_points,
force_timezone=args.force_timezone,
pause_snap=int(args.snap),
)
except Exception:
sys.exit(traceback.format_exc())
# write result to a file as GPX Animator --input argument
gpx_out, segment = gpxlib.create(gpx_in)
segment.points = points
tmpfile = tempfile.NamedTemporaryFile(delete=not args.keep)
with open(tmpfile.name, "w") as f:
f.write(gpx_out.to_xml())
f.flush()
# create GPX Animator command line invocation from --args argument and all
# unprocessed command line arguments; command-line arguments override
# settings from --args
# read default args for GPX Animator from a file, if given
pass_args_h = {}
if args_args:
with open(args_args, "r") as fx:
for line in fx.readlines():
line = line.strip()
if not line or line.startswith("#"):
continue
thunk = shlex.split(line)
flag = thunk.pop(0)
pass_args_h[flag] = None
if len(thunk):
pass_args_h[flag] = thunk[0]
# turn pass_args_into a hash
while len(pass_args):
arg = pass_args.pop(0)
if (
arg.startswith("--")
and pass_args[0]
and not pass_args[0].startswith("--")
):
pass_args_h[arg] = pass_args.pop(0)
else:
pass_args_h[arg] = None
cmd = ["java", "-jar", jarpath]
for key, value in pass_args_h.items():
cmd.append(key)
if value:
cmd.append(value)
# --input argument to GPXA
cmd += ["--input", tmpfile.name]
if args.divide:
total_duration /= float(args.divide)
if args.total_duration:
total_duration = args.total_duration
if total_duration:
cmd += ["--total-time", str(int(total_duration) + DURATION_FIX)]
logging.info(" ".join(cmd))
call(cmd)
if args.keep:
logging.info("GPX file kept at %s" % (tmpfile.name))
if __name__ == "__main__":
main()