screenkit 0.0.1 → 0.0.2
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 +4 -4
- data/.github/workflows/docker.yml +44 -0
- data/.github/workflows/ruby-tests.yml +21 -1
- data/Brewfile +5 -0
- data/CHANGELOG.md +7 -0
- data/CONTRIBUTING.md +25 -0
- data/DOCUMENTATION.md +125 -41
- data/Dockerfile +107 -0
- data/lib/screen_kit.rb +15 -16
- data/lib/screenkit/animation_filters.rb +16 -0
- data/lib/screenkit/callout/styles/base.rb +12 -0
- data/lib/screenkit/callout/styles/file_copy.rb +26 -0
- data/lib/screenkit/callout/styles/inline_block.rb +6 -9
- data/lib/screenkit/callout/styles/{default.rb → shadow_block.rb} +9 -11
- data/lib/screenkit/callout.rb +1 -1
- data/lib/screenkit/cli/episode.rb +1 -1
- data/lib/screenkit/config/episode.rb +6 -0
- data/lib/screenkit/config/project.rb +5 -2
- data/lib/screenkit/exporter/demotape.rb +8 -0
- data/lib/screenkit/exporter/episode.rb +37 -17
- data/lib/screenkit/exporter/segment.rb +262 -71
- data/lib/screenkit/generators/episode/callouts/001.yml +12 -0
- data/lib/screenkit/generators/episode/config.yml.erb +8 -8
- data/lib/screenkit/generators/project/screenkit.yml +46 -15
- data/lib/screenkit/schema_validator.rb +1 -1
- data/lib/screenkit/schemas/callout_styles/file_copy.json +8 -0
- data/lib/screenkit/schemas/callout_styles/inline_block.json +20 -0
- data/lib/screenkit/schemas/{callouts/default.json → callout_styles/shadow_block.json} +2 -2
- data/lib/screenkit/schemas/callouts/inline_block.json +20 -11
- data/lib/screenkit/schemas/callouts/shadow_block.json +39 -0
- data/lib/screenkit/schemas/episode.json +6 -43
- data/lib/screenkit/schemas/project.json +4 -5
- data/lib/screenkit/schemas/refs/anchor.json +1 -1
- data/lib/screenkit/schemas/refs/animation.json +1 -1
- data/lib/screenkit/schemas/refs/background.json +1 -1
- data/lib/screenkit/schemas/refs/{callout.json → callout_style.json} +6 -8
- data/lib/screenkit/schemas/refs/color.json +1 -1
- data/lib/screenkit/schemas/refs/demotape.json +1 -1
- data/lib/screenkit/schemas/refs/demotape_themes.json +1 -1
- data/lib/screenkit/schemas/refs/directory.json +1 -1
- data/lib/screenkit/schemas/refs/duration.json +1 -1
- data/lib/screenkit/schemas/refs/intro.json +1 -1
- data/lib/screenkit/schemas/refs/logo.json +1 -1
- data/lib/screenkit/schemas/refs/outro.json +1 -1
- data/lib/screenkit/schemas/refs/position.json +1 -1
- data/lib/screenkit/schemas/refs/scenes.json +1 -1
- data/lib/screenkit/schemas/refs/size.json +1 -1
- data/lib/screenkit/schemas/refs/sound.json +1 -1
- data/lib/screenkit/schemas/refs/spacing.json +1 -1
- data/lib/screenkit/schemas/refs/text_style.json +1 -1
- data/lib/screenkit/schemas/refs/transition.json +1 -1
- data/lib/screenkit/schemas/refs/tts.json +4 -41
- data/lib/screenkit/schemas/refs/tts_builtin.json +23 -0
- data/lib/screenkit/schemas/refs/watermark.json +1 -1
- data/lib/screenkit/schemas/tts/elevenlabs.json +11 -2
- data/lib/screenkit/schemas/tts/espeak.json +26 -0
- data/lib/screenkit/schemas/tts/say.json +11 -1
- data/lib/screenkit/shell.rb +6 -0
- data/lib/screenkit/time_formatter.rb +14 -0
- data/lib/screenkit/tts/base.rb +21 -0
- data/lib/screenkit/tts/eleven_labs.rb +8 -9
- data/lib/screenkit/tts/espeak.rb +30 -0
- data/lib/screenkit/tts/say.rb +5 -6
- data/lib/screenkit/utils.rb +6 -0
- data/lib/screenkit/version.rb +1 -1
- metadata +21 -42
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans-Bold.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans-BoldItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans-ExtraBoldItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans-Italic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans-Light.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans-LightItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans-Medium.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans-MediumItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans-Regular.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans-SemiBoldItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-Bold.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-BoldItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-ExtraBold.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-ExtraBoldItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-Italic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-Light.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-LightItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-Medium.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-MediumItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-Regular.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-SemiBold.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-SemiBoldItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-Bold.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-BoldItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-ExtraBold.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-ExtraBoldItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-Italic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-Light.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-LightItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-Medium.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-MediumItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-Regular.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-SemiBold.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-SemiBoldItalic.ttf +0 -0
|
@@ -6,30 +6,27 @@ module ScreenKit
|
|
|
6
6
|
class Callout
|
|
7
7
|
module Styles
|
|
8
8
|
class InlineBlock < Base
|
|
9
|
-
extend SchemaValidator
|
|
10
|
-
|
|
11
9
|
attr_reader :background_color, :text_style, :body,
|
|
12
|
-
:
|
|
10
|
+
:padding, :text, :width
|
|
13
11
|
|
|
14
12
|
def self.schema_path
|
|
15
13
|
ScreenKit.root_dir
|
|
16
|
-
.join("screenkit/schemas/
|
|
14
|
+
.join("screenkit/schemas/callout_styles/inline_block.json")
|
|
17
15
|
end
|
|
18
16
|
|
|
19
|
-
def initialize(source:, **kwargs)
|
|
17
|
+
def initialize(source:, **kwargs)
|
|
20
18
|
self.class.validate!(kwargs)
|
|
21
|
-
|
|
22
|
-
@source = source
|
|
19
|
+
super
|
|
23
20
|
|
|
24
21
|
# Set default values
|
|
25
|
-
|
|
22
|
+
self.options = hi_res({
|
|
26
23
|
text_style: {size: 50, color: "#ffffff"}.merge(text_style || {}),
|
|
27
24
|
width: 600,
|
|
28
25
|
padding: [10, 10, 10, 10],
|
|
29
26
|
background_color: "#000000"
|
|
30
27
|
}.merge(kwargs))
|
|
31
28
|
|
|
32
|
-
|
|
29
|
+
options.each do |key, value|
|
|
33
30
|
value = case key
|
|
34
31
|
when :padding
|
|
35
32
|
Spacing.new(value)
|
|
@@ -5,20 +5,18 @@ require "mini_magick"
|
|
|
5
5
|
module ScreenKit
|
|
6
6
|
class Callout
|
|
7
7
|
module Styles
|
|
8
|
-
class
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
attr_reader :background_color, :body, :body_style,
|
|
12
|
-
:output_path, :padding, :shadow,
|
|
13
|
-
:title, :title_style, :width, :source
|
|
8
|
+
class ShadowBlock < Base
|
|
9
|
+
attr_reader :background_color, :body, :body_style, :padding, :shadow,
|
|
10
|
+
:title, :title_style, :width
|
|
14
11
|
|
|
15
12
|
def self.schema_path
|
|
16
|
-
ScreenKit.root_dir
|
|
13
|
+
ScreenKit.root_dir
|
|
14
|
+
.join("screenkit/schemas/callout_styles/shadow_block.json")
|
|
17
15
|
end
|
|
18
16
|
|
|
19
|
-
def initialize(source:, **kwargs)
|
|
17
|
+
def initialize(source:, **kwargs)
|
|
20
18
|
self.class.validate!(kwargs)
|
|
21
|
-
|
|
19
|
+
super
|
|
22
20
|
|
|
23
21
|
# Set default values
|
|
24
22
|
kwargs[:shadow] = case kwargs[:shadow]
|
|
@@ -32,9 +30,9 @@ module ScreenKit
|
|
|
32
30
|
kwargs[:shadow]
|
|
33
31
|
end
|
|
34
32
|
|
|
35
|
-
|
|
33
|
+
self.options = hi_res({width: 600}.merge(kwargs))
|
|
36
34
|
|
|
37
|
-
|
|
35
|
+
options.each do |key, value|
|
|
38
36
|
value = case key
|
|
39
37
|
when :body_style, :title_style
|
|
40
38
|
TextStyle.new(source:, **value)
|
data/lib/screenkit/callout.rb
CHANGED
|
@@ -32,7 +32,7 @@ module ScreenKit
|
|
|
32
32
|
:style_props, :style_class, :animation, :source, :log_path
|
|
33
33
|
|
|
34
34
|
def self.schema_path
|
|
35
|
-
ScreenKit.root_dir.join("screenkit/schemas/refs/
|
|
35
|
+
ScreenKit.root_dir.join("screenkit/schemas/refs/callout_style.json")
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def initialize(
|
|
@@ -18,6 +18,12 @@ module ScreenKit
|
|
|
18
18
|
# The watermark configuration.
|
|
19
19
|
attr_reader :watermark
|
|
20
20
|
|
|
21
|
+
# The demotape configuration.
|
|
22
|
+
attr_reader :demotape
|
|
23
|
+
|
|
24
|
+
# The callout styles configuration.
|
|
25
|
+
attr_reader :callout_styles
|
|
26
|
+
|
|
21
27
|
def self.schema_path
|
|
22
28
|
@schema_path ||=
|
|
23
29
|
ScreenKit.root_dir.join("screenkit/schemas/episode.json")
|
|
@@ -12,8 +12,8 @@ module ScreenKit
|
|
|
12
12
|
# The output directory for exported files.
|
|
13
13
|
attr_reader :output_dir
|
|
14
14
|
|
|
15
|
-
# Callout
|
|
16
|
-
attr_reader :
|
|
15
|
+
# Callout styles
|
|
16
|
+
attr_reader :callout_styles
|
|
17
17
|
|
|
18
18
|
# Scene configurations
|
|
19
19
|
attr_reader :scenes
|
|
@@ -27,6 +27,9 @@ module ScreenKit
|
|
|
27
27
|
# The watermark configuration.
|
|
28
28
|
attr_reader :watermark
|
|
29
29
|
|
|
30
|
+
# The demotape configuration.
|
|
31
|
+
attr_reader :demotape
|
|
32
|
+
|
|
30
33
|
def self.schema_path
|
|
31
34
|
@schema_path ||=
|
|
32
35
|
ScreenKit.root_dir.join("screenkit/schemas/project.json")
|
|
@@ -5,6 +5,10 @@ module ScreenKit
|
|
|
5
5
|
class Demotape
|
|
6
6
|
include Shell
|
|
7
7
|
|
|
8
|
+
DURATION_ATTRIBUTES = %w[
|
|
9
|
+
typing_speed loop_delay run_enter_delay run_sleep
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
8
12
|
attr_reader :demotape_path, :log_path, :options
|
|
9
13
|
|
|
10
14
|
def initialize(demotape_path:, options: {}, log_path: nil)
|
|
@@ -28,6 +32,10 @@ module ScreenKit
|
|
|
28
32
|
|
|
29
33
|
def options_to_args(options)
|
|
30
34
|
(options || {}).flat_map do |key, value|
|
|
35
|
+
if DURATION_ATTRIBUTES.include?(key.to_s)
|
|
36
|
+
value = Duration.parse(value)
|
|
37
|
+
end
|
|
38
|
+
|
|
31
39
|
[key.to_s.tr("_", "-").prepend("--"), value]
|
|
32
40
|
end
|
|
33
41
|
end
|
|
@@ -37,12 +37,8 @@ module ScreenKit
|
|
|
37
37
|
@logfile = Logfile.new(output_dir.join("logs"))
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
def
|
|
41
|
-
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def tts_options
|
|
45
|
-
(config.tts || {}).merge(project_config.tts || {})
|
|
40
|
+
def tts_available?
|
|
41
|
+
tts_engines.any?(&:available?)
|
|
46
42
|
end
|
|
47
43
|
|
|
48
44
|
def demotape_options
|
|
@@ -50,9 +46,28 @@ module ScreenKit
|
|
|
50
46
|
end
|
|
51
47
|
|
|
52
48
|
def tts_engine
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
49
|
+
tts_engines.find(&:available?)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def tts_engines
|
|
53
|
+
@tts_engines ||= begin
|
|
54
|
+
project_tts = if project_config.tts.is_a?(Hash)
|
|
55
|
+
[project_config.tts]
|
|
56
|
+
else
|
|
57
|
+
Array(project_config.tts)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
episode_tts = if config.tts.is_a?(Hash)
|
|
61
|
+
[config.tts]
|
|
62
|
+
else
|
|
63
|
+
Array(config.tts)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
(episode_tts + project_tts).map do |opts|
|
|
67
|
+
TTS.const_get(opts[:engine].camelize)
|
|
68
|
+
.new(**opts.except(:engine), api_key: options.tts_api_key)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
56
71
|
end
|
|
57
72
|
|
|
58
73
|
# Logs a message to the shell with a specific category.
|
|
@@ -97,7 +112,7 @@ module ScreenKit
|
|
|
97
112
|
|
|
98
113
|
def export_callouts
|
|
99
114
|
callouts = filtered_segments.flat_map do |segment|
|
|
100
|
-
segment.callouts.map { {prefix: segment.prefix,
|
|
115
|
+
segment.callouts.map { {prefix: segment.prefix, callout: it} }
|
|
101
116
|
end
|
|
102
117
|
|
|
103
118
|
elapsed = ParallelProcessor.new(
|
|
@@ -106,15 +121,16 @@ module ScreenKit
|
|
|
106
121
|
message: "Exporting callouts (%{progress}/%{count})"
|
|
107
122
|
).run do |item, index|
|
|
108
123
|
log_path = logfile.create(item[:prefix], :callout, index)
|
|
109
|
-
type = item[:
|
|
110
|
-
|
|
111
|
-
|
|
124
|
+
type = item[:callout].fetch(:type).to_sym
|
|
125
|
+
|
|
126
|
+
callout_style = callout_styles
|
|
112
127
|
.fetch(type)
|
|
113
|
-
.merge(item[:
|
|
128
|
+
.merge(item[:callout])
|
|
114
129
|
callout_path = output_dir
|
|
115
130
|
.join("callouts", "#{item[:prefix]}-#{index}.png")
|
|
116
131
|
Callout.new(
|
|
117
|
-
source:,
|
|
132
|
+
source:,
|
|
133
|
+
**callout_style,
|
|
118
134
|
output_path: callout_path,
|
|
119
135
|
log_path:
|
|
120
136
|
).render
|
|
@@ -187,7 +203,7 @@ module ScreenKit
|
|
|
187
203
|
]
|
|
188
204
|
|
|
189
205
|
backtrack_adjustment = 1.0 / backtrack.volume
|
|
190
|
-
backtrack_fade_volume = if
|
|
206
|
+
backtrack_fade_volume = if tts_available?
|
|
191
207
|
0.15 * backtrack_adjustment
|
|
192
208
|
else
|
|
193
209
|
1.0
|
|
@@ -401,7 +417,7 @@ module ScreenKit
|
|
|
401
417
|
dir: shell.set_color(relative_path(root_dir), :blue)
|
|
402
418
|
)
|
|
403
419
|
|
|
404
|
-
unless
|
|
420
|
+
unless tts_available?
|
|
405
421
|
log(
|
|
406
422
|
:info,
|
|
407
423
|
shell.set_color("Voiceover is currently disabled", :red),
|
|
@@ -528,6 +544,10 @@ module ScreenKit
|
|
|
528
544
|
end
|
|
529
545
|
end
|
|
530
546
|
|
|
547
|
+
def callout_styles
|
|
548
|
+
(project_config.callout_styles || {}).merge(config.callout_styles || {})
|
|
549
|
+
end
|
|
550
|
+
|
|
531
551
|
def output_video_path
|
|
532
552
|
@output_video_path ||= output_dir.join("#{root_dir.basename}.mp4")
|
|
533
553
|
end
|
|
@@ -40,7 +40,7 @@ module ScreenKit
|
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def voiceover_path
|
|
43
|
-
return episode.mute_sound_path unless episode.
|
|
43
|
+
return episode.mute_sound_path unless episode.tts_available?
|
|
44
44
|
|
|
45
45
|
dir = episode.root_dir.join("voiceovers")
|
|
46
46
|
dir.glob("#{prefix}.{#{ContentType.audio.join(',')}}").first ||
|
|
@@ -117,79 +117,19 @@ module ScreenKit
|
|
|
117
117
|
|
|
118
118
|
audio_mix_inputs = ["[1:a]"]
|
|
119
119
|
animation_duration = 0.2
|
|
120
|
+
current_input_index = 2 # Start after video (0) and voiceover (1)
|
|
120
121
|
|
|
121
122
|
callouts.each_with_index do |callout, index|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
in_sound = Sound.new(input: callout_config[:in_transition][:sound],
|
|
125
|
-
source: episode.source)
|
|
126
|
-
out_sound = Sound.new(input: callout_config[:out_transition][:sound],
|
|
127
|
-
source: episode.source)
|
|
128
|
-
|
|
129
|
-
starts_at = callout[:starts_at]
|
|
130
|
-
max_duration = [content_duration - 0.2, 0].max
|
|
131
|
-
duration = Duration.parse(callout[:duration])
|
|
132
|
-
.clamp(0, max_duration)
|
|
133
|
-
.round(half: :down)
|
|
134
|
-
ends_at = starts_at + duration
|
|
135
|
-
callout_image_path = episode.output_dir.join("callouts",
|
|
136
|
-
"#{prefix}-#{index}.png")
|
|
137
|
-
image_width, image_height = image_size(callout_image_path)
|
|
138
|
-
|
|
139
|
-
x, y = calculate_position(
|
|
140
|
-
anchor: Anchor.new(callout_config[:anchor]),
|
|
141
|
-
margin: Spacing.new(callout_config[:margin] || 0),
|
|
142
|
-
width: image_width,
|
|
143
|
-
height: image_height
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
inputs += [
|
|
147
|
-
"-loop", "1",
|
|
148
|
-
"-t", duration,
|
|
149
|
-
"-i", callout_image_path,
|
|
150
|
-
|
|
151
|
-
# Add sound for transition in
|
|
152
|
-
"-i", in_sound.path,
|
|
153
|
-
|
|
154
|
-
# Add sound for transition out
|
|
155
|
-
"-i", out_sound.path
|
|
156
|
-
]
|
|
157
|
-
|
|
158
|
-
input_stream = "v#{index}"
|
|
159
|
-
output_stream = "v#{index + 1}"
|
|
160
|
-
callout_index = 2 + (index * 3)
|
|
161
|
-
|
|
162
|
-
animation_filters = AnimationFilters.new(
|
|
163
|
-
content_duration:,
|
|
164
|
-
callout_index:,
|
|
165
|
-
input_stream:,
|
|
166
|
-
output_stream:,
|
|
123
|
+
current_input_index = process_callout(
|
|
124
|
+
callout:,
|
|
167
125
|
index:,
|
|
168
|
-
|
|
169
|
-
ends_at:,
|
|
170
|
-
x:,
|
|
171
|
-
y:,
|
|
126
|
+
content_duration:,
|
|
172
127
|
animation_duration:,
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
# Delay and mix callout sounds
|
|
180
|
-
in_index = callout_index + 1
|
|
181
|
-
out_index = callout_index + 2
|
|
182
|
-
|
|
183
|
-
filters << "[#{in_index}:a]volume=#{in_sound.volume}," \
|
|
184
|
-
"adelay=#{(starts_at * 1000).to_i}|" \
|
|
185
|
-
"#{(starts_at * 1000).to_i}[in_#{index}]"
|
|
186
|
-
filters << "[#{out_index}:a]volume=#{out_sound.volume}," \
|
|
187
|
-
"adelay=#{(animation_filters[:out_start] * 1000).to_i}|" \
|
|
188
|
-
"#{(animation_filters[:out_start] * 1000).to_i}" \
|
|
189
|
-
"[out_#{index}]"
|
|
190
|
-
|
|
191
|
-
audio_mix_inputs << "[in_#{index}]"
|
|
192
|
-
audio_mix_inputs << "[out_#{index}]"
|
|
128
|
+
inputs:,
|
|
129
|
+
filters:,
|
|
130
|
+
audio_mix_inputs:,
|
|
131
|
+
current_input_index:
|
|
132
|
+
)
|
|
193
133
|
end
|
|
194
134
|
|
|
195
135
|
# Mix all audio streams (voiceover + all sounds)
|
|
@@ -240,7 +180,7 @@ module ScreenKit
|
|
|
240
180
|
def create_voiceover(log_path:)
|
|
241
181
|
return if voiceover_path&.file? && !episode.options.overwrite
|
|
242
182
|
return unless script_path.file?
|
|
243
|
-
return unless episode.
|
|
183
|
+
return unless episode.tts_available?
|
|
244
184
|
|
|
245
185
|
FileUtils.mkdir_p(voiceover_path.dirname)
|
|
246
186
|
|
|
@@ -258,6 +198,257 @@ module ScreenKit
|
|
|
258
198
|
|
|
259
199
|
format(path.to_s, prefix:) if path
|
|
260
200
|
end
|
|
201
|
+
|
|
202
|
+
def process_callout(
|
|
203
|
+
callout:,
|
|
204
|
+
index:,
|
|
205
|
+
content_duration:,
|
|
206
|
+
animation_duration:,
|
|
207
|
+
inputs:,
|
|
208
|
+
filters:,
|
|
209
|
+
audio_mix_inputs:,
|
|
210
|
+
current_input_index:
|
|
211
|
+
)
|
|
212
|
+
type = callout[:type].to_sym
|
|
213
|
+
callout_config = episode.callout_styles[type]
|
|
214
|
+
in_sound = Sound.new(input: callout_config[:in_transition][:sound],
|
|
215
|
+
source: episode.source)
|
|
216
|
+
out_sound = Sound.new(input: callout_config[:out_transition][:sound],
|
|
217
|
+
source: episode.source)
|
|
218
|
+
|
|
219
|
+
starts_at = TimeFormatter.parse(callout[:starts_at])
|
|
220
|
+
max_duration = [content_duration - 0.2, 0].max
|
|
221
|
+
duration = Duration.parse(callout[:duration])
|
|
222
|
+
.clamp(0, max_duration)
|
|
223
|
+
.round(half: :down)
|
|
224
|
+
ends_at = starts_at + duration
|
|
225
|
+
callout_duration = ends_at - starts_at
|
|
226
|
+
|
|
227
|
+
callout_path = find_callout_path(index)
|
|
228
|
+
video_callout = video_callout?(callout_path)
|
|
229
|
+
has_video_audio = has_audio?(callout_path)
|
|
230
|
+
|
|
231
|
+
callout_width, callout_height = image_size(callout_path)
|
|
232
|
+
x, y = calculate_callout_position(
|
|
233
|
+
video_callout:,
|
|
234
|
+
callout_config:,
|
|
235
|
+
callout_width:,
|
|
236
|
+
callout_height:
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
callout_index, current_input_index = add_callout_inputs(
|
|
240
|
+
inputs:,
|
|
241
|
+
current_input_index:,
|
|
242
|
+
callout_path:,
|
|
243
|
+
video_callout:,
|
|
244
|
+
has_video_audio:,
|
|
245
|
+
duration:
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
in_sound_index, out_sound_index, current_input_index =
|
|
249
|
+
add_transition_sound_inputs(
|
|
250
|
+
inputs:,
|
|
251
|
+
current_input_index:,
|
|
252
|
+
video_callout:,
|
|
253
|
+
in_sound:,
|
|
254
|
+
out_sound:
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
animation_filters = add_video_filters(
|
|
258
|
+
filters:,
|
|
259
|
+
index:,
|
|
260
|
+
callout_index:,
|
|
261
|
+
callout_config:,
|
|
262
|
+
video_callout:,
|
|
263
|
+
content_duration:,
|
|
264
|
+
starts_at:,
|
|
265
|
+
ends_at:,
|
|
266
|
+
x:,
|
|
267
|
+
y:,
|
|
268
|
+
animation_duration:,
|
|
269
|
+
callout_width:,
|
|
270
|
+
callout_height:
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
add_audio_filters(
|
|
274
|
+
filters:,
|
|
275
|
+
audio_mix_inputs:,
|
|
276
|
+
index:,
|
|
277
|
+
callout_index:,
|
|
278
|
+
video_callout:,
|
|
279
|
+
has_video_audio:,
|
|
280
|
+
starts_at:,
|
|
281
|
+
callout_duration:,
|
|
282
|
+
in_sound:,
|
|
283
|
+
out_sound:,
|
|
284
|
+
in_sound_index:,
|
|
285
|
+
out_sound_index:,
|
|
286
|
+
animation_filters:
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
current_input_index
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def find_callout_path(index)
|
|
293
|
+
callout_path = episode.output_dir.join("callouts").glob(
|
|
294
|
+
"#{prefix}-#{index}.{png,#{ContentType.video.join(',')}}"
|
|
295
|
+
).first
|
|
296
|
+
|
|
297
|
+
raise "Callout file not found for #{prefix}-#{index}" unless callout_path
|
|
298
|
+
|
|
299
|
+
callout_path
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def video_callout?(callout_path)
|
|
303
|
+
ContentType.video.include?(callout_path.extname.delete_prefix("."))
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def calculate_callout_position(
|
|
307
|
+
video_callout:,
|
|
308
|
+
callout_config:,
|
|
309
|
+
callout_width:,
|
|
310
|
+
callout_height:
|
|
311
|
+
)
|
|
312
|
+
# For video callouts, ignore anchor/margin and position at 0,0
|
|
313
|
+
# (assumes videos are already properly sized and positioned)
|
|
314
|
+
if video_callout
|
|
315
|
+
[0, 0]
|
|
316
|
+
else
|
|
317
|
+
calculate_position(
|
|
318
|
+
anchor: Anchor.new(callout_config[:anchor]),
|
|
319
|
+
margin: Spacing.new(callout_config[:margin] || 0),
|
|
320
|
+
width: callout_width,
|
|
321
|
+
height: callout_height
|
|
322
|
+
)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def add_callout_inputs(
|
|
327
|
+
inputs:,
|
|
328
|
+
current_input_index:,
|
|
329
|
+
callout_path:,
|
|
330
|
+
video_callout:,
|
|
331
|
+
has_video_audio:,
|
|
332
|
+
duration:
|
|
333
|
+
)
|
|
334
|
+
callout_index = current_input_index
|
|
335
|
+
|
|
336
|
+
if video_callout
|
|
337
|
+
# Don't use -t for videos, let them play naturally
|
|
338
|
+
inputs << "-i" << callout_path
|
|
339
|
+
current_input_index += 1
|
|
340
|
+
|
|
341
|
+
# Add mute audio if video has no audio
|
|
342
|
+
unless has_video_audio
|
|
343
|
+
inputs << "-t" << duration << "-i" << episode.mute_sound_path
|
|
344
|
+
current_input_index += 1
|
|
345
|
+
end
|
|
346
|
+
else
|
|
347
|
+
inputs << "-loop" << "1" << "-t" << duration << "-i" << callout_path
|
|
348
|
+
current_input_index += 1
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
[callout_index, current_input_index]
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def add_transition_sound_inputs(
|
|
355
|
+
inputs:,
|
|
356
|
+
current_input_index:,
|
|
357
|
+
video_callout:,
|
|
358
|
+
in_sound:,
|
|
359
|
+
out_sound:
|
|
360
|
+
)
|
|
361
|
+
# Add transition sounds (only for non-video callouts)
|
|
362
|
+
return [nil, nil, current_input_index] if video_callout
|
|
363
|
+
|
|
364
|
+
in_sound_index = current_input_index
|
|
365
|
+
inputs << "-i" << in_sound.path
|
|
366
|
+
current_input_index += 1
|
|
367
|
+
|
|
368
|
+
out_sound_index = current_input_index
|
|
369
|
+
inputs << "-i" << out_sound.path
|
|
370
|
+
current_input_index += 1
|
|
371
|
+
|
|
372
|
+
[in_sound_index, out_sound_index, current_input_index]
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def add_video_filters(
|
|
376
|
+
filters:,
|
|
377
|
+
index:,
|
|
378
|
+
callout_index:,
|
|
379
|
+
callout_config:,
|
|
380
|
+
video_callout:,
|
|
381
|
+
content_duration:,
|
|
382
|
+
starts_at:,
|
|
383
|
+
ends_at:,
|
|
384
|
+
x:,
|
|
385
|
+
y:,
|
|
386
|
+
animation_duration:,
|
|
387
|
+
callout_width:,
|
|
388
|
+
callout_height:
|
|
389
|
+
)
|
|
390
|
+
input_stream = "v#{index}"
|
|
391
|
+
output_stream = "v#{index + 1}"
|
|
392
|
+
animation_method = video_callout ? :video : callout_config[:animation]
|
|
393
|
+
|
|
394
|
+
animation_filters = AnimationFilters.new(
|
|
395
|
+
content_duration:,
|
|
396
|
+
callout_index:,
|
|
397
|
+
input_stream:,
|
|
398
|
+
output_stream:,
|
|
399
|
+
index:,
|
|
400
|
+
starts_at:,
|
|
401
|
+
ends_at:,
|
|
402
|
+
x:,
|
|
403
|
+
y:,
|
|
404
|
+
animation_duration:,
|
|
405
|
+
image_width: callout_width,
|
|
406
|
+
image_height: callout_height
|
|
407
|
+
).send(animation_method)
|
|
408
|
+
|
|
409
|
+
filters.concat(animation_filters[:video])
|
|
410
|
+
|
|
411
|
+
animation_filters
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def add_audio_filters(
|
|
415
|
+
filters:,
|
|
416
|
+
audio_mix_inputs:,
|
|
417
|
+
index:,
|
|
418
|
+
callout_index:,
|
|
419
|
+
video_callout:,
|
|
420
|
+
has_video_audio:,
|
|
421
|
+
starts_at:,
|
|
422
|
+
callout_duration:,
|
|
423
|
+
in_sound:,
|
|
424
|
+
out_sound:,
|
|
425
|
+
in_sound_index:,
|
|
426
|
+
out_sound_index:,
|
|
427
|
+
animation_filters:
|
|
428
|
+
)
|
|
429
|
+
# Mix video audio if present
|
|
430
|
+
if video_callout && has_video_audio
|
|
431
|
+
filters <<
|
|
432
|
+
"[#{callout_index}:a]atrim=end=#{callout_duration}," \
|
|
433
|
+
"asetpts=PTS-STARTPTS,adelay=#{(starts_at * 1000).to_i}|" \
|
|
434
|
+
"#{(starts_at * 1000).to_i}[video_audio_#{index}]"
|
|
435
|
+
audio_mix_inputs << "[video_audio_#{index}]"
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# For non-video callouts, add transition sounds
|
|
439
|
+
return if video_callout
|
|
440
|
+
|
|
441
|
+
filters << "[#{in_sound_index}:a]volume=#{in_sound.volume}," \
|
|
442
|
+
"adelay=#{(starts_at * 1000).to_i}|" \
|
|
443
|
+
"#{(starts_at * 1000).to_i}[in_#{index}]"
|
|
444
|
+
filters << "[#{out_sound_index}:a]volume=#{out_sound.volume}," \
|
|
445
|
+
"adelay=#{(animation_filters[:out_start] * 1000).to_i}|" \
|
|
446
|
+
"#{(animation_filters[:out_start] * 1000).to_i}" \
|
|
447
|
+
"[out_#{index}]"
|
|
448
|
+
|
|
449
|
+
audio_mix_inputs << "[in_#{index}]"
|
|
450
|
+
audio_mix_inputs << "[out_#{index}]"
|
|
451
|
+
end
|
|
261
452
|
end
|
|
262
453
|
end
|
|
263
454
|
end
|