teslacam-merge 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.
@@ -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: []