teslacam-merge 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c454a054f75c62785f06dad71f23a2f26ceacd75f261c8ffdbefc96f64942cd5
4
+ data.tar.gz: e7b7256e16be5868d489db58c6ae5c682075a5790b941bd5414fd8742fa62857
5
+ SHA512:
6
+ metadata.gz: 1a03cc0ccf4929faa055bbd3ae6d4a2d1475ca25f789b9a52f86b72c2c44de4136413f312cf0520e032761142a8dbde5cc8c79f6efc4a77d41a173e7dc0a1c9c
7
+ data.tar.gz: 21fd8321e18f5dd150e10a5b24e80eb4c8bfeabac6ca19e8f3bec9d2e474d38e479385c074a255a8e0ba6bbdff6a7bf27068febbac11f9c3befedafa71af28ae
@@ -0,0 +1,23 @@
1
+ # teslacam-merge
2
+
3
+ Combine TeslaCam videos into a single output video. Allows you to set
4
+ the output video size, and add a title to the output video.
5
+
6
+ Example:
7
+
8
+ ```
9
+ # combine given videos, set the title to "sample video", and write the
10
+ # result to the file "sentry-example.mp4"
11
+ teslacam-merge -t 'sample video' -s 320x240 -o sentry-example.mp4 \
12
+ 2019-11-08_01-55-17-* 2019-11-08_01-48-14-{left,right}*.mp4
13
+ ```
14
+
15
+ ## Installation
16
+
17
+ Install `teslacam-merge` via [RubyGems][]:
18
+
19
+ ```
20
+ gem install teslacam-merge
21
+ ```
22
+
23
+ [rubygems]: https://rubygems.org/
@@ -0,0 +1,16 @@
1
+ require 'rake/testtask'
2
+ require 'rdoc/task'
3
+
4
+ Rake::TestTask.new do |t|
5
+ t.libs << 'test'
6
+ end
7
+
8
+ RDoc::Task.new :docs do |t|
9
+ t.main = "lib/teslacam.rb"
10
+ t.rdoc_files.include('lib/*.rb')
11
+ t.rdoc_dir = 'docs'
12
+ # t.options << "--all"
13
+ end
14
+
15
+ desc "Run tests"
16
+ task default: :test
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ #
4
+ # teslacam-merge: Merge teslacam videos into one combined video.
5
+ #
6
+ # Example usage:
7
+ #
8
+ # # merge all 2019-11-07_22-22-26* videos into out.mp4
9
+ # teslacam-merge out.mp4 2019-11-07_22-22-26-*
10
+ #
11
+ # # merge all 2019-11-07_22-22-26* videos and 2019-11-07_22-23-26-*
12
+ # # videos into combined.mp4
13
+ # teslacam-merge combined.mp4 2019-11-07_22-23-26-* 2019-11-07_22-22-26-*
14
+ #
15
+ # notes:
16
+ # * src video size: 1280x960
17
+ # * ffmpeg command src: https://trac.ffmpeg.org/wiki/Create%20a%20mosaic%20out%20of%20several%20input%20videos
18
+ #
19
+
20
+ require_relative '../lib/teslacam'
21
+
22
+ ::TeslaCam::CLI.run($0, ARGV) if __FILE__ == $0
@@ -0,0 +1,11 @@
1
+ module TeslaCam
2
+ VERSION = '0.1.0'
3
+
4
+ LIB_DIR = File.join(__dir__, 'teslacam').freeze
5
+
6
+ autoload :CLI, File.join(LIB_DIR, 'cli.rb')
7
+ autoload :Config, File.join(LIB_DIR, 'config.rb')
8
+ autoload :Filter, File.join(LIB_DIR, 'filter.rb')
9
+ autoload :Model, File.join(LIB_DIR, 'model.rb')
10
+ autoload :Size, File.join(LIB_DIR, 'size.rb')
11
+ end
@@ -0,0 +1,29 @@
1
+ require 'pp'
2
+ require 'logger'
3
+
4
+ #
5
+ # Command-line interface.
6
+ #
7
+ module TeslaCam::CLI
8
+ LIB_DIR = File.join(__dir__, 'cli').freeze
9
+ autoload :Config, File.join(LIB_DIR, 'config.rb')
10
+
11
+ #
12
+ # Run from command-line.
13
+ #
14
+ def self.run(app, args)
15
+ # get config from command-line, build model
16
+ config = ::TeslaCam::CLI::Config.new(app, args)
17
+
18
+ # create logger from config
19
+ log = ::Logger.new(config.quiet ? nil : STDERR)
20
+
21
+ # create model from config and log
22
+ model = ::TeslaCam::Model.new(config, log)
23
+
24
+
25
+ # exec command
26
+ log.debug { 'exec: %p' % [model.command] }
27
+ ::Kernel.exec(*model.command)
28
+ end
29
+ end
@@ -0,0 +1,53 @@
1
+ require 'optparse'
2
+
3
+ #
4
+ # Parse config from command-line arguments
5
+ #
6
+ class TeslaCam::CLI::Config < ::TeslaCam::Config
7
+ def initialize(app, args)
8
+ # initialize defaults
9
+ super()
10
+
11
+ @inputs = OptionParser.new do |o|
12
+ o.banner = "Usage: #{app} [options] <videos>"
13
+ o.separator ''
14
+
15
+ o.separator 'Options:'
16
+ o.on('-o', '--output [FILE]', String, 'Output file.') do |val|
17
+ @output = val
18
+ end
19
+
20
+ o.on('-s', '--size [SIZE]', String, 'Output size (WxH).') do |val|
21
+ md = val.match(/^(?<w>\d+)x(?<h>\d+)$/)
22
+ raise "invalid size: #{val}" unless md
23
+ @size = ::TeslaCam::Size.new(md[:w].to_i / 2, md[:h].to_i / 2)
24
+ end
25
+
26
+ o.on('--font-size [SIZE]', Integer, 'Font size.') do |val|
27
+ raise "invalid font size: #{val}" if val < 1
28
+ @font_size = val
29
+ end
30
+
31
+ o.on('--bg-color [COLOR]', Integer, 'Background color.') do |val|
32
+ raise "invalid font size: #{val}" if val < 1
33
+ @missing_color = val
34
+ end
35
+
36
+ o.on('-t', '--title [TITLE]', String, 'Video title.') do |val|
37
+ @title = val
38
+ end
39
+
40
+ o.on('-q', '--quiet', 'Silence ffmpeg output.') do
41
+ @quiet = true
42
+ end
43
+
44
+ o.on_tail('-h', '--help', 'Show help.') do
45
+ puts o
46
+ exit 0
47
+ end
48
+ end.parse(args)
49
+
50
+ raise "missing input videos" unless @inputs.size > 0
51
+ raise "missing output" unless @output
52
+ end
53
+ end
@@ -0,0 +1,36 @@
1
+ #
2
+ # Parse command-line arguments into config
3
+ #
4
+ class TeslaCam::Config
5
+ attr :ffmpeg,
6
+ :quiet,
7
+ :output,
8
+ :inputs,
9
+ :size,
10
+ :font_size,
11
+ :missing_color,
12
+ :title
13
+
14
+ #
15
+ # Create a new Config instance and set defaults.
16
+ #
17
+ def initialize
18
+ # path to ffmpeg command
19
+ @ffmpeg = '/usr/bin/ffmpeg'
20
+
21
+ # make ffmpeg only show fatal errors
22
+ @quiet = false
23
+
24
+ # video title
25
+ @title = ''
26
+
27
+ # output size
28
+ @size = ::TeslaCam::Size.new(320, 240)
29
+
30
+ # font size
31
+ @font_size = 16
32
+
33
+ # background color for missing videos
34
+ @missing_color = 'black'
35
+ end
36
+ end
@@ -0,0 +1,226 @@
1
+ require 'time'
2
+
3
+ class TeslaCam::Filter
4
+ def initialize(model)
5
+ config = model.config
6
+ num_times = model.times.size
7
+
8
+ # build filter string
9
+ @s = model.times.each_with_index.map { |time, i|
10
+ [
11
+ # get null source and video source nodes
12
+ sources(i, model, time),
13
+
14
+ # get overlay nodes
15
+ overlays(i, config),
16
+
17
+ # get text overlay nodes
18
+ texts(i, config, time, num_times),
19
+ ]
20
+ }.flatten.concat(
21
+ # get concatenate node
22
+ concat_expr(num_times)
23
+ ).join(';').freeze
24
+
25
+ # puts @s
26
+ # exit 0
27
+ end
28
+
29
+ def to_s
30
+ @s
31
+ end
32
+
33
+ private
34
+
35
+ QUADS = {
36
+ tl: :front,
37
+ tr: :back,
38
+ bl: :left_repeater,
39
+ br: :right_repeater,
40
+ }
41
+
42
+ #
43
+ # Get video sources.
44
+ #
45
+ def sources(i, model, time)
46
+ # get missing color and quad size from config
47
+ color = model.config.missing_color
48
+ w = model.config.size.w
49
+ h = model.config.size.h
50
+
51
+ # build map of quad ID to argument number of corresponding video
52
+ # on command-line
53
+ lut = QUADS.keys.reduce({
54
+ # get command line argument offset for files in this set
55
+ ofs: i.times.reduce(0) do |r, j|
56
+ r + model.videos[model.times[j]].size
57
+ end,
58
+
59
+ args: []
60
+ }) do |r, id|
61
+ if model.videos[time][QUADS[id]]
62
+ r[:args] << { id: id, ofs: r[:ofs] }
63
+ r[:ofs] += 1
64
+ end
65
+ r
66
+ end[:args].each.with_object({}) do |row, r|
67
+ r[row[:id]] = row[:ofs]
68
+ end
69
+
70
+ # build null sources
71
+ [
72
+ "nullsrc=size=#{w*2}x#{h*2}:d=60, drawbox=c=#{color}:t=fill [v#{i}_bg]",
73
+ ].concat((QUADS.keys - lut.keys).map { |id|
74
+ # missing video
75
+ "nullsrc=size=#{w}x#{h}:d=60 [v#{i}_#{id}]"
76
+ }).concat(lut.map { |id, ofs|
77
+ # command-line argument source
78
+ "[#{ofs}:v] setpts=PTS-STARTPTS, scale=#{w}x#{h} [v#{i}_#{id}]"
79
+ }).join(';')
80
+ end
81
+
82
+ #
83
+ # Ordered list of video overlays.
84
+ #
85
+ OVERLAYS = [{
86
+ srcs: %w{bg tl},
87
+ dst: 't0',
88
+ x: 0,
89
+ y: 0,
90
+ }, {
91
+ srcs: %w{t0 tr},
92
+ dst: 't1',
93
+ x: 1,
94
+ y: 0,
95
+ }, {
96
+ srcs: %w{t1 bl},
97
+ dst: 't2',
98
+ x: 0,
99
+ y: 1,
100
+ }, {
101
+ srcs: %w{t2 br},
102
+ dst: 't3',
103
+ x: 1,
104
+ y: 1,
105
+ }]
106
+
107
+ #
108
+ # Video overlay format string.
109
+ #
110
+ OVERLAY_FORMAT = '
111
+ [v%<i>d_%<src0>s][v%<i>d_%<src1>s]
112
+ overlay=shortest=0:x=%<x>d:y=%<y>d
113
+ [v%<i>d_%<dst>s]
114
+ '.gsub(/[\s\n]+/mx, ' ').strip.freeze
115
+
116
+ #
117
+ # Get video overlays.
118
+ #
119
+ def overlays(i, config)
120
+ w = config.size.w
121
+ h = config.size.h
122
+
123
+ OVERLAYS.map { |row|
124
+ OVERLAY_FORMAT % row.merge({
125
+ src0: row[:srcs].first,
126
+ src1: row[:srcs].last,
127
+ i: i,
128
+ x: row[:x] * w,
129
+ y: row[:y] * h,
130
+ })
131
+ }.join(';')
132
+ end
133
+
134
+ #
135
+ # Text overlays.
136
+ #
137
+ TEXTS = [{
138
+ text: 'front',
139
+ x: '4',
140
+ y: '3',
141
+ }, {
142
+ text: 'back',
143
+ x: '(w-text_w-4)',
144
+ y: '3',
145
+ }, {
146
+ text: 'left',
147
+ x: '4',
148
+ y: '(h-text_h-3)',
149
+ }, {
150
+ text: 'right',
151
+ x: '(w-text_w-4)',
152
+ y: '(h-text_h-3)',
153
+ }, {
154
+ text: '%%{pts\\\\:localtime\\\\:%<ts>i}',
155
+ x: '(w-text_w)/2',
156
+ y: '(h-text_h-5)',
157
+ }, {
158
+ text: '%<title>s',
159
+ x: '(w-text_w)/2',
160
+ y: '3',
161
+ }]
162
+
163
+ #
164
+ # Get text overlays
165
+ #
166
+ def texts(i, config, time, num_times)
167
+ # get font
168
+ font = get_font(config)
169
+
170
+ # build text args
171
+ text_args = {
172
+ # timestamp offset
173
+ ts: Time.parse(time).to_i,
174
+
175
+ # video title
176
+ title: config.title, # FIXME: need to escape this
177
+ }
178
+
179
+ # build and return result
180
+ '[v%<i>d_t3] %<texts>s %<sink>s' % {
181
+ i: i,
182
+
183
+ texts: TEXTS.map { |row|
184
+ 'drawtext=text=%<text>s:x=%<x>s:y=%<y>s:%<font>s' % row.merge({
185
+ text: row[:text] % text_args,
186
+ font: font,
187
+ })
188
+ }.join(', '),
189
+
190
+ sink: (num_times > 1) ? "[v#{i}]" : ''
191
+ }
192
+ end
193
+
194
+ #
195
+ # Get font config
196
+ #
197
+ def get_font(config)
198
+ [
199
+ 'fontcolor=white@0.8',
200
+ "fontsize=#{config.font_size}",
201
+
202
+ # 'shadowcolor=black@0.5',
203
+ # 'shadowx=1',
204
+ # 'shadowy=1',
205
+
206
+ # 'box=1',
207
+ # 'boxborderw=2',
208
+ # 'boxcolor=black@0.5',
209
+
210
+ 'borderw=1',
211
+ 'bordercolor=black@0.5',
212
+ ].join(':')
213
+ end
214
+
215
+ #
216
+ # Get concat statement.
217
+ #
218
+ def concat_expr(num_times)
219
+ (num_times > 1) ? [
220
+ '%s concat=n=%d' % [
221
+ num_times.times.map { |i| "[v#{i}]" }.join(''),
222
+ num_times,
223
+ ],
224
+ ] : []
225
+ end
226
+ end
@@ -0,0 +1,121 @@
1
+ class TeslaCam::Model
2
+ attr :config,
3
+ :videos,
4
+ :times,
5
+ :paths,
6
+ :filter,
7
+ :command
8
+
9
+ #
10
+ # Format string for ISO-8601 timestamps.
11
+ #
12
+ TIME_FORMAT = '%<year>s-%<month>s-%<day>sT%<hour>s:%<min>s:%<sec>s'
13
+
14
+ #
15
+ # Regular expression to extract relevant data from video file names.
16
+ #
17
+ VIDEO_PATH_RE = %r{^
18
+ (?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})_
19
+ (?<hour>\d{2})-(?<min>\d{2})-(?<sec>\d{2})-
20
+ (?<name>[a-z_]+)\.mp4
21
+ $}mx
22
+
23
+ #
24
+ # list of camera symbols
25
+ #
26
+ CAMS = %i{front back left_repeater right_repeater}
27
+
28
+ def initialize(config, log)
29
+ # cache config
30
+ @config = config
31
+ @log = log
32
+
33
+ # extract video sets from input file names
34
+ @videos = parse_inputs(config.inputs)
35
+ @times = @videos.keys.sort
36
+ @paths = get_paths(@times, @videos)
37
+ @filter = TeslaCam::Filter.new(self)
38
+ @command = get_command(config, @paths, @filter)
39
+ end
40
+
41
+ #
42
+ # Are there multiple video sets?
43
+ #
44
+ def multiple_video_sets?
45
+ @videos.keys.size > 1
46
+ end
47
+
48
+ private
49
+
50
+ #
51
+ # extract video sets from input file names
52
+ #
53
+ def parse_inputs(inputs)
54
+ # extract video sets from input file names
55
+ videos = inputs.each.with_object(Hash.new { |h, k| h[k] = {} }) do |path, r|
56
+ if md = ::File.basename(path).match(VIDEO_PATH_RE)
57
+ # build data
58
+ data = (md.names.each.with_object({}) { |s, mr|
59
+ i = s.intern
60
+ mr[i] = md[i]
61
+ }).merge({
62
+ path: path,
63
+ })
64
+
65
+ # build key
66
+ key = TIME_FORMAT % data
67
+ r[key][md[:name].intern] = data
68
+ end
69
+ end
70
+
71
+ # check for missing videos in each set
72
+ videos.each do |time, videos|
73
+ missing = CAMS - videos.keys
74
+ if missing.size > 0
75
+ # some videos missing, raise warning
76
+ @log.warn { "missing videos in #{time}: #{missing * ', '}" }
77
+ end
78
+ end
79
+
80
+ # return videos
81
+ videos
82
+ end
83
+
84
+ #
85
+ # Build ordered list of video input paths.
86
+ #
87
+ def get_paths(times, videos)
88
+ times.reduce([]) do |r, time|
89
+ CAMS.select { |cam|
90
+ videos[time].key?(cam)
91
+ }.reduce(r) do |r, cam|
92
+ r << videos[time][cam][:path]
93
+ end
94
+ end
95
+ end
96
+
97
+ #
98
+ # build ffmpeg command
99
+ #
100
+ def get_command(config, paths, filter)
101
+ [
102
+ config.ffmpeg,
103
+
104
+ # hide ffmpeg banner
105
+ '-hide_banner',
106
+
107
+ # set ffmpeg log level
108
+ '-loglevel', (config.quiet ? 'fatal' : 'info'),
109
+
110
+ # sorted list of videos (in order of CAMS, see above)
111
+ *(paths.map { |path| ['-i', path] }.flatten),
112
+
113
+ # filter command
114
+ '-filter_complex', filter.to_s,
115
+ '-c:v', 'libx264',
116
+
117
+ # output path
118
+ config.output,
119
+ ]
120
+ end
121
+ end
@@ -0,0 +1,5 @@
1
+ class TeslaCam::Size < Struct.new(:w, :h)
2
+ def to_s
3
+ "#{w}x#{h}"
4
+ end
5
+ end
@@ -0,0 +1,20 @@
1
+ Copyright 2019 Paul Duncan
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a
4
+ copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included
12
+ in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15
+ OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
18
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
19
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
20
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
metadata ADDED
@@ -0,0 +1,61 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: teslacam-merge
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Paul Duncan
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2019-11-15 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: "\n Combine TeslaCam videos into a single output video. Allows you
14
+ to\n set the output video size, and add a title to the output video.\n "
15
+ email: pabs@pablotron.org
16
+ executables:
17
+ - teslacam-merge
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - README.md
22
+ - Rakefile
23
+ - bin/teslacam-merge
24
+ - lib/teslacam.rb
25
+ - lib/teslacam/cli.rb
26
+ - lib/teslacam/cli/config.rb
27
+ - lib/teslacam/config.rb
28
+ - lib/teslacam/filter.rb
29
+ - lib/teslacam/model.rb
30
+ - lib/teslacam/size.rb
31
+ - license.txt
32
+ homepage: https://github.com/pablotron/teslacam-merge
33
+ licenses:
34
+ - MIT
35
+ metadata:
36
+ bug_tracker_uri: https://github.com/pablotron/teslacam-merge/issues
37
+ documentation_uri: https://pablotron.github.io/teslacam-merge/
38
+ homepage_uri: https://github.com/pablotron/teslacam-merge
39
+ source_code_uri: https://github.com/pablotron/teslacam-merge
40
+ wiki_uri: https://github.com/pablotron/teslacam-merge/wiki
41
+ post_install_message:
42
+ rdoc_options: []
43
+ require_paths:
44
+ - lib
45
+ required_ruby_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '0'
50
+ required_rubygems_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ requirements: []
56
+ rubyforge_project:
57
+ rubygems_version: 2.7.6.2
58
+ signing_key:
59
+ specification_version: 4
60
+ summary: Combine TeslaCam videos into a single output video.
61
+ test_files: []