castaway 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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