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,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
|