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.
- checksums.yaml +7 -0
- data/.github/CODEOWNERS +4 -0
- data/.github/FUNDING.yml +4 -0
- data/.github/ISSUE_TEMPLATE/bug_report.md +41 -0
- data/.github/ISSUE_TEMPLATE/config.yml +5 -0
- data/.github/ISSUE_TEMPLATE/feature_request.md +23 -0
- data/.github/PULL_REQUEST_TEMPLATE.md +38 -0
- data/.github/dependabot.yml +15 -0
- data/.github/workflows/ruby-tests.yml +51 -0
- data/.gitignore +12 -0
- data/.rubocop.yml +14 -0
- data/CHANGELOG.md +16 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +80 -0
- data/DOCUMENTATION.md +972 -0
- data/Gemfile +5 -0
- data/LICENSE.md +20 -0
- data/README.md +49 -0
- data/Rakefile +15 -0
- data/bin/console +16 -0
- data/bin/setup +10 -0
- data/exe/screenkit +5 -0
- data/lib/screen_kit.rb +79 -0
- data/lib/screenkit/anchor.rb +19 -0
- data/lib/screenkit/animation_filters.rb +114 -0
- data/lib/screenkit/banner.rb +46 -0
- data/lib/screenkit/callout/styles/base.rb +101 -0
- data/lib/screenkit/callout/styles/default.rb +144 -0
- data/lib/screenkit/callout/styles/inline_block.rb +123 -0
- data/lib/screenkit/callout/text_style.rb +44 -0
- data/lib/screenkit/callout.rb +98 -0
- data/lib/screenkit/cli/base.rb +24 -0
- data/lib/screenkit/cli/episode.rb +78 -0
- data/lib/screenkit/cli/root.rb +73 -0
- data/lib/screenkit/cli.rb +9 -0
- data/lib/screenkit/config/base.rb +51 -0
- data/lib/screenkit/config/episode.rb +42 -0
- data/lib/screenkit/config/project.rb +47 -0
- data/lib/screenkit/content_type.rb +12 -0
- data/lib/screenkit/core_ext/json.rb +47 -0
- data/lib/screenkit/core_ext/string.rb +28 -0
- data/lib/screenkit/exporter/demotape.rb +26 -0
- data/lib/screenkit/exporter/episode.rb +565 -0
- data/lib/screenkit/exporter/image.rb +31 -0
- data/lib/screenkit/exporter/intro.rb +261 -0
- data/lib/screenkit/exporter/outro.rb +183 -0
- data/lib/screenkit/exporter/segment.rb +258 -0
- data/lib/screenkit/exporter/video.rb +33 -0
- data/lib/screenkit/generators/episode/config.yml.erb +106 -0
- data/lib/screenkit/generators/episode/content/001.tape +4 -0
- data/lib/screenkit/generators/episode/scripts/001.txt +1 -0
- data/lib/screenkit/generators/episode.rb +31 -0
- data/lib/screenkit/generators/project/Gemfile.erb +7 -0
- data/lib/screenkit/generators/project/resources/backtracks/default.aac +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OFL.txt +93 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-Bold.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-BoldItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-ExtraBold.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-ExtraBoldItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-Italic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-Light.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-LightItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-Medium.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-MediumItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-Regular.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-SemiBold.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-SemiBoldItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-Bold.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-BoldItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-ExtraBold.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-ExtraBoldItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-Italic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-Light.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-LightItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-Medium.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-MediumItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-Regular.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-SemiBold.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-SemiBoldItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-Bold.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-BoldItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-ExtraBold.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-ExtraBoldItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-Italic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-Light.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-LightItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-Medium.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-MediumItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-Regular.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-SemiBold.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-SemiBoldItalic.ttf +0 -0
- data/lib/screenkit/generators/project/resources/fonts/opensans/README.txt +100 -0
- data/lib/screenkit/generators/project/resources/images/logo.png +0 -0
- data/lib/screenkit/generators/project/resources/images/watermark.png +0 -0
- data/lib/screenkit/generators/project/resources/sounds/chime.mp3 +0 -0
- data/lib/screenkit/generators/project/resources/sounds/pop.mp3 +0 -0
- data/lib/screenkit/generators/project/resources/sounds/whoosh.mp3 +0 -0
- data/lib/screenkit/generators/project/screenkit.yml +189 -0
- data/lib/screenkit/generators/project.rb +34 -0
- data/lib/screenkit/logfile.rb +33 -0
- data/lib/screenkit/parallel_processor.rb +50 -0
- data/lib/screenkit/path_lookup.rb +27 -0
- data/lib/screenkit/resources/mute.mp3 +0 -0
- data/lib/screenkit/resources/transparent.png +0 -0
- data/lib/screenkit/schema_validator.rb +14 -0
- data/lib/screenkit/schemas/callouts/default.json +44 -0
- data/lib/screenkit/schemas/callouts/inline_block.json +20 -0
- data/lib/screenkit/schemas/episode.json +74 -0
- data/lib/screenkit/schemas/project.json +37 -0
- data/lib/screenkit/schemas/refs/anchor.json +17 -0
- data/lib/screenkit/schemas/refs/animation.json +7 -0
- data/lib/screenkit/schemas/refs/background.json +14 -0
- data/lib/screenkit/schemas/refs/callout.json +27 -0
- data/lib/screenkit/schemas/refs/color.json +7 -0
- data/lib/screenkit/schemas/refs/directory.json +20 -0
- data/lib/screenkit/schemas/refs/intro.json +30 -0
- data/lib/screenkit/schemas/refs/logo.json +26 -0
- data/lib/screenkit/schemas/refs/outro.json +26 -0
- data/lib/screenkit/schemas/refs/position.json +38 -0
- data/lib/screenkit/schemas/refs/scenes.json +27 -0
- data/lib/screenkit/schemas/refs/size.json +19 -0
- data/lib/screenkit/schemas/refs/sound.json +36 -0
- data/lib/screenkit/schemas/refs/spacing.json +22 -0
- data/lib/screenkit/schemas/refs/text_style.json +18 -0
- data/lib/screenkit/schemas/refs/transition.json +15 -0
- data/lib/screenkit/schemas/refs/tts.json +47 -0
- data/lib/screenkit/schemas/refs/watermark.json +18 -0
- data/lib/screenkit/schemas/tts/elevenlabs.json +67 -0
- data/lib/screenkit/schemas/tts/say.json +16 -0
- data/lib/screenkit/shell.rb +58 -0
- data/lib/screenkit/sound.rb +44 -0
- data/lib/screenkit/spacing.rb +23 -0
- data/lib/screenkit/spinner.rb +39 -0
- data/lib/screenkit/transition.rb +16 -0
- data/lib/screenkit/tts/eleven_labs.rb +51 -0
- data/lib/screenkit/tts/say.rb +31 -0
- data/lib/screenkit/utils.rb +87 -0
- data/lib/screenkit/version.rb +5 -0
- data/lib/screenkit/watermark.rb +34 -0
- data/lib/screenkit.rb +3 -0
- data/screenkit.gemspec +56 -0
- 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
|