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