magma_cli 0.0.8

Sign up to get free protection for your applications and to get access to all the features.
data/lib/magma/app.rb ADDED
@@ -0,0 +1,73 @@
1
+ # rubocop:disable Lint/MissingCopEnableDirective, Metrics/LineLength
2
+
3
+ require 'etc'
4
+ require 'tempfile'
5
+ require 'thor'
6
+ require 'magma/preparer'
7
+ require 'magma/renderer'
8
+ require 'magma/templater'
9
+
10
+ module Magma
11
+ # The CLI application
12
+ class App < Thor
13
+ class << self
14
+ def exit_on_failure?
15
+ true
16
+ end
17
+ end
18
+
19
+ desc 'render [outfile]', 'Renders an MLT into an HLS video'
20
+ option :context, type: :hash, default: {}, aliases: 'c', desc: 'Render-time context'
21
+ option :data_file, type: :string, aliases: 'D', desc: 'Static data file, accepts JSON or YML'
22
+ option :enable_real_time, type: :boolean, default: false, aliases: 'r', desc: 'Render in real-time'
23
+ option :format, type: :string, default: 'mp4', aliases: 'f', enum: Renderer::SUPPORTED_FORMATS, desc: 'Encoding format'
24
+ option :globals, type: :hash, default: {}, aliases: 'g', desc: 'Global data overrides'
25
+ option :infile, type: :string, aliases: 'i', desc: 'Input file', required: true
26
+ option :length, type: :numeric, default: 2, desc: 'Set the target length in seconds'
27
+ option :preset, type: :string, default: 'fast', desc: 'Video preset for encoding'
28
+ option :real_time, type: :numeric, default: Etc.nprocessors, aliases: 'n', desc: 'Number of cores (real-time)'
29
+ option :size, type: :string, default: '1280x720', aliases: 's', desc: 'Frame size (width x height)'
30
+ option :skip_template, type: :boolean, default: false, desc: 'Skips template-filling step'
31
+ option :vcodec, type: :string, desc: 'Video codec to use for encoding'
32
+ def render(outfile)
33
+ if options[:skip_template]
34
+ Renderer.call outfile, options
35
+ else
36
+ template = Tempfile.new [File.basename(outfile), '.mlt']
37
+
38
+ begin
39
+ Preparer.call options[:infile], options.merge(outfile: template)
40
+ Templater.call template, options.merge(outfile: template)
41
+ Renderer.call outfile, options.merge(infile: template.path)
42
+ ensure
43
+ template.close
44
+ template.unlink
45
+ end
46
+ end
47
+ end
48
+
49
+ desc 'template [file]', 'Fills a template'
50
+ option :context, type: :hash, default: {}, aliases: 'c', desc: 'Render-time context'
51
+ option :globals, type: :hash, default: {}, aliases: 'g', desc: 'Global data overrides'
52
+ option :data_file, type: :string, aliases: 'D', desc: 'Static data file, accepts JSON or YML'
53
+ option :outfile, type: :string, aliases: 'o', desc: 'Output file'
54
+ option :overwrite, type: :boolean, default: false, aliases: 'O', desc: 'Overwrite input file'
55
+ option :print, type: :string, aliases: 'p', desc: 'Print output'
56
+ def template(infile = nil)
57
+ Templater.call infile, options
58
+ end
59
+
60
+ desc 'prepare [file]', 'Prepares a template'
61
+ option :outfile, type: :string, aliases: 'o', desc: 'Output file'
62
+ option :overwrite, type: :boolean, default: false, aliases: 'O', desc: 'Overwrite input file'
63
+ option :print, type: :string, aliases: 'p', desc: 'Print output'
64
+ def prepare(infile = nil)
65
+ Preparer.call infile, options
66
+ end
67
+
68
+ desc 'version', 'Displays the version'
69
+ def version
70
+ puts Magma::VERSION
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,36 @@
1
+ require 'active_support/concern'
2
+
3
+ module Magma
4
+ module Common
5
+ extend ActiveSupport::Concern
6
+
7
+ class_methods do
8
+ def call(*args)
9
+ new(*args).call
10
+ end
11
+ end
12
+
13
+ def handle_output!(output, options)
14
+ output!(output) if options[:print]
15
+ overwrite!(options[:infile], output) if options[:overwrite]
16
+ save!(options[:outfile], output) if options[:outfile]
17
+ output
18
+ end
19
+
20
+ private
21
+
22
+ def output!(output)
23
+ puts output
24
+ end
25
+
26
+ def overwrite!(infile, output)
27
+ raise('No file to overwrite') unless infile
28
+ File.write infile, output
29
+ end
30
+
31
+ def save!(outfile, output)
32
+ raise('No output file specified') unless outfile
33
+ File.write outfile, output
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,90 @@
1
+ require 'active_support/core_ext/hash/indifferent_access'
2
+ require 'active_support/core_ext/hash/deep_merge'
3
+
4
+ module Magma::Config
5
+ class MLT < ActiveSupport::HashWithIndifferentAccess
6
+ VERSION = 1
7
+ ALLOWED_KEYS = %w[
8
+ globals
9
+ scopes
10
+ type
11
+ version
12
+ ].freeze
13
+
14
+ attr_reader :data
15
+
16
+ def initialize(hash = {})
17
+ super({
18
+ globals: {},
19
+ scopes: {},
20
+ type: 'mlt',
21
+ version: VERSION,
22
+ }.merge(hash))
23
+ validate!
24
+ end
25
+
26
+ def globals
27
+ self[:globals]
28
+ end
29
+
30
+ def operations
31
+ raise 'needs to be resolved' unless resolved?
32
+ data.select { |key, _value| key[0] == '$' }
33
+ end
34
+
35
+ def resolve(context = {})
36
+ @data = context.reduce(globals.clone) do |res, (key, value)|
37
+ scope = scopes.fetch(key, {}).fetch(value, nil)
38
+ res.tap { |r| r.deep_merge!(scope) if scope }
39
+ end
40
+ end
41
+
42
+ def resolved?
43
+ data && true
44
+ end
45
+
46
+ def scopes
47
+ self[:scopes]
48
+ end
49
+
50
+ def transform(doc)
51
+ raise 'needs to be resolved' unless resolved?
52
+ operations.each do |key, obj|
53
+ # Skip if not prefixed with $
54
+ leader = key[0]
55
+ next unless leader == '$'
56
+
57
+ # Select node set
58
+ nodeset = doc.css(key[1..-1])
59
+
60
+ case obj
61
+ when Hash
62
+ obj.each do |name, value|
63
+ if value
64
+ nodeset.attr(name, value)
65
+ else
66
+ nodeset.remove_attr(name)
67
+ end
68
+ end
69
+ else
70
+ text = obj.to_s
71
+ nodeset.each { |node| node.content = text }
72
+ end
73
+ end
74
+ doc
75
+ end
76
+
77
+ def variables
78
+ raise 'needs to be resolved' unless resolved?
79
+ data.reject { |key, _value| key[0] == '$' }
80
+ end
81
+
82
+ private
83
+
84
+ def validate!
85
+ extras = keys - ALLOWED_KEYS
86
+ raise "extra keys: #{extras}" unless extras.empty?
87
+ raise 'not type: mlt' unless self[:type] == 'mlt'
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,4 @@
1
+ module Magma
2
+ module Config
3
+ end
4
+ end
@@ -0,0 +1,85 @@
1
+ require 'magma/common'
2
+ require 'magma/utils'
3
+ require 'nokogiri'
4
+ require 'securerandom'
5
+
6
+ module Magma
7
+ class Preparer
8
+ include Magma::Common
9
+ include Magma::Utils
10
+
11
+ def initialize(infile, options = {})
12
+ @infile = infile
13
+ @options = options
14
+ end
15
+
16
+ def call
17
+ stabilize_producers!
18
+ handle_output! xml, options.merge(infile: infile)
19
+ xml
20
+ end
21
+
22
+ private
23
+
24
+ attr_accessor :infile, :options
25
+
26
+ def doc
27
+ @doc ||= Nokogiri::XML(source)
28
+ end
29
+
30
+ def log(message)
31
+ # Don't print the message if we're out-putting to STDOUT
32
+ puts message unless options[:print]
33
+ end
34
+
35
+ def resource_path
36
+ return Dir.pwd unless infile
37
+ File.dirname File.expand_path(infile)
38
+ end
39
+
40
+ def stabilize_producers! # rubocop:disable Metrics/AbcSize
41
+ map = Hash[doc.xpath('//producer').map do |producer|
42
+ id = producer.get_attribute 'id'
43
+ next nil if uuid? id
44
+ [producer.get_attribute('id'), uuid]
45
+ end.compact]
46
+ return if map.empty?
47
+
48
+ doc.traverse do |node|
49
+ id = node.get_attribute 'id'
50
+ case node.name
51
+ when 'producer'
52
+ if map[id]
53
+ log "Re-mapping producer##{id} id to #{map[id]}"
54
+ node.set_attribute('id', map[id])
55
+
56
+ node.search('property[name="resource"]').each do |property|
57
+ if File.file?(File.join(resource_path, property.content))
58
+ log "Expanding resource '#{resource_path}/#{property.content}'"
59
+ property.content = File.join(resource_path, property.content)
60
+ end
61
+ end
62
+ end
63
+ else
64
+ producer = node.get_attribute 'producer'
65
+ if map[producer]
66
+ node.set_attribute('producer', map[producer])
67
+ log "Re-mapping #{node.name}#{id ? '#' + id : ''} producer to #{map[producer]}"
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ def source
74
+ @source ||= read_file_or_stdin(infile) || raise('Need a template')
75
+ end
76
+
77
+ def uuid
78
+ SecureRandom.uuid
79
+ end
80
+
81
+ def xml
82
+ doc.to_xml
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,123 @@
1
+ require 'magma/utils'
2
+ require 'pry'
3
+
4
+ module Magma
5
+ class Renderer
6
+ SUPPORTED_FORMATS = %w[hls mp4].freeze
7
+ MELT = 'melt'.freeze
8
+
9
+ include Magma::Common
10
+ include Magma::Utils
11
+
12
+ def initialize(outfile, options)
13
+ @outfile = outfile
14
+ @options = options
15
+ end
16
+
17
+ def call
18
+ render!
19
+ end
20
+
21
+ private
22
+
23
+ attr_accessor :options, :outfile
24
+
25
+ # Adds sane defaults to config
26
+ def add_defaults(options)
27
+ {
28
+ hls_list_size: 0,
29
+ start_number: 0,
30
+ }.merge(options)
31
+ end
32
+
33
+ # Formats keys to melt arguments
34
+ def format(options)
35
+ options.map do |key, value|
36
+ [{
37
+ format: :f,
38
+ size: :s,
39
+ length: :hls_time,
40
+ }[key] || key, value].map(&:to_s).join('=')
41
+ end
42
+ end
43
+
44
+ # Finds and constructs a melt invocation string
45
+ def melt(args)
46
+ bin = which(MELT) || raise("#{MELT} not found. Is MLT installed?")
47
+ "#{bin} #{args}"
48
+ end
49
+
50
+ # Constructs melt arguments from options hash
51
+ def melt_args(options)
52
+ [
53
+ options[:infile],
54
+ "-consumer avformat:#{outfile}",
55
+ ].concat(pipe(options, %i[
56
+ symbolize_keys
57
+ add_defaults
58
+ select
59
+ transform
60
+ table
61
+ format
62
+ ]))
63
+ end
64
+
65
+ # Renders the project
66
+ def render!
67
+ cmd = melt melt_args(options.merge(outfile: outfile)).join(' ')
68
+ puts "Run: #{cmd}"
69
+ puts
70
+
71
+ run(cmd) do |_stdin, _stdout, stderr|
72
+ stderr.each("\r") do |line|
73
+ STDOUT.write "\r#{line}"
74
+ end
75
+ end
76
+ end
77
+
78
+ # Selects certain options
79
+ def select(options)
80
+ # Clone original
81
+ options = options.clone
82
+
83
+ # Handle related options
84
+ options.delete(:real_time) unless options.delete(:enable_real_time)
85
+
86
+ # Reject
87
+ options.select do |key, value|
88
+ !value.nil? && %i[
89
+ format
90
+ hls_list_size
91
+ real_time
92
+ length
93
+ preset
94
+ size
95
+ start_number
96
+ vcodec
97
+ ].include?(key)
98
+ end
99
+ end
100
+
101
+ # Prints a table and passes through the options hash
102
+ def table(options)
103
+ lpadding = options.keys.max_by(&:length).length + 2
104
+ rpadding = options.values.max_by { |v| v.to_s.length }.to_s.length
105
+ puts 'PROPERTY'.ljust(lpadding) + 'VALUE'
106
+ puts '=' * (lpadding + rpadding)
107
+ options.keys.sort.each do |key|
108
+ puts key.to_s.ljust(lpadding) + options[key].to_s
109
+ end
110
+ puts
111
+ options
112
+ end
113
+
114
+ # Transforms certain options values
115
+ def transform(options)
116
+ options.map do |key, value|
117
+ [key, ({
118
+ real_time: ->(x) { "-#{x}" },
119
+ }[key] || proc { |x| x }).call(value)]
120
+ end.to_h
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,78 @@
1
+ require 'magma/config/mlt'
2
+ require 'magma/common'
3
+ require 'magma/utils'
4
+ require 'liquid'
5
+ require 'nokogiri'
6
+
7
+ module Magma
8
+ class Templater
9
+ include Magma::Common
10
+ include Magma::Utils
11
+
12
+ def initialize(infile, options)
13
+ @infile = infile
14
+ @options = options
15
+ end
16
+
17
+ def call
18
+ pipe source, [
19
+ :template,
20
+ :render,
21
+ :parse,
22
+ :transform,
23
+ '.to_xml',
24
+ ->(result) { handle_output!(result, options.merge(infile: infile)) },
25
+ ]
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :infile, :options
31
+
32
+ def config
33
+ @config ||= Magma::Config::MLT.new(data).tap do |c|
34
+ c.resolve(options[:context] || {})
35
+ end
36
+ end
37
+
38
+ def data
39
+ @data ||= options[:data_file].nil? ? {} : YAML.safe_load(File.read(options[:data_file]), [], [], true)
40
+ end
41
+
42
+ def source
43
+ @source ||= read_file_or_stdin(infile) || raise('Need a template')
44
+ end
45
+
46
+ ### Pipeline
47
+
48
+ # Renders a Liquid template
49
+ def render(template)
50
+ template.render(config.variables.deep_merge(options[:globals]), strict_variables: true).tap do
51
+ if template.errors&.length&.positive?
52
+ puts template.errors
53
+ raise template.errors.map(&:to_s).join('; ')
54
+ end
55
+ end
56
+ end
57
+
58
+ # Parses a template into a DOM
59
+ def parse(template)
60
+ Nokogiri::XML template
61
+ end
62
+
63
+ # Parses a source into a template
64
+ def template(source)
65
+ Liquid::Template.parse(source, error_mode: :strict)
66
+ end
67
+
68
+ # Applies transformations to a document
69
+ def transform(doc)
70
+ config.transform doc
71
+ end
72
+
73
+ # Converts a docuemtn into XML
74
+ def xml(doc)
75
+ doc.to_xml
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,57 @@
1
+ require 'open3'
2
+
3
+ module Magma
4
+ module Utils
5
+ # Reads a file or from STDIN if piped
6
+ def read_file_or_stdin(filename = nil)
7
+ filename.nil? ? !STDIN.tty? && STDIN.read : File.read(filename)
8
+ end
9
+
10
+ # Returns whether a string is a UUID
11
+ def uuid?(uuid)
12
+ uuid_regex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
13
+ uuid_regex =~ uuid.to_s.downcase
14
+ end
15
+
16
+ # Applies methods/procs to an object in order
17
+ def pipe(object, methods = [])
18
+ methods.reduce(object) do |acc, method|
19
+ case method
20
+ when Proc || method.respond_to?(:call)
21
+ method.call(acc)
22
+ when Symbol
23
+ send(method, acc)
24
+ when String
25
+ raise "must start with '.'" unless method[0] == '.'
26
+ acc.send(method[1..-1].to_sym)
27
+ else
28
+ raise "unexpected pipe #{method}"
29
+ end
30
+ end
31
+ end
32
+
33
+ # Delegates to Open3
34
+ def run(*args, &block)
35
+ return Open3.popen3(*args, &block) if block_given?
36
+ Open3.popen3(*args)
37
+ end
38
+
39
+ # Symbolizes a hash's keys
40
+ def symbolize_keys(hash)
41
+ hash.each_with_object({}) { |(k, v), memo| memo[k.to_sym] = v }
42
+ end
43
+
44
+ # Cross-platform way of finding an executable in the $PATH
45
+ # Reference: https://stackoverflow.com/a/5471032/3557448
46
+ def which(cmd)
47
+ exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : ['']
48
+ ENV['PATH'].split(File::PATH_SEPARATOR).each do |path|
49
+ exts.each do |ext|
50
+ exe = File.join(path, "#{cmd}#{ext}")
51
+ return exe if File.executable?(exe) && !File.directory?(exe)
52
+ end
53
+ end
54
+ nil
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,3 @@
1
+ module Magma
2
+ VERSION = '0.0.8'.freeze
3
+ end
data/lib/magma.rb ADDED
@@ -0,0 +1,6 @@
1
+ require 'magma/app'
2
+ require 'magma/version'
3
+
4
+ # The main CLI module
5
+ module Magma
6
+ end
data/magma.gemspec ADDED
@@ -0,0 +1,30 @@
1
+ lib = File.expand_path('lib', __dir__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'magma/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'magma_cli'
7
+ spec.version = Magma::VERSION
8
+ spec.authors = ['Allan Reyes']
9
+ spec.email = ['allanbreyes@users.noreply.github.com']
10
+
11
+ spec.summary = 'Magma CLI'
12
+ spec.description = 'Command-line interface for MLT templating and rendering'
13
+ spec.homepage = 'https://github.com/darbylabs/magma'
14
+ spec.license = 'LGPL-2.1'
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
17
+ f.match(%r{^(examples|features|spec|test)/})
18
+ end
19
+ spec.executables = ['magma']
20
+ spec.require_paths = ['lib']
21
+
22
+ spec.add_dependency 'activesupport', '~> 5.2'
23
+ spec.add_dependency 'liquid', '~> 4.0'
24
+ spec.add_dependency 'nokogiri', '~> 1.8'
25
+ spec.add_dependency 'thor', '~> 0.20'
26
+
27
+ spec.add_development_dependency 'bundler', '~> 1.16'
28
+ spec.add_development_dependency 'rake', '~> 10.0'
29
+ spec.add_development_dependency 'rspec', '~> 3.0'
30
+ end