screenkit 0.0.9 → 0.0.11

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.
@@ -3,8 +3,11 @@
3
3
  module ScreenKit
4
4
  module Exporter
5
5
  class Intro
6
+ using CoreExt
7
+
6
8
  include Shell
7
9
  include Utils
10
+ include ImageMagick
8
11
  extend SchemaValidator
9
12
 
10
13
  def self.schema_path
@@ -14,19 +17,15 @@ module ScreenKit
14
17
  # The intro scene configuration.
15
18
  attr_reader :config
16
19
 
17
- # The title text.
18
- attr_reader :text
19
-
20
20
  # The source path lookup instance.
21
21
  attr_reader :source
22
22
 
23
23
  # The log path.
24
24
  attr_reader :log_path
25
25
 
26
- def initialize(config:, text:, source:, log_path: nil)
26
+ def initialize(config:, source:, log_path: nil)
27
27
  self.class.validate!(config)
28
28
  @config = config
29
- @text = text
30
29
  @source = source
31
30
  @log_path = log_path
32
31
  end
@@ -75,8 +74,89 @@ module ScreenKit
75
74
  @font_path ||= source.search(title_config[:font_path])
76
75
  end
77
76
 
77
+ def render_elements(overlay_path)
78
+ padding = Spacing.new(hi_res(config.fetch(:padding, 100)))
79
+
80
+ hi_res(
81
+ image_width: 1920,
82
+ image_height: 1080,
83
+ content_width: 1920 - (padding.horizontal / 2)
84
+ ) => {
85
+ image_width:,
86
+ image_height:,
87
+ content_width:,
88
+ }
89
+
90
+ logo = hi_res({width: 350, x: 0, y: 0}.deep_merge(config[:logo]))
91
+ title = hi_res({size: 144, x: 0, y: 0}.deep_merge(config[:title]))
92
+ url = hi_res({size: 42, x: 0, y: 0}.deep_merge(config[:url]))
93
+ url_height = 0
94
+
95
+ logo_intermediary_path = overlay_path.sub_ext("_logo.png")
96
+ title_intermediary_path = overlay_path.sub_ext("_title.png")
97
+ url_intermediary_path = overlay_path.sub_ext("_url.png")
98
+
99
+ MiniMagick.convert do |image|
100
+ image << logo_path
101
+ image << "-resize"
102
+ image << "#{logo[:width]}x"
103
+ image << "PNG:#{logo_intermediary_path}"
104
+ end
105
+
106
+ title_style = TextStyle.new(source:, **title)
107
+ url_style = TextStyle.new(source:, **url)
108
+
109
+ _, _, title_height =
110
+ *render_text_image(path: title_intermediary_path,
111
+ text: title[:text],
112
+ style: title_style,
113
+ width: content_width,
114
+ type: "caption")
115
+
116
+ if url[:text]
117
+ _, _, url_height =
118
+ *render_text_image(path: url_intermediary_path,
119
+ text: url[:text],
120
+ style: url_style,
121
+ width: content_width,
122
+ type: "caption")
123
+ end
124
+
125
+ MiniMagick.convert do |image|
126
+ image << "-size"
127
+ image << "#{image_width}x#{image_height}"
128
+ image << "xc:none"
129
+
130
+ image << logo_intermediary_path
131
+ image << "-geometry"
132
+ image << "+#{padding.left}+#{padding.top}"
133
+ image << "-composite"
134
+
135
+ offset_y = (image_height - title_height) / 2
136
+
137
+ image << title_intermediary_path
138
+ image << "-geometry"
139
+ image << "+#{padding.left}+#{offset_y}"
140
+ image << "-composite"
141
+
142
+ offset_y = image_height - padding.bottom - url_height
143
+
144
+ if url[:text]
145
+ image << url_intermediary_path
146
+ image << "-geometry"
147
+ image << "+#{padding.left}+#{offset_y}"
148
+ image << "-composite"
149
+ end
150
+
151
+ image << "PNG:#{overlay_path}"
152
+ end
153
+ end
154
+
78
155
  def export(path)
79
- ffmpeg_params => {inputs:, filters:, maps:}
156
+ overlay_path = path.sub_ext(".png")
157
+ render_elements(overlay_path)
158
+
159
+ ffmpeg_params(overlay_path) => {inputs:, filters:, maps:}
80
160
 
81
161
  cmd = [
82
162
  "ffmpeg",
@@ -95,11 +175,11 @@ module ScreenKit
95
175
  run_command(*cmd, log_path:)
96
176
  end
97
177
 
98
- private def ffmpeg_params
178
+ private def ffmpeg_params(overlay_path)
99
179
  duration = Duration.parse(config[:duration])
100
180
  fade_in = Duration.parse(config.fetch(:fade_in, 0.0))
101
181
  fade_out = Duration.parse(config.fetch(:fade_out, 0.5))
102
- fade_out_start = duration - fade_out - 0.1
182
+ fade_out_start = duration - fade_out - 0.2
103
183
 
104
184
  # Build filter chain
105
185
  inputs = []
@@ -155,61 +235,15 @@ module ScreenKit
155
235
 
156
236
  current_layer = "bg"
157
237
 
158
- # Logo layer (if present)
159
- if logo_path
160
- logo_width = logo_config.fetch(:width, 350)
161
- logo_x = logo_config.fetch(:x, "center")
162
- logo_y = logo_config.fetch(:y, "center")
163
- overlay_x = logo_x == "center" ? "(W-w)/2" : logo_x
164
- overlay_y = logo_y == "center" ? "(H-h)/2" : logo_y
165
-
166
- inputs += ["-loop", "1", "-i", logo_path]
167
- filters << "[#{stream_index}:v]scale=#{logo_width}:" \
168
- "-1:flags=lanczos[logo]"
169
- filters << "[#{current_layer}][logo]overlay=#{overlay_x}:" \
170
- "#{overlay_y}[with_logo]"
171
- current_layer = "with_logo"
238
+ # Overlay layer (if present)
239
+ if overlay_path.file?
240
+ inputs += ["-loop", "1", "-i", overlay_path]
241
+ filters << "[#{stream_index}:v]scale=1920:1080[overlay]"
242
+ filters << "[#{current_layer}][overlay]overlay=0:0[with_overlay]"
243
+ current_layer = "with_overlay"
172
244
  stream_index += 1
173
245
  end
174
246
 
175
- # Title layer (if present)
176
- if title_config
177
- title_x = title_config.fetch(:x, "center")
178
- title_y = title_config.fetch(:y, "center")
179
- title_size = title_config.fetch(:size, 72)
180
- title_color = title_config.fetch(:color, "white")
181
-
182
- # Calculate max width based on x offset
183
- max_width = if title_x == "center"
184
- 1720 # 1920 - (2 * 100)
185
- else
186
- 1920 - (2 * title_x.to_i)
187
- end
188
-
189
- # Rough estimate characters per line based on font size
190
- avg_char_width = title_size * 0.7
191
- max_chars_per_line = (max_width / avg_char_width).floor
192
-
193
- # Auto-wrap text
194
- wrapped_text = wrap_text(text, max_chars_per_line)
195
-
196
- # Convert position to drawtext coordinates
197
- drawtext_x = title_x == "center" ? "(w-text_w)/2" : title_x
198
- drawtext_y = title_y == "center" ? "(h-text_h)/2" : title_y
199
-
200
- # Center align text when x is centered
201
- text_align = title_x == "center" ? ":text_align=center" : ""
202
-
203
- # Escape special characters in text
204
- wrapped_text = wrapped_text.gsub("'", "'\\\\\\''").gsub(":", "\\:")
205
-
206
- filters << "[#{current_layer}]drawtext=text='#{wrapped_text}':" \
207
- "fontfile=#{font_path}:fontsize=#{title_size}:" \
208
- "fontcolor=#{title_color}:x=#{drawtext_x}:" \
209
- "y=#{drawtext_y}#{text_align}[with_title]"
210
- current_layer = "with_title"
211
- end
212
-
213
247
  # Apply fades to final video layer
214
248
  # Use black for fade color when background is an image file
215
249
  fade_color = background_path&.file? ? "black" : background
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenKit
4
+ module Exporter
5
+ class Playwright
6
+ include Shell
7
+
8
+ attr_reader :script_path, :log_path, :options
9
+
10
+ def initialize(script_path:, options: {}, log_path: nil)
11
+ @script_path = script_path
12
+ @log_path = log_path
13
+ @options = options
14
+ end
15
+
16
+ def export(output_path)
17
+ run_command "playwright-video",
18
+ "export",
19
+ script_path,
20
+ options_to_args(options),
21
+ "--output-path", output_path,
22
+ log_path:,
23
+ chdir: script_path.parent.parent
24
+ end
25
+
26
+ def options_to_args(options)
27
+ (options || {}).flat_map do |key, value|
28
+ [key.to_s.tr("_", "-").prepend("--"), value]
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -60,11 +60,23 @@ module ScreenKit
60
60
  end
61
61
 
62
62
  def export_video(log_path:)
63
- return if video_path.file? && !episode.options.overwrite
63
+ return if video_path.file? && !overwrite_content?
64
64
 
65
65
  log_path = format(log_path.to_s, prefix:) if log_path
66
66
 
67
- case content_path.extname.downcase.gsub(/^\./, "")
67
+ # Consider all extnames, after the first dot.
68
+ # E.g. "video.playwright.js" -> "playwright.js"
69
+ extname = content_path.basename.to_s.downcase.split(".")[1..].join(".")
70
+
71
+ case extname
72
+ when *ContentType.playwright
73
+ Exporter::Playwright
74
+ .new(
75
+ script_path: content_path,
76
+ options: episode.playwright_options,
77
+ log_path:,
78
+ )
79
+ .export(video_path)
68
80
  when *ContentType.video
69
81
  Exporter::Video
70
82
  .new(input_path: content_path, log_path:)
@@ -181,8 +193,16 @@ module ScreenKit
181
193
  @script_content ||= script_path.read if script_path.file?
182
194
  end
183
195
 
196
+ def overwrite_voiceover?
197
+ episode.options.overwrite || episode.options.overwrite_voiceover
198
+ end
199
+
200
+ def overwrite_content?
201
+ episode.options.overwrite || episode.options.overwrite_content
202
+ end
203
+
184
204
  def create_voiceover(log_path:)
185
- return if voiceover_path&.file? && !episode.options.overwrite
205
+ return if voiceover_path&.file? && !overwrite_voiceover?
186
206
  return unless script_path.file?
187
207
  return unless episode.tts_available?
188
208
 
@@ -1,5 +1,5 @@
1
1
  ---
2
- # yaml-language-server: $schema=https://screenkit.dev/schemas/episode.json
2
+ # yaml-language-server: $schema=https://screenkit.dev/schemas/config.json
3
3
 
4
4
  # The episode title (displayed in intro scene).
5
5
  # Line breaks are inferred based on approximate text width.
@@ -8,4 +8,4 @@ title: <%= options.title.to_yaml[4..] %>
8
8
  # Episode-specific overrides for global configuration.
9
9
  # Use an editor with JSON Schema support to see available options and
10
10
  # autocomplete.
11
- # Schema: https://screenkit.dev/schemas/episode.json
11
+ # Schema: https://screenkit.dev/schemas/config.json
@@ -0,0 +1 @@
1
+ /output
@@ -1,8 +1,7 @@
1
1
  ---
2
- # yaml-language-server: $schema=../../schemas/project.json
3
- ## yaml-language-server: $schema=https://screenkit.dev/schemas/project.json
2
+ # yaml-language-server: $schema=https://screenkit.dev/schemas/config.json
4
3
 
5
- # ScreenKit project configuration file.
4
+ # ScreenKit configuration version.
6
5
  schema: 1
7
6
 
8
7
  # The episodes directory is where the source files for each episode are stored.
@@ -155,10 +154,18 @@ scenes:
155
154
  # The background color/image of the intro scene.
156
155
  background: "#100f50"
157
156
 
157
+ # The padding around the intro.
158
+ padding: 100
159
+
160
+ # Show the url text in the intro scene.
161
+ url:
162
+ text: "https://screenkit.dev"
163
+ font_path: open-sans/OpenSans-SemiBold.ttf
164
+ size: 42
165
+ color: "#ffffff80"
166
+
158
167
  # The title text to be displayed in the intro scene.
159
168
  title:
160
- x: 100
161
- y: 300
162
169
  font_path: open-sans/OpenSans-ExtraBold.ttf
163
170
  size: 144
164
171
  color: "#ffffff"
@@ -168,8 +175,6 @@ scenes:
168
175
  # size it'll be displayed).
169
176
  logo:
170
177
  path: logo.png
171
- x: 100
172
- y: 200
173
178
  width: 300
174
179
 
175
180
  # The sound to be played along with the logo in the intro.
@@ -21,6 +21,7 @@ module ScreenKit
21
21
 
22
22
  def copy_files
23
23
  copy_file "screenkit.yml"
24
+ copy_file ".gitignore"
24
25
  directory "resources", exclude_pattern: /DS_Store/
25
26
  directory ".github", exclude_pattern: /DS_Store/
26
27
  end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenKit
4
+ module ImageMagick
5
+ # Wrap text to fit within the specified maximum width.
6
+ # @param text [String] The text to wrap.
7
+ # @param max_width [Integer] The maximum width in pixels.
8
+ # @param font_size [Integer] The font size in points.
9
+ # @return [Array<String>] The wrapped lines of text.
10
+ def text_wrap(text, max_width:, font_size:)
11
+ words = text.to_s.split(/\s+/)
12
+ width_factor = 0.6
13
+
14
+ [].tap do |lines|
15
+ words.each do |word|
16
+ line = lines.pop.to_s
17
+ word_width = word.size * (font_size * width_factor)
18
+ line_width = line.size * (font_size * width_factor)
19
+
20
+ if line_width + word_width <= max_width
21
+ line = [(line unless line.empty?), word].compact.join(" ")
22
+ lines << line
23
+ else
24
+ lines << line
25
+ lines << word
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ # Escape text for use in ImageMagick caption.
32
+ def escape_text_for_image(text)
33
+ text.gsub("'", "\\\\'")
34
+ end
35
+
36
+ # Render text into an image using MiniMagick.
37
+ # @param text [String] The text to render.
38
+ # @param style [TextStyle] The text style to apply.
39
+ # @param width [Integer] The width of the text image.
40
+ # @param type [String] The ImageMagick text type (e.g., "caption").
41
+ # @param path [String, nil] The output path.
42
+ # @return [Array] The path to the generated text image, width, and
43
+ # height.
44
+ def render_text_image(text:, style:, width:, type:, path: nil)
45
+ return [nil, 0, 0] if text.to_s.empty?
46
+
47
+ image = MiniMagick::Image.open(
48
+ create_text_image(text:, style:, width:, type:, path:)
49
+ )
50
+
51
+ [image.path, image.width, image.height]
52
+ end
53
+
54
+ # Convert values to high resolution (2x).
55
+ # @param value [Object] The value to convert.
56
+ # @return [Object] The converted value.
57
+ def hi_res(value)
58
+ case value
59
+ when Array
60
+ value.map { hi_res(it) }
61
+ when Hash
62
+ value.transform_values { hi_res(it) }
63
+ when Numeric
64
+ value * 2
65
+ else
66
+ value
67
+ end
68
+ end
69
+
70
+ # Create a text image using MiniMagick.
71
+ # @param text [String] The text to render.
72
+ # @param style [TextStyle] The text style to apply.
73
+ # @param width [Integer] The width of the text image.
74
+ # @param type [String] The ImageMagick text type (e.g., "caption").
75
+ # @param path [String, nil] The output path.
76
+ # @return [Array] The path to the generated text image, and the actual
77
+ # `Tempfile` instance.
78
+ def create_text_image(text:, style:, width:, type:, path: nil)
79
+ path ||= File.join(Dir.tmpdir, "callout-text-#{SecureRandom.hex(10)}.png")
80
+ FileUtils.mkdir_p(File.dirname(path))
81
+
82
+ MiniMagick.convert do |image|
83
+ unless type == "label"
84
+ image << "-size"
85
+ image << "#{width}x"
86
+ end
87
+
88
+ image << "-background"
89
+ image << "none"
90
+ image << "-fill"
91
+ image << style.color
92
+ image << "-font"
93
+ image << style.font_path.to_s
94
+ image << "-pointsize"
95
+ image << style.size.to_s
96
+ image << "#{type}:#{escape_text_for_image(text)}"
97
+ image << "PNG:#{path}"
98
+ end
99
+
100
+ path
101
+ rescue MiniMagick::Error => error
102
+ retry if error.message.include?("No such file or directory")
103
+ raise
104
+ end
105
+ end
106
+ end
@@ -23,11 +23,14 @@ module ScreenKit
23
23
  indexed_list.each_slice(Etc.nprocessors) do |slice|
24
24
  threads = slice.map do |args|
25
25
  thread = Thread.new do
26
+ update_message
26
27
  yield(*args.take([1, arity].max), log_path:)
27
- update_progress
28
+ mutex.synchronize { self.progress += 1 }
29
+ update_message
28
30
  end
29
31
 
30
32
  thread.abort_on_exception = true
33
+ thread.report_on_exception = false
31
34
  thread
32
35
  end
33
36
 
@@ -40,11 +43,8 @@ module ScreenKit
40
43
  ended_at - started_at
41
44
  end
42
45
 
43
- def update_progress
44
- mutex.synchronize do
45
- self.progress += 1
46
- spinner.update(format(message, count:, progress:))
47
- end
46
+ def update_message
47
+ spinner.update(format(message, count:, progress:))
48
48
  end
49
49
  end
50
50
  end
@@ -3,8 +3,14 @@
3
3
  "$id": "https://screenkit.dev/schemas/project.json",
4
4
  "title": "ScreenKit Project Configuration (screenkit.yml)",
5
5
  "type": "object",
6
- "required": ["schema", "resources_dir", "episode_dir", "output_dir"],
6
+ "required": ["schema"],
7
7
  "properties": {
8
+ "title": {
9
+ "type": "string",
10
+ "description": "The episode title",
11
+ "minLength": 1,
12
+ "pattern": "\\S"
13
+ },
8
14
  "resources_dir": { "$ref": "refs/directory.json" },
9
15
  "backtrack": { "$ref": "refs/sound.json" },
10
16
  "tts": { "$ref": "refs/tts.json" },
@@ -31,7 +37,8 @@
31
37
  "$ref": "refs/callout_style.json"
32
38
  }
33
39
  },
34
- "demotape": { "$ref": "refs/demotape.json" }
40
+ "demotape": { "$ref": "refs/demotape.json" },
41
+ "playwright": { "$ref": "refs/playwright.json" }
35
42
  },
36
43
  "additionalProperties": false
37
44
  }
@@ -7,12 +7,16 @@
7
7
  "fade_in": { "$ref": "duration.json" },
8
8
  "fade_out": { "$ref": "duration.json" },
9
9
  "title": {
10
- "type": "object",
11
10
  "description": "Title configuration",
12
- "allOf": [{ "$ref": "text_style.json" }, { "$ref": "position.json" }]
11
+ "$ref": "text_style.json"
13
12
  },
14
13
  "background": { "$ref": "background.json" },
15
14
  "logo": { "$ref": "logo.json" },
16
- "sound": { "$ref": "sound.json" }
15
+ "sound": { "$ref": "sound.json" },
16
+ "padding": { "$ref": "spacing.json" },
17
+ "url": {
18
+ "description": "URL configuration",
19
+ "$ref": "text_style.json"
20
+ }
17
21
  }
18
22
  }
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-04/schema#",
3
+ "$id": "https://screenkit.dev/schemas/refs/playwright.json",
4
+ "title": "playwright-video settings (https://github.com/fnando/playwright-video)",
5
+ "type": "object",
6
+ "properties": {
7
+ "color_scheme": {
8
+ "type": "string",
9
+ "description": "Color scheme to use when capturing the video.",
10
+ "enum": ["light", "dark"]
11
+ }
12
+ }
13
+ }
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenKit
4
+ class TextStyle
5
+ attr_reader :color, :size, :font_path, :align
6
+
7
+ def initialize(source:, **kwargs)
8
+ @source = source
9
+
10
+ kwargs.each do |key, value|
11
+ value = case key.to_sym
12
+ when :font_path
13
+ @source.search(value)
14
+ else
15
+ value
16
+ end
17
+
18
+ instance_variable_set(:"@#{key}", value)
19
+ end
20
+ end
21
+
22
+ # Convert hex color (with optional alpha) to RGB + opacity
23
+ # #RRGGBB or #RRGGBBAA
24
+ def rgb_color
25
+ color.match(/#([0-9a-fA-F]{6})/) {|m| m[1] }
26
+ end
27
+
28
+ def opacity
29
+ if color.length == 9
30
+ color.match(/#[0-9a-fA-F]{6}([0-9a-fA-F]{2})/) do |m|
31
+ m[1].to_i(16) / 255.0
32
+ end
33
+ else
34
+ 1.0
35
+ end
36
+ end
37
+
38
+ def as_json(*)
39
+ {color:, size:, font_path:, rgb_color:, opacity:, align:}
40
+ end
41
+ end
42
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ScreenKit
4
- VERSION = "0.0.9"
4
+ VERSION = "0.0.11"
5
5
  end
data/lib/screenkit.rb CHANGED
@@ -26,14 +26,13 @@ module ScreenKit
26
26
  require_relative "screenkit/spinner"
27
27
  require_relative "screenkit/shell"
28
28
  require_relative "screenkit/http"
29
+ require_relative "screenkit/image_magick"
29
30
  require_relative "screenkit/schema_validator"
30
31
  require_relative "screenkit/generators/project"
31
32
  require_relative "screenkit/generators/episode"
32
- require_relative "screenkit/config/base"
33
- require_relative "screenkit/config/project"
34
- require_relative "screenkit/config/episode"
33
+ require_relative "screenkit/config"
35
34
  require_relative "screenkit/callout"
36
- require_relative "screenkit/callout/text_style"
35
+ require_relative "screenkit/text_style"
37
36
  require_relative "screenkit/callout/styles/base"
38
37
  require_relative "screenkit/transition"
39
38
  require_relative "screenkit/parallel_processor"
@@ -54,6 +53,7 @@ module ScreenKit
54
53
  require_relative "screenkit/exporter/segment"
55
54
  require_relative "screenkit/exporter/image"
56
55
  require_relative "screenkit/exporter/video"
56
+ require_relative "screenkit/exporter/playwright"
57
57
 
58
58
  require_files = lambda do |pattern|
59
59
  Gem.find_files_from_load_path(pattern).each do |path|