screenkit 0.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.
Files changed (142) hide show
  1. checksums.yaml +7 -0
  2. data/.github/CODEOWNERS +4 -0
  3. data/.github/FUNDING.yml +4 -0
  4. data/.github/ISSUE_TEMPLATE/bug_report.md +41 -0
  5. data/.github/ISSUE_TEMPLATE/config.yml +5 -0
  6. data/.github/ISSUE_TEMPLATE/feature_request.md +23 -0
  7. data/.github/PULL_REQUEST_TEMPLATE.md +38 -0
  8. data/.github/dependabot.yml +15 -0
  9. data/.github/workflows/ruby-tests.yml +51 -0
  10. data/.gitignore +12 -0
  11. data/.rubocop.yml +14 -0
  12. data/CHANGELOG.md +16 -0
  13. data/CODE_OF_CONDUCT.md +74 -0
  14. data/CONTRIBUTING.md +80 -0
  15. data/DOCUMENTATION.md +972 -0
  16. data/Gemfile +5 -0
  17. data/LICENSE.md +20 -0
  18. data/README.md +49 -0
  19. data/Rakefile +15 -0
  20. data/bin/console +16 -0
  21. data/bin/setup +10 -0
  22. data/exe/screenkit +5 -0
  23. data/lib/screen_kit.rb +79 -0
  24. data/lib/screenkit/anchor.rb +19 -0
  25. data/lib/screenkit/animation_filters.rb +114 -0
  26. data/lib/screenkit/banner.rb +46 -0
  27. data/lib/screenkit/callout/styles/base.rb +101 -0
  28. data/lib/screenkit/callout/styles/default.rb +144 -0
  29. data/lib/screenkit/callout/styles/inline_block.rb +123 -0
  30. data/lib/screenkit/callout/text_style.rb +44 -0
  31. data/lib/screenkit/callout.rb +98 -0
  32. data/lib/screenkit/cli/base.rb +24 -0
  33. data/lib/screenkit/cli/episode.rb +78 -0
  34. data/lib/screenkit/cli/root.rb +73 -0
  35. data/lib/screenkit/cli.rb +9 -0
  36. data/lib/screenkit/config/base.rb +51 -0
  37. data/lib/screenkit/config/episode.rb +42 -0
  38. data/lib/screenkit/config/project.rb +47 -0
  39. data/lib/screenkit/content_type.rb +12 -0
  40. data/lib/screenkit/core_ext/json.rb +47 -0
  41. data/lib/screenkit/core_ext/string.rb +28 -0
  42. data/lib/screenkit/exporter/demotape.rb +26 -0
  43. data/lib/screenkit/exporter/episode.rb +565 -0
  44. data/lib/screenkit/exporter/image.rb +31 -0
  45. data/lib/screenkit/exporter/intro.rb +261 -0
  46. data/lib/screenkit/exporter/outro.rb +183 -0
  47. data/lib/screenkit/exporter/segment.rb +258 -0
  48. data/lib/screenkit/exporter/video.rb +33 -0
  49. data/lib/screenkit/generators/episode/config.yml.erb +106 -0
  50. data/lib/screenkit/generators/episode/content/001.tape +4 -0
  51. data/lib/screenkit/generators/episode/scripts/001.txt +1 -0
  52. data/lib/screenkit/generators/episode.rb +31 -0
  53. data/lib/screenkit/generators/project/Gemfile.erb +7 -0
  54. data/lib/screenkit/generators/project/resources/backtracks/default.aac +0 -0
  55. data/lib/screenkit/generators/project/resources/fonts/opensans/OFL.txt +93 -0
  56. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-Bold.ttf +0 -0
  57. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-BoldItalic.ttf +0 -0
  58. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-ExtraBold.ttf +0 -0
  59. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-ExtraBoldItalic.ttf +0 -0
  60. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-Italic.ttf +0 -0
  61. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-Light.ttf +0 -0
  62. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-LightItalic.ttf +0 -0
  63. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-Medium.ttf +0 -0
  64. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-MediumItalic.ttf +0 -0
  65. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-Regular.ttf +0 -0
  66. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-SemiBold.ttf +0 -0
  67. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-SemiBoldItalic.ttf +0 -0
  68. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-Bold.ttf +0 -0
  69. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-BoldItalic.ttf +0 -0
  70. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-ExtraBold.ttf +0 -0
  71. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-ExtraBoldItalic.ttf +0 -0
  72. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-Italic.ttf +0 -0
  73. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-Light.ttf +0 -0
  74. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-LightItalic.ttf +0 -0
  75. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-Medium.ttf +0 -0
  76. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-MediumItalic.ttf +0 -0
  77. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-Regular.ttf +0 -0
  78. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-SemiBold.ttf +0 -0
  79. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-SemiBoldItalic.ttf +0 -0
  80. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-Bold.ttf +0 -0
  81. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-BoldItalic.ttf +0 -0
  82. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-ExtraBold.ttf +0 -0
  83. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-ExtraBoldItalic.ttf +0 -0
  84. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-Italic.ttf +0 -0
  85. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-Light.ttf +0 -0
  86. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-LightItalic.ttf +0 -0
  87. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-Medium.ttf +0 -0
  88. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-MediumItalic.ttf +0 -0
  89. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-Regular.ttf +0 -0
  90. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-SemiBold.ttf +0 -0
  91. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-SemiBoldItalic.ttf +0 -0
  92. data/lib/screenkit/generators/project/resources/fonts/opensans/README.txt +100 -0
  93. data/lib/screenkit/generators/project/resources/images/logo.png +0 -0
  94. data/lib/screenkit/generators/project/resources/images/watermark.png +0 -0
  95. data/lib/screenkit/generators/project/resources/sounds/chime.mp3 +0 -0
  96. data/lib/screenkit/generators/project/resources/sounds/pop.mp3 +0 -0
  97. data/lib/screenkit/generators/project/resources/sounds/whoosh.mp3 +0 -0
  98. data/lib/screenkit/generators/project/screenkit.yml +189 -0
  99. data/lib/screenkit/generators/project.rb +34 -0
  100. data/lib/screenkit/logfile.rb +33 -0
  101. data/lib/screenkit/parallel_processor.rb +50 -0
  102. data/lib/screenkit/path_lookup.rb +27 -0
  103. data/lib/screenkit/resources/mute.mp3 +0 -0
  104. data/lib/screenkit/resources/transparent.png +0 -0
  105. data/lib/screenkit/schema_validator.rb +14 -0
  106. data/lib/screenkit/schemas/callouts/default.json +44 -0
  107. data/lib/screenkit/schemas/callouts/inline_block.json +20 -0
  108. data/lib/screenkit/schemas/episode.json +74 -0
  109. data/lib/screenkit/schemas/project.json +37 -0
  110. data/lib/screenkit/schemas/refs/anchor.json +17 -0
  111. data/lib/screenkit/schemas/refs/animation.json +7 -0
  112. data/lib/screenkit/schemas/refs/background.json +14 -0
  113. data/lib/screenkit/schemas/refs/callout.json +27 -0
  114. data/lib/screenkit/schemas/refs/color.json +7 -0
  115. data/lib/screenkit/schemas/refs/directory.json +20 -0
  116. data/lib/screenkit/schemas/refs/intro.json +30 -0
  117. data/lib/screenkit/schemas/refs/logo.json +26 -0
  118. data/lib/screenkit/schemas/refs/outro.json +26 -0
  119. data/lib/screenkit/schemas/refs/position.json +38 -0
  120. data/lib/screenkit/schemas/refs/scenes.json +27 -0
  121. data/lib/screenkit/schemas/refs/size.json +19 -0
  122. data/lib/screenkit/schemas/refs/sound.json +36 -0
  123. data/lib/screenkit/schemas/refs/spacing.json +22 -0
  124. data/lib/screenkit/schemas/refs/text_style.json +18 -0
  125. data/lib/screenkit/schemas/refs/transition.json +15 -0
  126. data/lib/screenkit/schemas/refs/tts.json +47 -0
  127. data/lib/screenkit/schemas/refs/watermark.json +18 -0
  128. data/lib/screenkit/schemas/tts/elevenlabs.json +67 -0
  129. data/lib/screenkit/schemas/tts/say.json +16 -0
  130. data/lib/screenkit/shell.rb +58 -0
  131. data/lib/screenkit/sound.rb +44 -0
  132. data/lib/screenkit/spacing.rb +23 -0
  133. data/lib/screenkit/spinner.rb +39 -0
  134. data/lib/screenkit/transition.rb +16 -0
  135. data/lib/screenkit/tts/eleven_labs.rb +51 -0
  136. data/lib/screenkit/tts/say.rb +31 -0
  137. data/lib/screenkit/utils.rb +87 -0
  138. data/lib/screenkit/version.rb +5 -0
  139. data/lib/screenkit/watermark.rb +34 -0
  140. data/lib/screenkit.rb +3 -0
  141. data/screenkit.gemspec +56 -0
  142. metadata +426 -0
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenKit
4
+ module Exporter
5
+ class Intro
6
+ include Shell
7
+ include Utils
8
+ extend SchemaValidator
9
+
10
+ def self.schema_path
11
+ ScreenKit.root_dir.join("screenkit/schemas/refs/intro.json")
12
+ end
13
+
14
+ # The intro scene configuration.
15
+ attr_reader :config
16
+
17
+ # The title text.
18
+ attr_reader :text
19
+
20
+ # The source path lookup instance.
21
+ attr_reader :source
22
+
23
+ # The log path.
24
+ attr_reader :log_path
25
+
26
+ def initialize(config:, text:, source:, log_path: nil)
27
+ self.class.validate!(config)
28
+ @config = config
29
+ @text = text
30
+ @source = source
31
+ @log_path = log_path
32
+ end
33
+
34
+ def logo_config
35
+ @logo_config ||= config[:logo]
36
+ end
37
+
38
+ def title_config
39
+ @title_config ||= config[:title]
40
+ end
41
+
42
+ def sound_config
43
+ return unless config[:sound]
44
+
45
+ @sound_config ||= case config[:sound]
46
+ when String
47
+ {path: config[:sound], volume: 1.0}
48
+ else
49
+ config[:sound]
50
+ end
51
+ end
52
+
53
+ def logo_path
54
+ return unless logo_config
55
+
56
+ @logo_path ||= source.search(logo_config.fetch(:path))
57
+ end
58
+
59
+ def sound_path
60
+ return unless sound_config
61
+
62
+ @sound_path ||= source.search(sound_config.fetch(:path))
63
+ end
64
+
65
+ def background_path
66
+ return unless config[:background]
67
+ return if config[:background].start_with?("#")
68
+
69
+ @background_path ||= source.search(config[:background])
70
+ end
71
+
72
+ def font_path
73
+ return unless title_config&.[](:font_path)
74
+
75
+ @font_path ||= source.search(title_config[:font_path])
76
+ end
77
+
78
+ def export(path)
79
+ ffmpeg_params => {inputs:, filters:, maps:}
80
+
81
+ cmd = [
82
+ "ffmpeg",
83
+ *inputs,
84
+ "-filter_complex", filters,
85
+ *maps,
86
+ "-c:v", "libx264", "-crf", "0", "-pix_fmt", "yuv444p",
87
+ "-c:a", "flac", "-ac", "1", "-ar", "44100",
88
+ "-shortest",
89
+ "-t", config[:duration],
90
+ "-r", 24,
91
+ "-y",
92
+ path
93
+ ]
94
+
95
+ run_command(*cmd, log_path:)
96
+ end
97
+
98
+ private def ffmpeg_params
99
+ duration = config[:duration]
100
+ fade_in = config.fetch(:fade_in, 0.0)
101
+ fade_out = config.fetch(:fade_out, 0.5)
102
+ fade_out_start = duration - fade_out - 0.1
103
+
104
+ # Build filter chain
105
+ inputs = []
106
+ filters = []
107
+ stream_index = 0
108
+
109
+ # Background layer
110
+ if background_path&.file?
111
+ if video_file?(background_path)
112
+ # Ensure video is 24fps
113
+ extname = background_path.extname
114
+ optimized_path = background_path.sub_ext("_24fps#{extname}")
115
+
116
+ if (-0.02..0.02).cover?(fps(background_path))
117
+ optimized_path = background_path
118
+ end
119
+
120
+ unless optimized_path.file?
121
+ Video.new(input_path: background_path).export(optimized_path)
122
+ end
123
+
124
+ # Video background
125
+ video_duration = duration(optimized_path)
126
+
127
+ # Calculate how many loops we need
128
+ loops_needed = (duration / video_duration).ceil
129
+
130
+ inputs += [
131
+ "-stream_loop", (loops_needed - 1).to_s,
132
+ "-i", optimized_path
133
+ ]
134
+
135
+ # Scale, crop, then trim to exact duration needed
136
+ filters << "[#{stream_index}:v]scale=1920:1080:" \
137
+ "force_original_aspect_ratio=increase:flags=lanczos," \
138
+ "crop=1920:1080," \
139
+ "trim=end=#{duration}," \
140
+ "setpts=PTS-STARTPTS[bg]"
141
+ else
142
+ inputs += ["-loop", "1", "-t", duration, "-i", background_path]
143
+ filters << "[#{stream_index}:v]scale=1920:1080:" \
144
+ "force_original_aspect_ratio=increase:flags=lanczos," \
145
+ "crop=1920:1080,setpts=PTS-STARTPTS[bg]"
146
+ end
147
+ else
148
+ background = config.fetch(:background, "black")
149
+ inputs += [
150
+ "-f", "lavfi", "-i",
151
+ "color=c=#{background}:s=1920x1080:d=#{duration}"
152
+ ]
153
+ filters << "[#{stream_index}:v]setpts=PTS-STARTPTS[bg]"
154
+ end
155
+ stream_index += 1
156
+
157
+ current_layer = "bg"
158
+
159
+ # Logo layer (if present)
160
+ if logo_path
161
+ logo_width = logo_config.fetch(:width, 350)
162
+ logo_x = logo_config.fetch(:x, "center")
163
+ logo_y = logo_config.fetch(:y, "center")
164
+ overlay_x = logo_x == "center" ? "(W-w)/2" : logo_x
165
+ overlay_y = logo_y == "center" ? "(H-h)/2" : logo_y
166
+
167
+ inputs += ["-loop", "1", "-i", logo_path]
168
+ filters << "[#{stream_index}:v]scale=#{logo_width}:" \
169
+ "-1:flags=lanczos[logo]"
170
+ filters << "[#{current_layer}][logo]overlay=#{overlay_x}:" \
171
+ "#{overlay_y}[with_logo]"
172
+ current_layer = "with_logo"
173
+ stream_index += 1
174
+ end
175
+
176
+ # Title layer (if present)
177
+ if title_config
178
+ title_x = title_config.fetch(:x, "center")
179
+ title_y = title_config.fetch(:y, "center")
180
+ title_size = title_config.fetch(:size, 72)
181
+ title_color = title_config.fetch(:color, "white")
182
+
183
+ # Calculate max width based on x offset
184
+ max_width = if title_x == "center"
185
+ 1720 # 1920 - (2 * 100)
186
+ else
187
+ 1920 - (2 * title_x.to_i)
188
+ end
189
+
190
+ # Rough estimate characters per line based on font size
191
+ avg_char_width = title_size * 0.7
192
+ max_chars_per_line = (max_width / avg_char_width).floor
193
+
194
+ # Auto-wrap text
195
+ wrapped_text = wrap_text(text, max_chars_per_line)
196
+
197
+ # Convert position to drawtext coordinates
198
+ drawtext_x = title_x == "center" ? "(w-text_w)/2" : title_x
199
+ drawtext_y = title_y == "center" ? "(h-text_h)/2" : title_y
200
+
201
+ # Center align text when x is centered
202
+ text_align = title_x == "center" ? ":text_align=center" : ""
203
+
204
+ # Escape special characters in text
205
+ wrapped_text = wrapped_text.gsub("'", "'\\\\\\''").gsub(":", "\\:")
206
+
207
+ filters << "[#{current_layer}]drawtext=text='#{wrapped_text}':" \
208
+ "fontfile=#{font_path}:fontsize=#{title_size}:" \
209
+ "fontcolor=#{title_color}:x=#{drawtext_x}:" \
210
+ "y=#{drawtext_y}#{text_align}[with_title]"
211
+ current_layer = "with_title"
212
+ end
213
+
214
+ # Apply fades to final video layer
215
+ # Use black for fade color when background is an image file
216
+ fade_color = background_path&.file? ? "black" : background
217
+ filters << "[#{current_layer}]fade=t=in:st=0:d=#{fade_in}:" \
218
+ "c=#{fade_color},fade=t=out:st=#{fade_out_start}:" \
219
+ "d=#{fade_out}:c=#{fade_color},setpts=PTS-STARTPTS[fade]"
220
+
221
+ # Audio (always generate, silent if no sound configured)
222
+ if sound_path
223
+ inputs += ["-i", sound_path]
224
+ sound_volume = sound_config.fetch(:volume, 1.0)
225
+ filters << "[#{stream_index}:a]apad,atrim=end=#{duration}," \
226
+ "aresample=async=1,volume=#{sound_volume}[a]"
227
+ else
228
+ # Generate silent audio track
229
+ filters << "anullsrc=r=44100:cl=mono,atrim=end=#{duration}[a]"
230
+ end
231
+
232
+ maps = ["-map", "[fade]", "-map", "[a]"]
233
+
234
+ {inputs:, filters: filters.join(";"), maps:}
235
+ end
236
+
237
+ def wrap_text(text, max_chars_per_line)
238
+ return text if text.lines.size > 1
239
+
240
+ words = text.strip.split(/\s+/)
241
+ breaks = []
242
+ current_line = []
243
+
244
+ words.each do |word|
245
+ line_size_candidate = current_line.join(" ").length + word.length + 1
246
+
247
+ if line_size_candidate <= max_chars_per_line
248
+ current_line << word
249
+ else
250
+ breaks << current_line.join(" ")
251
+ current_line = [word]
252
+ end
253
+ end
254
+
255
+ breaks << current_line.join(" ") unless current_line.empty?
256
+
257
+ breaks.join("\n")
258
+ end
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenKit
4
+ module Exporter
5
+ class Outro
6
+ include Shell
7
+ include Utils
8
+ extend SchemaValidator
9
+
10
+ def self.schema_path
11
+ ScreenKit.root_dir.join("screenkit/schemas/refs/outro.json")
12
+ end
13
+
14
+ # The outro scene configuration.
15
+ attr_reader :config
16
+
17
+ # The source path lookup instance.
18
+ attr_reader :source
19
+
20
+ # The log path.
21
+ attr_reader :log_path
22
+
23
+ def initialize(config:, source:, log_path: nil)
24
+ self.class.validate!(config)
25
+ @config = config
26
+ @source = source
27
+ @log_path = log_path
28
+ end
29
+
30
+ def logo_config
31
+ @logo_config ||= config.fetch(:logo)
32
+ end
33
+
34
+ def sound_config
35
+ return unless config[:sound]
36
+
37
+ @sound_config ||= case config[:sound]
38
+ when String
39
+ {path: config[:sound], volume: 1.0}
40
+ else
41
+ config.fetch(:sound)
42
+ end
43
+ end
44
+
45
+ def logo_path
46
+ @logo_path ||= source.search(logo_config.fetch(:path))
47
+ end
48
+
49
+ def sound_path
50
+ return unless sound_config
51
+
52
+ @sound_path ||= source.search(sound_config.fetch(:path))
53
+ end
54
+
55
+ def background_path
56
+ return unless config[:background]
57
+ return if config[:background].to_s.start_with?("#")
58
+
59
+ @background_path ||= source.search(config[:background])
60
+ end
61
+
62
+ def export(path)
63
+ ffmpeg_params => {inputs:, filters:, maps:}
64
+
65
+ cmd = [
66
+ "ffmpeg",
67
+ *inputs,
68
+ "-sws_flags", "lanczos+accurate_rnd+full_chroma_int",
69
+ "-filter_complex", filters,
70
+ *maps,
71
+ "-c:v", "libx264", "-crf", "0", "-pix_fmt", "yuv444p",
72
+ "-c:a", "flac", "-ac", "1", "-ar", "44100",
73
+ "-shortest",
74
+ "-t", config[:duration],
75
+ "-r", 24,
76
+ "-y",
77
+ path
78
+ ]
79
+
80
+ run_command(*cmd, log_path:)
81
+ end
82
+
83
+ private def ffmpeg_params
84
+ duration = config[:duration]
85
+ logo_delay = 0.5
86
+ fade_in = config.fetch(:fade_in, 0.5)
87
+ fade_out = config.fetch(:fade_out, 0.5)
88
+ fade_out_start = duration - fade_out - 0.1
89
+
90
+ # Build filter chain
91
+ inputs = []
92
+ filters = []
93
+ stream_index = 0
94
+
95
+ # Background layer
96
+ if background_path&.file?
97
+ if video_file?(background_path)
98
+ # Video background
99
+ video_duration = duration(background_path)
100
+
101
+ # Calculate how many loops we need
102
+ loops_needed = (duration / video_duration).ceil
103
+
104
+ inputs += [
105
+ "-stream_loop", (loops_needed - 1).to_s, "-i",
106
+ background_path
107
+ ]
108
+
109
+ # Scale, crop, then trim to exact duration needed
110
+ filters << "[#{stream_index}:v]scale=1920:1080:" \
111
+ "force_original_aspect_ratio=increase:flags=lanczos," \
112
+ "crop=1920:1080," \
113
+ "trim=end=#{duration}," \
114
+ "setpts=PTS-STARTPTS[bg]"
115
+ else
116
+ # Image background
117
+ inputs += ["-loop", "1", "-t", duration, "-i", background_path]
118
+ filters << "[#{stream_index}:v]scale=1920:1080:" \
119
+ "force_original_aspect_ratio=increase:flags=lanczos," \
120
+ "crop=1920:1080,setpts=PTS-STARTPTS[bg]"
121
+ end
122
+ else
123
+ # Color background
124
+ background = config.fetch(:background, "black")
125
+ inputs += [
126
+ "-f", "lavfi", "-i",
127
+ "color=c=#{background}:s=1920x1080:d=#{duration}"
128
+ ]
129
+ filters << "[#{stream_index}:v]setpts=PTS-STARTPTS[bg]"
130
+ end
131
+ stream_index += 1
132
+
133
+ current_layer = "bg"
134
+
135
+ # Logo layer
136
+ logo_width = logo_config.fetch(:width, 350)
137
+ logo_x = logo_config.fetch(:x, "center")
138
+ logo_y = logo_config.fetch(:y, "center")
139
+ overlay_x = logo_x == "center" ? "(W-w)/2" : logo_x
140
+ overlay_y = logo_y == "center" ? "(H-h)/2" : logo_y
141
+
142
+ inputs += ["-loop", "1", "-i", logo_path]
143
+ filters << "[#{stream_index}:v]scale=#{logo_width}:" \
144
+ "-1:flags=lanczos[logo]"
145
+ filters << "[#{current_layer}][logo]overlay=#{overlay_x}:" \
146
+ "#{overlay_y}[with_logo]"
147
+ current_layer = "with_logo"
148
+ stream_index += 1
149
+
150
+ # Apply fades to final video layer
151
+ # Use black for fade color when background is a file
152
+ fade_color = if background_path&.file?
153
+ "black"
154
+ else
155
+ config.fetch(
156
+ :background, "black"
157
+ )
158
+ end
159
+ filters <<
160
+ "[#{current_layer}]fade=t=in:st=#{logo_delay}:d=#{fade_in}:" \
161
+ "c=#{fade_color},fade=t=out:st=#{fade_out_start}:" \
162
+ "d=#{fade_out}:c=#{fade_color},setpts=PTS-STARTPTS[fade]"
163
+
164
+ # Audio (always generate, silent if no sound configured)
165
+ if sound_path
166
+ inputs += ["-i", sound_path]
167
+ sound_volume = sound_config.fetch(:volume, 1.0)
168
+ filters << "[#{stream_index}:a]adelay=#{(logo_delay * 1000).to_i}|" \
169
+ "#{(logo_delay * 1000).to_i}," \
170
+ "apad,atrim=end=#{duration}," \
171
+ "aresample=async=1,volume=#{sound_volume}[a]"
172
+ else
173
+ # Generate silent audio track
174
+ filters << "anullsrc=r=44100:cl=mono,atrim=end=#{duration}[a]"
175
+ end
176
+
177
+ maps = ["-map", "[fade]", "-map", "[a]"]
178
+
179
+ {inputs:, filters: filters.join(";"), maps:}
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenKit
4
+ module Exporter
5
+ class Segment
6
+ include Shell
7
+ include Utils
8
+
9
+ # The path to the content file for this segment.
10
+ # @return [Pathname]
11
+ attr_reader :content_path
12
+
13
+ # The episode exporter this segment belongs to.
14
+ # @return [ScreenKit::Exporter::Episode]
15
+ attr_reader :episode
16
+
17
+ # The prefix number of the segment file.
18
+ attr_reader :prefix
19
+
20
+ def initialize(content_path:, episode:)
21
+ @prefix = content_path.basename.to_s[/^(\d+)/, 1]
22
+ @content_path = content_path
23
+ @episode = episode
24
+ end
25
+
26
+ def script_path
27
+ @script_path ||= episode.root_dir.join("scripts", "#{prefix}.txt")
28
+ end
29
+
30
+ def video_path
31
+ @video_path ||= episode.output_dir.join("videos", "#{prefix}.mp4")
32
+ end
33
+
34
+ def callouts_path
35
+ @callouts_path ||= episode.root_dir.join("callouts/#{prefix}.yml")
36
+ end
37
+
38
+ def segment_path
39
+ @segment_path ||= episode.output_dir.join("segments", "#{prefix}.mp4")
40
+ end
41
+
42
+ def voiceover_path
43
+ return episode.mute_sound_path unless episode.tts?
44
+
45
+ dir = episode.root_dir.join("voiceovers")
46
+ dir.glob("#{prefix}.{#{ContentType.audio.join(',')}}").first ||
47
+ dir.join("#{prefix}.mp3")
48
+ end
49
+
50
+ def output_voiceover_path
51
+ episode.output_dir.join("voiceovers").join("#{prefix}.flac")
52
+ end
53
+
54
+ def callouts
55
+ @callouts ||= if callouts_path.file?
56
+ YAML.load_file(callouts_path, symbolize_names: true)
57
+ else
58
+ []
59
+ end
60
+ end
61
+
62
+ def export_video(log_path:)
63
+ return if video_path.file? && !episode.options.overwrite
64
+
65
+ log_path = format(log_path.to_s, prefix:) if log_path
66
+
67
+ case content_path.extname.downcase.gsub(/^\./, "")
68
+ when *ContentType.video
69
+ Exporter::Video
70
+ .new(input_path: content_path, log_path:)
71
+ .export(video_path)
72
+ when *ContentType.image
73
+ Exporter::Image
74
+ .new(image_path: content_path, log_path:)
75
+ .export(video_path)
76
+ when *ContentType.demotape
77
+ Exporter::Demotape
78
+ .new(demotape_path: content_path, log_path:)
79
+ .export(video_path)
80
+ else
81
+ raise "Unsupported content type: #{content_path.extname}"
82
+ end
83
+ end
84
+
85
+ def crossfade_duration
86
+ episode.scenes.fetch(:segment).fetch(:crossfade_duration, 0.5)
87
+ end
88
+
89
+ def merge_audio_and_video(log_path:)
90
+ # Get video duration
91
+ video_duration = duration(video_path)
92
+
93
+ # Get audio duration
94
+ audio_duration = duration(output_voiceover_path)
95
+
96
+ # Calculate the content duration and extend by crossfade duration
97
+ content_duration = [video_duration, audio_duration].max
98
+ final_duration = content_duration + crossfade_duration
99
+
100
+ # # Calculate padding needed for audio (content + silence for crossfade)
101
+ audio_pad_samples = ((final_duration - audio_duration) * 44_100).to_i
102
+
103
+ # Calculate video padding (content + cloned frame for crossfade)
104
+ video_pad_duration = final_duration - video_duration
105
+
106
+ # The raw video and voiceover
107
+ inputs = ["-i", video_path, "-i", output_voiceover_path]
108
+
109
+ filters = [
110
+ "[0:v]tpad=stop_mode=clone:stop_duration=#{video_pad_duration}[v0]"
111
+ ]
112
+
113
+ audio_mix_inputs = ["[1:a]"]
114
+ animation_duration = 0.2
115
+
116
+ callouts.each_with_index do |callout, index|
117
+ type = callout[:type].to_sym
118
+ callout_config = episode.project_config.callouts[type]
119
+ in_sound = Sound.new(input: callout_config[:in_transition][:sound],
120
+ source: episode.source)
121
+ out_sound = Sound.new(input: callout_config[:out_transition][:sound],
122
+ source: episode.source)
123
+
124
+ starts_at = callout[:starts_at]
125
+ max_duration = [content_duration - 0.2, 0].max
126
+ duration = callout[:duration]
127
+ .clamp(0, max_duration)
128
+ .round(half: :down)
129
+ ends_at = starts_at + duration
130
+ callout_image_path = episode.output_dir.join("callouts",
131
+ "#{prefix}-#{index}.png")
132
+ image_width, image_height = image_size(callout_image_path)
133
+
134
+ x, y = calculate_position(
135
+ anchor: Anchor.new(callout_config[:anchor]),
136
+ margin: Spacing.new(callout_config[:margin] || 0),
137
+ width: image_width,
138
+ height: image_height
139
+ )
140
+
141
+ inputs += [
142
+ "-loop", "1",
143
+ "-t", duration,
144
+ "-i", callout_image_path,
145
+
146
+ # Add sound for transition in
147
+ "-i", in_sound.path,
148
+
149
+ # Add sound for transition out
150
+ "-i", out_sound.path
151
+ ]
152
+
153
+ input_stream = "v#{index}"
154
+ output_stream = "v#{index + 1}"
155
+ callout_index = 2 + (index * 3)
156
+
157
+ animation_filters = AnimationFilters.new(
158
+ content_duration:,
159
+ callout_index:,
160
+ input_stream:,
161
+ output_stream:,
162
+ index:,
163
+ starts_at:,
164
+ ends_at:,
165
+ x:,
166
+ y:,
167
+ animation_duration:,
168
+ image_width:,
169
+ image_height:
170
+ ).send(callout_config[:animation])
171
+
172
+ filters.concat(animation_filters[:video])
173
+
174
+ # Delay and mix callout sounds
175
+ in_index = callout_index + 1
176
+ out_index = callout_index + 2
177
+
178
+ filters << "[#{in_index}:a]volume=#{in_sound.volume}," \
179
+ "adelay=#{(starts_at * 1000).to_i}|" \
180
+ "#{(starts_at * 1000).to_i}[in_#{index}]"
181
+ filters << "[#{out_index}:a]volume=#{out_sound.volume}," \
182
+ "adelay=#{(animation_filters[:out_start] * 1000).to_i}|" \
183
+ "#{(animation_filters[:out_start] * 1000).to_i}" \
184
+ "[out_#{index}]"
185
+
186
+ audio_mix_inputs << "[in_#{index}]"
187
+ audio_mix_inputs << "[out_#{index}]"
188
+ end
189
+
190
+ # Mix all audio streams (voiceover + all sounds)
191
+ filters << "#{audio_mix_inputs.join}amix=inputs=" \
192
+ "#{audio_mix_inputs.size}:duration=longest:normalize=0" \
193
+ "[mixed_audio]"
194
+ filters << "[mixed_audio]aresample=async=1,apad=pad_len=" \
195
+ "#{audio_pad_samples}[a]"
196
+
197
+ filter_complex = filters.join(";")
198
+
199
+ cmd = [
200
+ "ffmpeg",
201
+ *inputs,
202
+ "-filter_complex",
203
+ filter_complex,
204
+ "-map", "[v#{callouts.size}]",
205
+ "-map", "[a]",
206
+ "-t", final_duration,
207
+ "-r", 24,
208
+ "-c:a", "flac", "-ac", "1", "-ar", "44100",
209
+ "-c:v", "libx264", "-crf", "0", "-pix_fmt", "yuv444p",
210
+ "-y",
211
+ segment_path
212
+ ]
213
+
214
+ run_command(*cmd, log_path: create_log_path(log_path, __method__))
215
+ end
216
+
217
+ def export_voiceover(log_path:)
218
+ create_voiceover(log_path: create_log_path(log_path))
219
+ normalize_voiceover(
220
+ log_path: create_log_path(log_path, :normalize)
221
+ )
222
+ end
223
+
224
+ def normalize_voiceover(log_path:)
225
+ run_command "ffmpeg-normalize",
226
+ voiceover_path,
227
+ "-f",
228
+ "-o", output_voiceover_path,
229
+ "-nt", "ebu",
230
+ "-t", "-18",
231
+ "-c:a", "flac", "-ac", "1", "-ar", "44100",
232
+ log_path:
233
+ end
234
+
235
+ def create_voiceover(log_path:)
236
+ return if voiceover_path&.file? && !episode.options.overwrite
237
+ return unless script_path.file?
238
+ return unless episode.tts?
239
+
240
+ FileUtils.mkdir_p(voiceover_path.dirname)
241
+
242
+ episode.tts_engine.generate(
243
+ text: script_path.read,
244
+ output_path: voiceover_path,
245
+ log_path:
246
+ )
247
+ end
248
+
249
+ def create_log_path(path, tag = nil)
250
+ return path unless path
251
+
252
+ path = path.sub_ext("-#{tag.to_s.tr('_', '-')}.txt") if tag
253
+
254
+ format(path.to_s, prefix:) if path
255
+ end
256
+ end
257
+ end
258
+ end