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,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenKit
4
+ module Exporter
5
+ class Demotape
6
+ include Shell
7
+
8
+ attr_reader :demotape_path, :log_path
9
+
10
+ def initialize(demotape_path:, log_path: nil)
11
+ @demotape_path = demotape_path
12
+ @log_path = log_path
13
+ end
14
+
15
+ def export(output_path)
16
+ run_command "demotape",
17
+ "run",
18
+ demotape_path,
19
+ "--fps", 24,
20
+ "--overwrite",
21
+ "--output-path", output_path,
22
+ log_path:
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,565 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenKit
4
+ module Exporter
5
+ class Episode
6
+ using CoreExt
7
+ include Utils
8
+ include Shell
9
+
10
+ # Glob pattern to match content files.
11
+ # Each file will be used as a segment in the episode.
12
+ CONTENT_PATTERN = "content/**/*.{#{ContentType.all.join(',')}}".freeze
13
+
14
+ # The project configuration, usually the root's screenkit.yml file.
15
+ # @return [ScreenKit::Config::Project]
16
+ attr_reader :project_config
17
+
18
+ # The episode configuration, usually the episode's config.yml file.
19
+ # @return [ScreenKit::Config::Episode]
20
+ attr_reader :config
21
+
22
+ # The export options.
23
+ # @return [Hash{Symbol => Object}]
24
+ attr_reader :options
25
+
26
+ # A mutex for thread-safe operations.
27
+ attr_reader :mutex
28
+
29
+ # The logfile for logging export details.
30
+ attr_reader :logfile
31
+
32
+ def initialize(project_config:, config:, options:)
33
+ @project_config = project_config
34
+ @config = config
35
+ @options = options
36
+ @mutex = Mutex.new
37
+ @logfile = Logfile.new(output_dir.join("logs"))
38
+ end
39
+
40
+ def tts?
41
+ config.tts || project_config.tts
42
+ end
43
+
44
+ def tts_options
45
+ (config.tts || {}).merge(project_config.tts || {})
46
+ end
47
+
48
+ def tts_engine
49
+ @tts_engine ||= TTS.const_get(
50
+ tts_options[:engine].camelize
51
+ ).new(**tts_options.except(:engine))
52
+ end
53
+
54
+ # Logs a message to the shell with a specific category.
55
+ #
56
+ # @param category [Symbol] The category of the log message
57
+ # (e.g., :info, :error).
58
+ # @param message [String] The log message, which can include format
59
+ # placeholders.
60
+ # @param ** [Hash{Symbol => Object}] Additional keyword arguments to
61
+ # format the message.
62
+ def log(category, message, color: :magenta, **)
63
+ shell.say_status(category, format(message.to_s, **), color)
64
+ end
65
+
66
+ def log_elapsed(message, elapsed)
67
+ log(
68
+ :info,
69
+ message,
70
+ elapsed: shell.set_color(format("%.2fs", elapsed), :blue)
71
+ )
72
+ end
73
+
74
+ def export
75
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
76
+
77
+ cleanup_output_dir
78
+ prelude
79
+ create_output_dir
80
+ export_intro
81
+ export_outro
82
+ export_voiceovers
83
+ export_videos
84
+ export_callouts
85
+ create_segments
86
+ merge_segments
87
+
88
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
89
+ log_elapsed("Exported episode in %{elapsed}", elapsed)
90
+ ensure
91
+ spinner.stop
92
+ end
93
+
94
+ def export_callouts
95
+ callouts = filtered_segments.flat_map do |segment|
96
+ segment.callouts.map { {prefix: segment.prefix, callouts: it} }
97
+ end
98
+
99
+ elapsed = ParallelProcessor.new(
100
+ spinner:,
101
+ list: callouts,
102
+ message: "Exporting callouts (%{progress}/%{count})"
103
+ ).run do |item, index|
104
+ log_path = logfile.create(item[:prefix], :callout, index)
105
+ type = item[:callouts].fetch(:type).to_sym
106
+ callout_style = project_config
107
+ .callouts
108
+ .fetch(type)
109
+ .merge(item[:callouts].except(:starts_at, :duration))
110
+ callout_path = output_dir
111
+ .join("callouts", "#{item[:prefix]}-#{index}.png")
112
+ Callout.new(
113
+ source:, **callout_style,
114
+ output_path: callout_path,
115
+ log_path:
116
+ ).render
117
+ end
118
+
119
+ log_elapsed("Created callouts in %{elapsed}", elapsed)
120
+ end
121
+
122
+ def export_voiceovers
123
+ elapsed = ParallelProcessor.new(
124
+ spinner:,
125
+ list: filtered_segments,
126
+ message: "Exporting voiceovers (%{progress}/%{count})",
127
+ log_path: logfile.create("%{prefix}", :voiceover)
128
+ ).run(&:export_voiceover)
129
+
130
+ log_elapsed("Generated voiceover in %{elapsed}", elapsed)
131
+ end
132
+
133
+ def export_videos
134
+ elapsed = ParallelProcessor.new(
135
+ spinner:,
136
+ list: filtered_segments,
137
+ message: "Exporting videos (%{progress}/%{count})",
138
+ log_path: logfile.create("%{prefix}", "export-video")
139
+ ).run(&:export_video)
140
+
141
+ log_elapsed("Exported videos in %{elapsed}", elapsed)
142
+ end
143
+
144
+ def create_segments
145
+ elapsed = ParallelProcessor.new(
146
+ spinner:,
147
+ list: filtered_segments,
148
+ message: "Merging audio and video (%{progress}/%{count})",
149
+ log_path: logfile.create("%{prefix}")
150
+ ).run(&:merge_audio_and_video)
151
+
152
+ log_elapsed("Created segments in %{elapsed}", elapsed)
153
+ end
154
+
155
+ def merge_segments
156
+ spinner.update("Merging segments into final episode…")
157
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
158
+ crossfade_duration = scenes.dig(:segment, :crossfade_duration) || 0.5
159
+ watermark = Watermark.new(config.watermark || project_config.watermark)
160
+
161
+ watermark_path = if watermark.path
162
+ source.search(watermark.path)
163
+ else
164
+ ScreenKit.root_dir
165
+ .join("screenkit/resources/transparent.png")
166
+ end
167
+
168
+ watermark_width, watermark_height = image_size(watermark_path)
169
+ watermark_x, watermark_y = calculate_position(
170
+ anchor: watermark.anchor,
171
+ margin: watermark.margin,
172
+ width: watermark_width,
173
+ height: watermark_height
174
+ )
175
+
176
+ # Build input files list: intro, segments, outro
177
+ all_videos = [
178
+ intro_path,
179
+ *output_dir.glob("segments/**/*.mp4"),
180
+ outro_path
181
+ ]
182
+
183
+ backtrack_adjustment = 1.0 / backtrack.volume
184
+ backtrack_fade_volume = if tts?
185
+ 0.15 * backtrack_adjustment
186
+ else
187
+ 1.0
188
+ end
189
+ backtrack_full_volume = backtrack.volume
190
+
191
+ # Build xfade filter chain for video with crossfades
192
+ video_filters = []
193
+ audio_filters = []
194
+ offset = 0
195
+ target_fps = 24.0
196
+
197
+ # Calculate total video duration and watermark timing
198
+ intro_duration =
199
+ duration(all_videos.first) * (fps(all_videos.first) / target_fps)
200
+ total_segments_duration = 0
201
+ all_videos[1..-2].each do |video_path|
202
+ video_duration = duration(video_path)
203
+ video_fps = fps(video_path)
204
+ adjusted_duration = video_duration * (video_fps / target_fps)
205
+ total_segments_duration += adjusted_duration - crossfade_duration
206
+ end
207
+ outro_duration =
208
+ duration(all_videos.last) * (fps(all_videos.last) / target_fps)
209
+ total_video_duration = intro_duration +
210
+ total_segments_duration +
211
+ outro_duration -
212
+ (crossfade_duration * 2)
213
+
214
+ watermark_start = intro_duration - crossfade_duration
215
+ watermark_end = watermark_start + total_segments_duration
216
+
217
+ # Build ffmpeg inputs
218
+ inputs = all_videos.flat_map {|path| ["-i", path] }
219
+ inputs += [
220
+ "-loop", "1", "-t", total_video_duration.to_s, "-i",
221
+ watermark_path
222
+ ]
223
+ inputs += ["-i", backtrack.path]
224
+
225
+ watermark_stream_index = all_videos.size
226
+ backtrack_stream_index = all_videos.size + 1
227
+
228
+ all_videos.each_with_index do |video_path, index|
229
+ # Get original video duration and fps
230
+ video_duration = duration(video_path)
231
+ video_fps = fps(video_path)
232
+
233
+ # Adjust duration for fps conversion
234
+ adjusted_duration = video_duration * (video_fps / target_fps)
235
+ prev_label = index == 1 ? "v0" : "vx#{index - 2}"
236
+
237
+ video_filters <<
238
+ "[#{index}:v]fps=#{target_fps},setpts=PTS-STARTPTS[v#{index}]"
239
+
240
+ if index.zero?
241
+ # First video (intro)
242
+ audio_filters << "[#{index}:a]asetpts=PTS-STARTPTS[a#{index}]"
243
+ offset = adjusted_duration - crossfade_duration
244
+ elsif index == all_videos.size - 1
245
+ # Last video (outro) - xfade from previous
246
+ # Ensure the previous video is long enough for the xfade by padding
247
+ # if needed
248
+ pad_duration = offset + crossfade_duration
249
+ video_filters << "[#{prev_label}]tpad=stop_mode=clone" \
250
+ ":stop_duration=#{pad_duration}" \
251
+ "[#{prev_label}_padded]"
252
+ video_filters << "[#{prev_label}_padded][v#{index}]" \
253
+ "xfade=transition=fade:" \
254
+ "duration=#{crossfade_duration}:" \
255
+ "offset=#{offset}[vfinal]"
256
+ audio_filters << "[#{index}:a]adelay=#{(offset * 1000).to_i}|" \
257
+ "#{(offset * 1000).to_i}[a#{index}]"
258
+ else
259
+ video_filters << "[#{prev_label}][v#{index}]" \
260
+ "xfade=transition=fade:" \
261
+ "duration=#{crossfade_duration}:" \
262
+ "offset=#{offset}[vx#{index - 1}]"
263
+ audio_filters << "[#{index}:a]adelay=#{(offset * 1000).to_i}|" \
264
+ "#{(offset * 1000).to_i}[a#{index}]"
265
+ offset += adjusted_duration - crossfade_duration
266
+ end
267
+ end
268
+
269
+ # Apply watermark overlay - insert it before the outro xfade
270
+ # Find the index of the outro tpad filter (last occurrence of tpad)
271
+ tpad_index = video_filters.rindex do |f|
272
+ f.include?("tpad=stop_mode=clone")
273
+ end
274
+
275
+ if tpad_index
276
+ last_segment_index = all_videos.size - 2
277
+ last_xfade_label = "vx#{last_segment_index - 1}"
278
+
279
+ # Insert watermark filters before the tpad
280
+ video_filters.insert(
281
+ tpad_index,
282
+ "[#{watermark_stream_index}:v]scale=iw*0.5:ih*0.5," \
283
+ "format=rgba,colorchannelmixer=aa=#{watermark.opacity}[watermark]"
284
+ )
285
+ video_filters.insert(
286
+ tpad_index + 1,
287
+ "[#{last_xfade_label}][watermark]" \
288
+ "overlay=#{watermark_x}:#{watermark_y}:" \
289
+ "enable='between(t,#{watermark_start},#{watermark_end})'" \
290
+ "[#{last_xfade_label}_watermarked]"
291
+ )
292
+
293
+ # Update the tpad filter to use watermarked input
294
+ video_filters[tpad_index + 2] =
295
+ video_filters[tpad_index + 2]
296
+ .sub("[#{last_xfade_label}]", "[#{last_xfade_label}_watermarked]")
297
+ end
298
+
299
+ # Concatenate all audio tracks
300
+ audio_inputs = Array.new(all_videos.size) {|i| "[a#{i}]" }.join
301
+ audio_filters << "#{audio_inputs}amix=inputs=#{all_videos.size}:" \
302
+ "duration=longest:normalize=0[mixed]"
303
+
304
+ # Apply volume fade to backtrack:
305
+ # 1. Start at backtrack_full_volume during intro
306
+ # 2. Fade to backtrack_fade_volume over 1s, overlap first segment by 25%
307
+ # 3. Fade out to 0 over 1s at the end of the last segment before outro
308
+ intro_duration = duration(all_videos.first)
309
+ fade_in_duration = 1.0
310
+ fade_in_start = intro_duration - (fade_in_duration * 0.75)
311
+ fade_in_end = intro_duration + (fade_in_duration * 0.25)
312
+
313
+ # Calculate total duration up to end of last segment
314
+ total_duration = 0
315
+ all_videos[0..-2].each do |video_path|
316
+ video_duration = duration(video_path)
317
+ video_fps = fps(video_path)
318
+ adjusted_duration = video_duration * (video_fps / target_fps)
319
+
320
+ total_duration += adjusted_duration - crossfade_duration
321
+ end
322
+
323
+ fade_out_duration = 1.5
324
+ # Start fade out 75% before last segment ends, finish 25% into outro
325
+ fade_out_start = total_duration - (fade_out_duration * 0.75)
326
+ fade_out_end = total_duration + (fade_out_duration * 0.25)
327
+
328
+ audio_filters << "[#{backtrack_stream_index}:a]" \
329
+ "volume='if(lt(t,#{fade_in_start})," \
330
+ "#{backtrack_full_volume},if(lt(t,#{fade_in_end})," \
331
+ "#{backtrack_full_volume}-" \
332
+ "(#{backtrack_full_volume}-" \
333
+ "#{backtrack_fade_volume})*(t-#{fade_in_start})/" \
334
+ "#{fade_in_duration},if(lt(t,#{fade_out_start})," \
335
+ "#{backtrack_fade_volume}," \
336
+ "if(lt(t,#{fade_out_end})," \
337
+ "#{backtrack_fade_volume}*(#{fade_out_end}-t)/" \
338
+ "#{fade_out_duration},0))))':eval=" \
339
+ "frame[backtrack_faded]"
340
+
341
+ # Mix with backtrack
342
+ audio_filters << "[mixed][backtrack_faded]amix=inputs=2:" \
343
+ "duration=first:normalize=0[afinal]"
344
+
345
+ filter_complex = (video_filters + audio_filters).join(";")
346
+
347
+ command = [
348
+ "ffmpeg",
349
+ *inputs,
350
+ "-filter_complex",
351
+ filter_complex,
352
+ "-map", "[vfinal]",
353
+ "-map", "[afinal]",
354
+ "-c:v", "libx264", "-crf", "0", "-pix_fmt", "yuv444p",
355
+ "-c:a", "aac",
356
+ "-b:a", "320k",
357
+ "-ar", "44100",
358
+ "-y",
359
+ output_video_path
360
+ ]
361
+
362
+ run_command(*command, log_path: logfile.create("final-video"))
363
+
364
+ spinner.stop
365
+
366
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
367
+ log_elapsed("Merged videos in %{elapsed}", elapsed)
368
+
369
+ log(
370
+ :info,
371
+ "Exported video to %{path}",
372
+ path: shell.set_color(relative_path(output_video_path), :green)
373
+ )
374
+ end
375
+
376
+ # Logs initial information about the episode export process.
377
+ def prelude
378
+ logfile.json_log(
379
+ :config,
380
+ options.merge(
381
+ pwd: Dir.pwd,
382
+ episode_config: config.to_h,
383
+ project_config: project_config.to_h
384
+ )
385
+ )
386
+
387
+ log(
388
+ :info,
389
+ "Project root dir: %{dir}",
390
+ dir: shell.set_color(relative_path(project_root_dir), :blue)
391
+ )
392
+ log(
393
+ :info,
394
+ "Episode root dir: %{dir}",
395
+ dir: shell.set_color(relative_path(root_dir), :blue)
396
+ )
397
+
398
+ unless tts?
399
+ log(
400
+ :info,
401
+ shell.set_color("Voiceover is currently disabled", :red),
402
+ color: :red
403
+ )
404
+ end
405
+
406
+ filtered_count = filtered_segments.count
407
+ count = segments.count
408
+
409
+ message = if filtered_count == count && options.match_segment
410
+ "Matching all %{count} segments with %{regex}"
411
+ elsif filtered_count == count
412
+ "Matching all %{count} segments"
413
+ else
414
+ "Matching %{filtered_count} out of %{count} segments " \
415
+ "with %{regex}"
416
+ end
417
+
418
+ log(
419
+ :info,
420
+ message,
421
+ filtered_count: shell.set_color(filtered_count, :blue),
422
+ count: shell.set_color(count, :blue),
423
+ regex: shell.set_color(match_regex.source, :yellow)
424
+ )
425
+ end
426
+
427
+ def create_output_dir
428
+ FileUtils.mkdir_p([
429
+ output_dir.join("segments").to_s,
430
+ output_dir.join("scenes").to_s,
431
+ output_dir.join("logs").to_s,
432
+ output_dir.join("voiceovers").to_s,
433
+ output_dir.join("callouts").to_s,
434
+ output_dir.join("videos").to_s
435
+ ])
436
+ end
437
+
438
+ def cleanup_output_dir
439
+ FileUtils.rm_rf(output_dir.glob("logs/*"))
440
+ FileUtils.rm_rf(output_dir.glob("voiceovers/*"))
441
+ spinner.stop
442
+ end
443
+
444
+ def output_dir
445
+ @output_dir ||= Pathname(
446
+ format(
447
+ options.output_dir || project_config.output_dir.to_s,
448
+ episode_dirname: root_dir.basename
449
+ )
450
+ ).expand_path
451
+ end
452
+
453
+ def intro_path
454
+ @intro_path ||= output_dir.join("scenes/intro.mp4")
455
+ end
456
+
457
+ def outro_path
458
+ @outro_path ||= output_dir.join("scenes/outro.mp4")
459
+ end
460
+
461
+ def export_intro
462
+ time, _ = elapsed do
463
+ spinner.update("Exporting intro…")
464
+
465
+ intro_config = scenes.fetch(:intro)
466
+ log_path = logfile.create(:intro)
467
+
468
+ Intro
469
+ .new(config: intro_config, text: config.title, source:, log_path:)
470
+ .export(intro_path)
471
+
472
+ spinner.stop
473
+ end
474
+
475
+ log_elapsed("Exported intro in %{elapsed}", time)
476
+ end
477
+
478
+ def export_outro
479
+ time, _ = elapsed do
480
+ spinner.update("Exporting outro…")
481
+
482
+ log_path = logfile.create(:outro)
483
+ outro_config = scenes.fetch(:outro)
484
+ Outro.new(config: outro_config, source:, log_path:).export(outro_path)
485
+ spinner.stop
486
+ end
487
+
488
+ log_elapsed("Exported outro in %{elapsed}", time)
489
+ end
490
+
491
+ def spinner
492
+ @spinner ||= Spinner.new
493
+ end
494
+
495
+ def source
496
+ @source ||= PathLookup.new(*resources_dir)
497
+ end
498
+
499
+ def shell
500
+ @shell ||= Thor::Shell::Color.new
501
+ end
502
+
503
+ def scenes
504
+ @scenes ||= project_config.scenes.merge(config.scenes)
505
+ end
506
+
507
+ def project_root_dir
508
+ @project_root_dir ||= Pathname(root_dir.parent.parent).expand_path
509
+ end
510
+
511
+ def root_dir
512
+ @root_dir ||= Pathname(options.dir).expand_path
513
+ end
514
+
515
+ def resources_dir
516
+ @resources_dir ||= project_config.resources_dir.map do |dir|
517
+ path = dir
518
+ path = File.expand_path(dir) if dir.start_with?("~")
519
+ path = Pathname(format(path, episode_dir: root_dir))
520
+ path = Pathname.pwd.join(path) unless path.absolute?
521
+ path
522
+ end
523
+ end
524
+
525
+ def output_video_path
526
+ @output_video_path ||= output_dir.join("#{root_dir.basename}.mp4")
527
+ end
528
+
529
+ def mute_sound_path
530
+ @mute_sound_path ||= ScreenKit.root_dir.join(
531
+ "screenkit/resources/mute.mp3"
532
+ )
533
+ end
534
+
535
+ def backtrack
536
+ @backtrack ||=
537
+ if config.backtrack
538
+ Sound.new(input: config.backtrack, source:)
539
+ elsif project_config.backtrack
540
+ Sound.new(input: project_config.backtrack, source:)
541
+ else
542
+ Sound.new(input: mute_sound_path, source:)
543
+ end
544
+ end
545
+
546
+ def segments
547
+ @segments ||= root_dir
548
+ .glob(CONTENT_PATTERN)
549
+ .map { Segment.new(content_path: it, episode: self) }
550
+ end
551
+
552
+ def match_regex
553
+ @match_regex ||= Regexp.new(options.match_segment || ".*")
554
+ end
555
+
556
+ # Returns the segments filtered based on the `match_segment` option.
557
+ # @return [Array<ScreenKit::Exporter::Segment>]
558
+ def filtered_segments
559
+ @filtered_segments ||= segments.select do |segment|
560
+ segment.content_path.basename.to_s.match?(match_regex)
561
+ end
562
+ end
563
+ end
564
+ end
565
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenKit
4
+ module Exporter
5
+ class Image
6
+ include Shell
7
+
8
+ attr_reader :image_path, :log_path
9
+
10
+ def initialize(image_path:, log_path: nil)
11
+ @image_path = image_path
12
+ @log_path = log_path
13
+ end
14
+
15
+ def export(output_path)
16
+ cmd = [
17
+ "ffmpeg",
18
+ "-i", image_path,
19
+ "-vf", "scale=1920:1080:force_original_aspect_ratio=decrease," \
20
+ "pad=1920:1080:(ow-iw)/2:(oh-ih)/2:black",
21
+ "-r", 24,
22
+ "-c:v", "libx264", "-crf", "0", "-pix_fmt", "yuv444p",
23
+ "-y",
24
+ output_path
25
+ ]
26
+
27
+ run_command(*cmd, log_path:)
28
+ end
29
+ end
30
+ end
31
+ end