screenkit 0.0.1 → 0.0.3

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.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/docker.yml +46 -0
  3. data/.github/workflows/ruby-tests.yml +21 -1
  4. data/Brewfile +5 -0
  5. data/CHANGELOG.md +11 -0
  6. data/CONTRIBUTING.md +25 -0
  7. data/DOCUMENTATION.md +136 -41
  8. data/Dockerfile +107 -0
  9. data/lib/screen_kit.rb +15 -16
  10. data/lib/screenkit/animation_filters.rb +16 -0
  11. data/lib/screenkit/callout/styles/base.rb +12 -0
  12. data/lib/screenkit/callout/styles/file_copy.rb +26 -0
  13. data/lib/screenkit/callout/styles/inline_block.rb +6 -9
  14. data/lib/screenkit/callout/styles/{default.rb → shadow_block.rb} +9 -11
  15. data/lib/screenkit/callout.rb +1 -1
  16. data/lib/screenkit/cli/episode.rb +2 -3
  17. data/lib/screenkit/cli/root.rb +1 -1
  18. data/lib/screenkit/config/episode.rb +6 -0
  19. data/lib/screenkit/config/project.rb +5 -2
  20. data/lib/screenkit/exporter/demotape.rb +8 -0
  21. data/lib/screenkit/exporter/episode.rb +37 -17
  22. data/lib/screenkit/exporter/segment.rb +262 -71
  23. data/lib/screenkit/generators/episode/callouts/001.yml +12 -0
  24. data/lib/screenkit/generators/episode/config.yml.erb +8 -8
  25. data/lib/screenkit/generators/project/screenkit.yml +46 -15
  26. data/lib/screenkit/schema_validator.rb +1 -1
  27. data/lib/screenkit/schemas/callout_styles/file_copy.json +8 -0
  28. data/lib/screenkit/schemas/callout_styles/inline_block.json +20 -0
  29. data/lib/screenkit/schemas/{callouts/default.json → callout_styles/shadow_block.json} +2 -2
  30. data/lib/screenkit/schemas/callouts/inline_block.json +20 -11
  31. data/lib/screenkit/schemas/callouts/shadow_block.json +39 -0
  32. data/lib/screenkit/schemas/episode.json +6 -43
  33. data/lib/screenkit/schemas/project.json +4 -5
  34. data/lib/screenkit/schemas/refs/anchor.json +1 -1
  35. data/lib/screenkit/schemas/refs/animation.json +1 -1
  36. data/lib/screenkit/schemas/refs/background.json +1 -1
  37. data/lib/screenkit/schemas/refs/{callout.json → callout_style.json} +6 -8
  38. data/lib/screenkit/schemas/refs/color.json +1 -1
  39. data/lib/screenkit/schemas/refs/demotape.json +1 -1
  40. data/lib/screenkit/schemas/refs/demotape_themes.json +1 -1
  41. data/lib/screenkit/schemas/refs/directory.json +1 -1
  42. data/lib/screenkit/schemas/refs/duration.json +1 -1
  43. data/lib/screenkit/schemas/refs/intro.json +1 -1
  44. data/lib/screenkit/schemas/refs/logo.json +1 -1
  45. data/lib/screenkit/schemas/refs/outro.json +1 -1
  46. data/lib/screenkit/schemas/refs/position.json +1 -1
  47. data/lib/screenkit/schemas/refs/scenes.json +1 -1
  48. data/lib/screenkit/schemas/refs/size.json +1 -1
  49. data/lib/screenkit/schemas/refs/sound.json +1 -1
  50. data/lib/screenkit/schemas/refs/spacing.json +1 -1
  51. data/lib/screenkit/schemas/refs/text_style.json +1 -1
  52. data/lib/screenkit/schemas/refs/transition.json +1 -1
  53. data/lib/screenkit/schemas/refs/tts.json +4 -41
  54. data/lib/screenkit/schemas/refs/tts_builtin.json +23 -0
  55. data/lib/screenkit/schemas/refs/watermark.json +1 -1
  56. data/lib/screenkit/schemas/tts/elevenlabs.json +11 -2
  57. data/lib/screenkit/schemas/tts/espeak.json +26 -0
  58. data/lib/screenkit/schemas/tts/say.json +11 -1
  59. data/lib/screenkit/shell.rb +6 -0
  60. data/lib/screenkit/time_formatter.rb +14 -0
  61. data/lib/screenkit/tts/base.rb +21 -0
  62. data/lib/screenkit/tts/eleven_labs.rb +8 -9
  63. data/lib/screenkit/tts/espeak.rb +30 -0
  64. data/lib/screenkit/tts/say.rb +5 -6
  65. data/lib/screenkit/utils.rb +6 -0
  66. data/lib/screenkit/version.rb +1 -1
  67. metadata +21 -42
  68. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans-Bold.ttf +0 -0
  69. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans-BoldItalic.ttf +0 -0
  70. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans-ExtraBoldItalic.ttf +0 -0
  71. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans-Italic.ttf +0 -0
  72. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans-Light.ttf +0 -0
  73. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans-LightItalic.ttf +0 -0
  74. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans-Medium.ttf +0 -0
  75. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans-MediumItalic.ttf +0 -0
  76. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans-Regular.ttf +0 -0
  77. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans-SemiBoldItalic.ttf +0 -0
  78. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-Bold.ttf +0 -0
  79. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-BoldItalic.ttf +0 -0
  80. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-ExtraBold.ttf +0 -0
  81. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-ExtraBoldItalic.ttf +0 -0
  82. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-Italic.ttf +0 -0
  83. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-Light.ttf +0 -0
  84. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-LightItalic.ttf +0 -0
  85. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-Medium.ttf +0 -0
  86. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-MediumItalic.ttf +0 -0
  87. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-Regular.ttf +0 -0
  88. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-SemiBold.ttf +0 -0
  89. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_Condensed-SemiBoldItalic.ttf +0 -0
  90. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-Bold.ttf +0 -0
  91. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-BoldItalic.ttf +0 -0
  92. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-ExtraBold.ttf +0 -0
  93. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-ExtraBoldItalic.ttf +0 -0
  94. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-Italic.ttf +0 -0
  95. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-Light.ttf +0 -0
  96. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-LightItalic.ttf +0 -0
  97. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-Medium.ttf +0 -0
  98. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-MediumItalic.ttf +0 -0
  99. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-Regular.ttf +0 -0
  100. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-SemiBold.ttf +0 -0
  101. data/lib/screenkit/generators/project/resources/fonts/open-sans/OpenSans_SemiCondensed-SemiBoldItalic.ttf +0 -0
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenKit
4
+ class Callout
5
+ module Styles
6
+ class FileCopy < Base
7
+ def self.schema_path
8
+ ScreenKit.root_dir
9
+ .join("screenkit/schemas/callout_styles/file_copy.json")
10
+ end
11
+
12
+ def initialize(source:, **kwargs)
13
+ self.class.validate!(kwargs)
14
+ super
15
+ end
16
+
17
+ def render
18
+ ext = File.extname(options[:file_path])
19
+ FileUtils.mkdir_p(File.dirname(output_path))
20
+ FileUtils.cp source.search(options[:file_path]),
21
+ output_path.sub_ext(ext)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -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
- :output_path, :padding, :text, :width, :source
10
+ :padding, :text, :width
13
11
 
14
12
  def self.schema_path
15
13
  ScreenKit.root_dir
16
- .join("screenkit/schemas/callouts/inline_block.json")
14
+ .join("screenkit/schemas/callout_styles/inline_block.json")
17
15
  end
18
16
 
19
- def initialize(source:, **kwargs) # rubocop:disable Lint/MissingSuper
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
- kwargs = hi_res({
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
- kwargs.each do |key, value|
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 Default < Base
9
- extend SchemaValidator
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.join("screenkit/schemas/callouts/default.json")
13
+ ScreenKit.root_dir
14
+ .join("screenkit/schemas/callout_styles/shadow_block.json")
17
15
  end
18
16
 
19
- def initialize(source:, **kwargs) # rubocop:disable Lint/MissingSuper
17
+ def initialize(source:, **kwargs)
20
18
  self.class.validate!(kwargs)
21
- @source = source
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
- kwargs = hi_res({width: 600}.merge(kwargs))
33
+ self.options = hi_res({width: 600}.merge(kwargs))
36
34
 
37
- kwargs.each do |key, value|
35
+ options.each do |key, value|
38
36
  value = case key
39
37
  when :body_style, :title_style
40
38
  TextStyle.new(source:, **value)
@@ -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/callout.json")
35
+ ScreenKit.root_dir.join("screenkit/schemas/refs/callout_style.json")
36
36
  end
37
37
 
38
38
  def initialize(
@@ -24,8 +24,7 @@ module ScreenKit
24
24
  options[:episode_dir] = config.episode_dir.parent.join(dir)
25
25
 
26
26
  generator = Generators::Episode.new
27
- generator.destination_root =
28
- File.expand_path(File.dirname(options.config))
27
+ generator.destination_root = File.dirname(options.config)
29
28
  generator.options = options
30
29
  generator.invoke_all
31
30
  end
@@ -35,7 +34,7 @@ module ScreenKit
35
34
  type: :string,
36
35
  required: true,
37
36
  desc: "Directory of the episode to export"
38
- option :voice_api_key,
37
+ option :tts_api_key,
39
38
  type: :string,
40
39
  desc: "API key for the voice synthesis service"
41
40
  option :overwrite,
@@ -9,7 +9,7 @@ module ScreenKit
9
9
  desc "new PATH", "Create a new project"
10
10
  def new(path)
11
11
  generator = Generators::Project.new
12
- generator.destination_root = File.expand_path(path)
12
+ generator.destination_root = path
13
13
  generator.options = options
14
14
  generator.invoke_all
15
15
  end
@@ -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 configurations
16
- attr_reader :callouts
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 tts?
41
- config.tts || project_config.tts
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
- @tts_engine ||= TTS.const_get(
54
- tts_options[:engine].camelize
55
- ).new(**tts_options.except(:engine))
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, callouts: it} }
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[:callouts].fetch(:type).to_sym
110
- callout_style = project_config
111
- .callouts
124
+ type = item[:callout].fetch(:type).to_sym
125
+
126
+ callout_style = callout_styles
112
127
  .fetch(type)
113
- .merge(item[:callouts].except(:starts_at, :duration))
128
+ .merge(item[:callout])
114
129
  callout_path = output_dir
115
130
  .join("callouts", "#{item[:prefix]}-#{index}.png")
116
131
  Callout.new(
117
- source:, **callout_style,
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 tts?
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 tts?
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.tts?
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
- type = callout[:type].to_sym
123
- callout_config = episode.project_config.callouts[type]
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
- starts_at:,
169
- ends_at:,
170
- x:,
171
- y:,
126
+ content_duration:,
172
127
  animation_duration:,
173
- image_width:,
174
- image_height:
175
- ).send(callout_config[:animation])
176
-
177
- filters.concat(animation_filters[:video])
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.tts?
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
@@ -0,0 +1,12 @@
1
+ ---
2
+ - type: default
3
+ title: ScreenKit
4
+ body: https://github.com/fnando/screenkit
5
+ duration: 5s
6
+ starts_at: 00:00:02
7
+ width: 800
8
+
9
+ - type: inline_block
10
+ text: gem install screenkit
11
+ duration: 5s
12
+ starts_at: 00:00:02