magma_cli 0.0.8

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.
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