castaway 1.0.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,26 @@
1
+ module Castaway
2
+ module Interpolation
3
+
4
+ # A linear interpolation between two values
5
+ class Linear
6
+ attr_reader :start, :finish
7
+
8
+ def initialize(start, finish)
9
+ @start = start
10
+ @finish = finish
11
+ @delta = finish - start
12
+ end
13
+
14
+ def [](t)
15
+ if t < 0
16
+ @start
17
+ elsif t > 1
18
+ @finish
19
+ else
20
+ @delta * t + @start
21
+ end
22
+ end
23
+ end
24
+
25
+ end
26
+ end
@@ -0,0 +1,63 @@
1
+ module Castaway
2
+
3
+ class Point < Struct.new(:x, :y)
4
+ def self.make(*args)
5
+ if args.length == 1 && args[0].is_a?(Array)
6
+ new(args[0][0], args[0][1])
7
+ elsif args.length == 1 && args[0].is_a?(Point)
8
+ args[0]
9
+ else
10
+ raise ArgumentError, "can't make a point from #{args.inspect}"
11
+ end
12
+ end
13
+
14
+ def *(factor)
15
+ if factor.respond_to?(:x)
16
+ Point.new(x * factor.x, y * factor.y)
17
+ elsif factor.respond_to?(:width)
18
+ Point.new(x * factor.width, y * factor.height)
19
+ else
20
+ Point.new(x * factor, y * factor)
21
+ end
22
+ end
23
+
24
+ def -(pt)
25
+ Point.new(x - pt.x, y - pt.y)
26
+ end
27
+
28
+ def +(pt)
29
+ Point.new(x + pt.x, y + pt.y)
30
+ end
31
+
32
+ def zero?
33
+ x == 0 && y == 0
34
+ end
35
+
36
+ def translate(dx, dy)
37
+ Point.new(x + dx, y + dy)
38
+ end
39
+
40
+ def scale(sx, sy = sx)
41
+ Point.new(x * sx, y * sy)
42
+ end
43
+
44
+ def rotate(radians)
45
+ cos = Math.cos(radians)
46
+ sin = Math.sin(radians)
47
+
48
+ nx = x * cos - y * sin
49
+ ny = y * cos + x * sin
50
+
51
+ Point.new(nx, ny)
52
+ end
53
+
54
+ def to_s
55
+ format('(%.2f, %.2f)', x, y)
56
+ end
57
+
58
+ def to_geometry
59
+ format('+%.2f+%.2f', x, y)
60
+ end
61
+ end
62
+
63
+ end
@@ -0,0 +1,124 @@
1
+ require 'fileutils'
2
+ require 'castaway/range'
3
+ require 'castaway/size'
4
+ require 'castaway/timeline'
5
+ require 'ruby-progressbar'
6
+
7
+ require 'castaway/production/class_methods'
8
+ require 'castaway/production/audio'
9
+ require 'castaway/production/scenes'
10
+
11
+ module Castaway
12
+ class Production
13
+ extend Castaway::Production::ClassMethods
14
+ include Castaway::Production::Audio
15
+ include Castaway::Production::Scenes
16
+
17
+ attr_reader :options
18
+ attr_reader :current_scene, :scenes
19
+ attr_reader :resolution, :fps
20
+
21
+ def initialize(options = {})
22
+ @options = options
23
+ @resolution = _translate_resolution(options[:resolution] || '480p')
24
+ @deliverable = options[:deliverable]
25
+ @fps = options[:fps] || 30
26
+
27
+ _build_scenes
28
+ end
29
+
30
+ def produce(range = Castaway::Range.new(self))
31
+ FileUtils.mkdir_p(self.class.output_path)
32
+
33
+ timeline = _construct_timeline
34
+ _produce_frames(timeline, range)
35
+ soundtrack = _produce_soundtrack(range)
36
+ _produce_movie(soundtrack)
37
+ end
38
+
39
+ def deliverable
40
+ @deliverable ||= begin
41
+ if self.class.name
42
+ self.class.name.split(/::/).last.
43
+ gsub(/([^A-Z]+)([A-Z]+)/) { "#{$1}-#{$2.downcase}" }.
44
+ gsub(/([^0-9]+)([0-9]+)/) { "#{$1}-#{$2}" }.
45
+ downcase + '.mp4'
46
+ else
47
+ 'production.mp4'
48
+ end
49
+ end
50
+ end
51
+
52
+ def _construct_timeline
53
+ Castaway::Timeline.new(resolution, fps).tap do |timeline|
54
+ @scenes.each { |scene| scene.construct(timeline) }
55
+ end
56
+ end
57
+
58
+ def _template(ext = nil)
59
+ File.join(self.class.output_path, format('frame-%s%s', '%05d', ext))
60
+ end
61
+
62
+ def _produce_frames(timeline, range)
63
+ template = _template
64
+
65
+ start_frame = range.start_frame
66
+ end_frame = range.end_frame
67
+
68
+ progress_end = end_frame - start_frame + 1
69
+ progress = ProgressBar.create(starting_at: 0, total: progress_end)
70
+
71
+ start_frame.upto(end_frame) do |f|
72
+ timeline.render_frame(f, name: format(template, f - start_frame))
73
+ progress.increment
74
+ end
75
+ end
76
+
77
+ def _produce_movie(soundtrack)
78
+ FileUtils.rm_f(deliverable)
79
+
80
+ ffmpeg = Chaussettes::Tool.new('ffmpeg')
81
+ ffmpeg << '-thread_queue_size' << 8192
82
+ ffmpeg << '-r' << fps << '-s' << resolution.to_resolution
83
+ ffmpeg << '-i' << _template('.png') << '-i' << soundtrack
84
+ ffmpeg << '-vcodec' << 'libx264'
85
+ ffmpeg << '-preset' << 'veryslow' << '-tune' << 'stillimage'
86
+ ffmpeg << '-crf' << 23 << '-pix_fmt' << 'yuv420p' << '-acodec' << 'aac'
87
+ ffmpeg << deliverable
88
+
89
+ puts ffmpeg.to_s
90
+ system(ffmpeg.to_s)
91
+ end
92
+
93
+ def _construct_scene(scene, definition)
94
+ instance_exec(scene, &definition)
95
+ ensure
96
+ @current_scene = nil
97
+ end
98
+
99
+ def _translate_resolution(res)
100
+ case res
101
+ when Castaway::Size then res
102
+ when Array then Castaway::Size.new(res.first.to_i, res.last.to_i)
103
+ when /^(\d+)p$/ then _hd_resolution(Regexp.last_match(1))
104
+ when Integer then _hd_resolution(res)
105
+ when /^(\d+)x(\d+)$/ then
106
+ Castaway::Size.new(Regexp.last_match(1).to_i, Regexp.last_match(2).to_i)
107
+ else raise ArgumentError,
108
+ "don't know how to turn #{res.inspect} into resolution"
109
+ end
110
+ end
111
+
112
+ def _hd_resolution(rows)
113
+ rows = rows.to_i
114
+ cols = rows * 16 / 9.0
115
+ Castaway::Size.new(cols.ceil, rows)
116
+ end
117
+
118
+ def _next_filename(ext = nil)
119
+ @next_filename ||= 0
120
+ File.join(self.class.output_path,
121
+ format('__%04d%s', @next_filename += 1, ext))
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,161 @@
1
+ require 'chaussettes'
2
+
3
+ module Castaway
4
+ class Production
5
+ module Audio
6
+
7
+ # Returns the filename associated with the soundclip with the given id.
8
+ # If the soundclip was declared with a block, the block will be evaluated
9
+ # with a new `Chaussettes::Clip` instance, and the a temporary filename
10
+ # containing the resulting audio will be returned,
11
+ def soundclip(id)
12
+ @soundclips ||= {}
13
+ @soundclips[id] ||= begin
14
+ definition = self.class.soundclip(id)
15
+ raise "no soundclip #{id.inspect}" unless definition
16
+
17
+ case definition
18
+ when String then
19
+ definition
20
+ when Proc then
21
+ _next_filename('.aiff').tap do |filename|
22
+ Chaussettes::Clip.new do |clip|
23
+ instance_exec(clip, &definition)
24
+ clip.out(filename)
25
+ clip.run
26
+ end
27
+ end
28
+ else
29
+ raise ArgumentError, "can't use #{definition.inspect} as soundclip"
30
+ end
31
+ end
32
+ end
33
+
34
+ # Ducks the `basis` audio beneath the given `overlays`. Each overlay
35
+ # should be a hash containing at least a `:clip` key, corresponding to
36
+ # a filename to be used for the overlay clip. Additional keys are:
37
+ #
38
+ # * `:at` (default 0, where in `basis` the overlay should be applied)
39
+ # * `:adjust` (default 0.5, how much the basis audio should be reduced)
40
+ # * `:speed` (default 0.5, how many seconds the fade in/out should take)
41
+ #
42
+ # Returns a new filename representing the results of the duck operation.
43
+ def duck(basis, *overlays)
44
+ _next_filename('.aiff').tap do |result|
45
+ Chaussettes::Clip.new do |clip|
46
+ clip.mix.out result
47
+
48
+ count = overlays.reduce(0) do |total, options|
49
+ total + _duck(clip, basis, options)
50
+ end
51
+
52
+ # restore volume
53
+ clip.chain.vol count
54
+
55
+ clip.run
56
+ end
57
+ end
58
+ end
59
+
60
+ def _duck(clip, basis, options)
61
+ adjust = options[:adjust] || 0.5
62
+ speed = options[:speed] || 0.5
63
+
64
+ state = {
65
+ input: basis, overtrack: options[:clip],
66
+ at: options[:at] || 0,
67
+ adjust: adjust, speed: speed,
68
+ info: Chaussettes::Info.new(options[:clip]),
69
+ right_delay: adjust * speed, left_delay: (1 - adjust) * speed
70
+ }
71
+
72
+ count = _build_intro(clip, state)
73
+ count += _build_middle(clip, state)
74
+ count += _build_last(clip, state)
75
+ count + _build_overlay(clip, state)
76
+ end
77
+
78
+ def _build_intro(clip, state)
79
+ Chaussettes::Clip.new do |c|
80
+ c.in(state[:input])
81
+ c.out(device: :stdout).type(:aiff).rate(48_000).channels(2)
82
+ c.chain.fade(0, state[:at] + state[:right_delay],
83
+ state[:speed], type: :linear)
84
+
85
+ clip.in(c).type :aiff
86
+ end
87
+
88
+ 1
89
+ end
90
+
91
+ def _build_middle(clip, state)
92
+ Chaussettes::Clip.new do |c|
93
+ c.in(state[:input])
94
+ c.out(device: :stdout).type(:aiff).rate(48_000).channels(2)
95
+ c.chain.
96
+ trim(state[:at], state[:info].duration).
97
+ vol(state[:adjust]).
98
+ pad(state[:at])
99
+
100
+ clip.in(c).type :aiff
101
+ end
102
+
103
+ 1
104
+ end
105
+
106
+ def _build_last(clip, state)
107
+ return 0 unless state[:at] + state[:info].duration < duration
108
+
109
+ Chaussettes::Clip.new do |c|
110
+ c.in(state[:input])
111
+ c.out(device: :stdout).type(:aiff).rate(48_000).channels(2)
112
+ c.chain.trim(state[:at] + state[:info].duration - state[:left_delay]).
113
+ fade(state[:speed], type: :linear).
114
+ pad(state[:at] + state[:info].duration - state[:left_delay])
115
+
116
+ clip.in(c).type :aiff
117
+ end
118
+
119
+ 1
120
+ end
121
+
122
+ def _build_overlay(clip, state)
123
+ Chaussettes::Clip.new do |c|
124
+ c.in(state[:overtrack])
125
+ c.out(device: :stdout).type(:aiff).rate(48_000).channels(2)
126
+ c.chain.pad(state[:at])
127
+
128
+ clip.in(c).type :aiff
129
+ end
130
+
131
+ 1
132
+ end
133
+
134
+ def _produce_soundtrack(range)
135
+ block = self.class.soundtrack
136
+ return nil unless block
137
+
138
+ _next_filename('.aiff').tap do |filename|
139
+ real_filename = filename
140
+ filename = range.truncated? ? _next_filename('.aiff') : real_filename
141
+
142
+ Chaussettes::Clip.new do |clip|
143
+ instance_exec(clip, &block)
144
+ clip.out(filename)
145
+ clip.run
146
+ end
147
+
148
+ if range.truncated?
149
+ Chaussettes::Clip.new do |clip|
150
+ clip.in(filename)
151
+ clip.chain.trim range.start_time, range.end_time - range.start_time
152
+ clip.out(real_filename)
153
+ clip.run
154
+ end
155
+ end
156
+ end
157
+ end
158
+
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,148 @@
1
+ require 'castaway/times'
2
+
3
+ module Castaway
4
+ class Production
5
+ module ClassMethods
6
+ include Castaway::Times
7
+
8
+ # Returns an Array of Castaway::Scene objects corresponding to the
9
+ # scenes that have been defined.
10
+ def scenes
11
+ @scenes ||= []
12
+ end
13
+
14
+ # Returns a Hash, mapping ids to either direct values (path names), or
15
+ # blocks (which are intended to configure a soundclip).
16
+ def soundclips
17
+ @soundclips ||= {}
18
+ end
19
+
20
+ # Declare a new soundclip with the given `id`. If a `value` is given, it
21
+ # is expected to be a path to a sound file. For example:
22
+ #
23
+ # soundclip :narration, resource('narration.mp3')
24
+ #
25
+ # If a block is given, it is expected to accept a single parameter
26
+ # (a Chaussettes::Clip instance), and configure the soundclip on that
27
+ # instance.
28
+ #
29
+ # soundclip :theme do |clip|
30
+ # clip.in resource('theme.mp3')
31
+ # clip.chain.
32
+ # trim(0, 15). # grab the first 15s of the clip
33
+ # fade(0.5, 0, 5) # fade in 0.5s, and then out 5s at the end
34
+ # end
35
+ def soundclip(id, value = nil, &block)
36
+ if value.nil? && !block
37
+ soundclips[id]
38
+ else
39
+ soundclips[id] = value || block
40
+ end
41
+ end
42
+
43
+ # Declare the soundtrack for the production. Every production may have
44
+ # zero or one soundtracks. The block you provide here should accept a
45
+ # single parameter--a `Chausettes::Clip` instance--which must be populated
46
+ # with the desired audio for the production.
47
+ #
48
+ # soundtrack do |clip|
49
+ # clip.in(
50
+ # duck(soundclip(:theme),
51
+ # clip: soundclip[:narration], at: 3)
52
+ # )
53
+ # end
54
+ def soundtrack(&block)
55
+ if block
56
+ @soundtrack = block
57
+ else
58
+ @soundtrack
59
+ end
60
+ end
61
+
62
+ # Returns a list of paths that will be searched for resources. By
63
+ # default, the searched paths are 'sounds' and 'images'. See the
64
+ # #resource method for how to add paths to this list.
65
+ def resource_paths
66
+ @resource_paths ||= %w( sounds images )
67
+ end
68
+
69
+ # Returns the output path to be used for generated files, like frames and
70
+ # intermediate audio. It defaults to 'build'. See the #output method for
71
+ # how to set this value.
72
+ def output_path
73
+ @output_path ||= 'build'
74
+ end
75
+
76
+ # Adds the given path to the list of paths that will be searched by
77
+ # Castaway for resources. (See #resource_paths)
78
+ def resource_path(path)
79
+ resource_paths << path
80
+ end
81
+
82
+ # Looks for a file with the given name in the defined resource paths
83
+ # (see #resource_paths). Returns the path to the file if it is found in
84
+ # one of the paths, otherwise raises `Errno::ENOENT`.
85
+ def resource(name)
86
+ resource_paths.each do |path|
87
+ full = File.join(path, name)
88
+ return full if File.exist?(full)
89
+ end
90
+
91
+ raise Errno::ENOENT, "no such resource #{name} found"
92
+ end
93
+
94
+ # Declares the directory to be used for storing intermediate media, like
95
+ # frames and audio. (See #output_path)
96
+ def output(path)
97
+ @output_path = path
98
+ end
99
+
100
+ # Declares a new scene with the given name. Although it is not required
101
+ # that all scenes have unique names, it is recommended. The given block
102
+ # is invoked, without arguments, when the scene is constructed. (See
103
+ # Castaway::Scene)
104
+ def scene(name, &block)
105
+ scenes << [name, block]
106
+ end
107
+
108
+ # Declares the end-time of the production. If this is not set, your final
109
+ # scene will likely be truncated.
110
+ def finish(finish)
111
+ scene(nil) { start finish }
112
+ end
113
+
114
+ # Parses the given value into a float representing a number of seconds.
115
+ # See Castaway::Times.
116
+ def time(value)
117
+ _parse_time(value)
118
+ end
119
+
120
+ # Declares a pointer using the image at the given `path`. If an `:id`
121
+ # option is given, it will be used to identify the pointer. Otherwise,
122
+ # it will use the default identifier. See Castaway::Scene.
123
+ def pointer(path, options = {})
124
+ id = options[:id] || :default
125
+ pointers[id] = [path, options]
126
+ end
127
+
128
+ # Returns a Hash of pointer declarations, mapping ids to path/option
129
+ # data.
130
+ def pointers
131
+ @pointers ||= {}
132
+ end
133
+
134
+ # Treats the given `file` as a Castaway script, and evaluates it in the
135
+ # context of a new, anonymous subclass of Castaway::Production. This new
136
+ # subclass is returned.
137
+ def from_script(file)
138
+ Class.new(self) do
139
+ class_eval File.read(file), file
140
+ end
141
+ rescue Exception => e
142
+ puts "#{e.class} (#{e.message})"
143
+ puts e.backtrace
144
+ abort
145
+ end
146
+ end
147
+ end
148
+ end