castaway 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +123 -0
- data/bin/castaway +5 -0
- data/lib/castaway.rb +1 -0
- data/lib/castaway/animation.rb +37 -0
- data/lib/castaway/box.rb +77 -0
- data/lib/castaway/cli/build.rb +83 -0
- data/lib/castaway/cli/main.rb +24 -0
- data/lib/castaway/cli/script.rb +28 -0
- data/lib/castaway/cli/version.rb +17 -0
- data/lib/castaway/effect.rb +59 -0
- data/lib/castaway/element/base.rb +280 -0
- data/lib/castaway/element/matte.rb +20 -0
- data/lib/castaway/element/pointer.rb +35 -0
- data/lib/castaway/element/still.rb +31 -0
- data/lib/castaway/element/text.rb +76 -0
- data/lib/castaway/interpolation.rb +23 -0
- data/lib/castaway/interpolation/linear.rb +26 -0
- data/lib/castaway/point.rb +63 -0
- data/lib/castaway/production.rb +124 -0
- data/lib/castaway/production/audio.rb +161 -0
- data/lib/castaway/production/class_methods.rb +148 -0
- data/lib/castaway/production/scenes.rb +39 -0
- data/lib/castaway/range.rb +70 -0
- data/lib/castaway/relative_to.rb +18 -0
- data/lib/castaway/scene.rb +145 -0
- data/lib/castaway/size.rb +47 -0
- data/lib/castaway/timeline.rb +80 -0
- data/lib/castaway/times.rb +33 -0
- data/lib/castaway/version.rb +3 -0
- metadata +134 -0
@@ -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
|