screenkit 0.0.10 → 0.0.12

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e33c3c018a611a86e9ece01bf1cac931c8df89868c515eeaf8208c11888c946
4
- data.tar.gz: 6b8c37eb2dc866aec21660cd4bb58e5641f67e6eff27cc1e5d44304c590bf699
3
+ metadata.gz: cd0de2fa10f13914e8b0f2b9d8f0f1e295e4d964d54c7e8c045f7a5dc3b7e031
4
+ data.tar.gz: e16a92ffb88856e3cf482bd9ae1eb5f99d8785a73e632e92b5ad37c688e87f5e
5
5
  SHA512:
6
- metadata.gz: 3329f278b4fce62ca42bb9d9fec7ad8ec93b62fae9f0fa949a9444e9be45b68ddf755ea6c5b536176c2031d2a9ae24fc8ed0e99c4fa8918377a319af14772c1f
7
- data.tar.gz: 8e6a1089e6b396874e60d58b84140726b45b78532cddb54871eda524fd11382e383aebd4eed00a371d14d46b9db524854a89981619eb8bf066fe1f9aadeedc6a
6
+ metadata.gz: b20e2485811c2cd0321251e2e2ea0d7f4402df4979be44bf269ea9fbe71da79d7963146ec39d5634718ef6bc6011887948acafffdcf361c0db172d81455417e6
7
+ data.tar.gz: 0465a5f3f7b623021b510cd16e8bc927c58e0db8cbc3479363c5b87f5b545d43f969866244ea862875f3f65f4596fa02b223e22fe1056afd0acc50a832c1cab0
data/CHANGELOG.md CHANGED
@@ -11,6 +11,16 @@ Prefix your message with one of the following:
11
11
  - [Security] in case of vulnerabilities.
12
12
  -->
13
13
 
14
+ ## v0.0.12
15
+
16
+ - [Fixed] Fix how configuration is merged.
17
+ - [Fixed] Fix how extensions are loaded.
18
+
19
+ ## v0.0.11
20
+
21
+ - [Added] Add support for playwright scripts, so you can have browser-based
22
+ videos.
23
+
14
24
  ## v0.0.10
15
25
 
16
26
  - [Added] Add `--overwrite-content` and `--overwrite-voiceover` to individually
data/DOCUMENTATION.md CHANGED
@@ -3,8 +3,10 @@
3
3
  **Terminal to screencast, simplified**
4
4
 
5
5
  ScreenKit is a Ruby-based tool for creating professional screencasts from
6
- terminal recordings. It automates the process of combining intro/outro scenes,
7
- terminal recordings (via [demotapes](https://github.com/fnando/demotape)),
6
+ terminal and browser recordings. It automates the process of combining
7
+ intro/outro scenes, terminal recordings (via
8
+ [demotapes](https://github.com/fnando/demotape)), browser recordings (via
9
+ [@fnando/playwright-video](https://github.com/fnando/playwright-video)),
8
10
  voiceovers, background music, callouts (lower thirds), and watermarks into
9
11
  polished video content.
10
12
 
@@ -141,7 +143,8 @@ screenkit episode new --title "My First Episode"
141
143
  This creates an episode directory with:
142
144
 
143
145
  - `config.yml` - Episode configuration
144
- - `content/` - Terminal recording files (`.tape` files)
146
+ - `content/` - Content files (`.tape` files for terminal,
147
+ `.playwright.js`/`.playwright.mjs` for browser)
145
148
  - `scripts/` - Voiceover scripts (`.txt` files)
146
149
  - `voiceovers/` - Generated voiceover audio
147
150
  - `resources/` - Episode-specific resources
@@ -258,6 +261,18 @@ resources_dir:
258
261
  - /usr/share/fonts
259
262
  ```
260
263
 
264
+ ### Playwright Configuration
265
+
266
+ Configure options for Playwright-based browser recordings:
267
+
268
+ ```yaml
269
+ playwright:
270
+ color_scheme: dark # Color scheme: "light" or "dark"
271
+ ```
272
+
273
+ For more options, see
274
+ [@fnando/playwright-video documentation](https://github.com/fnando/playwright-video).
275
+
261
276
  ### Background Music
262
277
 
263
278
  ```yaml
@@ -524,7 +539,7 @@ callout_styles:
524
539
 
525
540
  ##### Usage in episode
526
541
 
527
- ScreenKit comes with some presets where you don't need to set up anything, but
542
+ ScreenKit comes with some presets where you don't need to set up anything, but
528
543
  you can also create custom callouts.
529
544
 
530
545
  Available presets:
@@ -1001,9 +1016,9 @@ episodes/001-episode-name/
1001
1016
  │ ├── 001.yml # Callouts that will appear on segment 001
1002
1017
  │ ├── 002.yml
1003
1018
  │ └── ...
1004
- ├── content/ # Terminal recordings
1019
+ ├── content/ # Content files (recordings/scripts)
1005
1020
  │ ├── 001.tape # Demo Tape files
1006
- │ ├── 002.tape
1021
+ │ ├── 002.playwright.js # Playwright scripts
1007
1022
  │ └── ...
1008
1023
  ├── scripts/ # Voiceover scripts
1009
1024
  │ ├── 001.txt # Text for TTS
@@ -1019,7 +1034,11 @@ episodes/001-episode-name/
1019
1034
  └── fonts/
1020
1035
  ```
1021
1036
 
1022
- ### Demo Tape Files
1037
+ ### Content Files
1038
+
1039
+ ScreenKit supports multiple content formats for creating screencast segments:
1040
+
1041
+ #### Demo Tape Files
1023
1042
 
1024
1043
  ScreenKit uses [Demo Tape](https://github.com/fnando/demotape) tape files for
1025
1044
  terminal recordings:
@@ -1038,6 +1057,38 @@ paths accordingly. For instance, `episodes/001-episode-name/content/001.tape`
1038
1057
  would need to reference `../../resources/some-file` to access
1039
1058
  `resources/some-file` in the project's directory.
1040
1059
 
1060
+ #### Playwright Scripts
1061
+
1062
+ For browser-based recordings, ScreenKit supports Playwright scripts via
1063
+ [@fnando/playwright-video](https://github.com/fnando/playwright-video):
1064
+
1065
+ ```javascript
1066
+ // content/001.playwright.js
1067
+ export default async function ({ page }) {
1068
+ await page.goto("https://example.com");
1069
+ await page.click("#button");
1070
+ await page.waitForTimeout(2000);
1071
+ }
1072
+ ```
1073
+
1074
+ **Requirements:**
1075
+
1076
+ - Install the npm package: `npm install -D @fnando/playwright-video`
1077
+ - Use `.playwright.js` or `.playwright.mjs` file extension
1078
+
1079
+ **Configuration:**
1080
+
1081
+ Add Playwright options to your project configuration:
1082
+
1083
+ ```yaml
1084
+ # screenkit.yml
1085
+ playwright:
1086
+ color_scheme: dark # or "light"
1087
+ ```
1088
+
1089
+ When running Playwright scripts, the working directory will be the episode
1090
+ directory, similar to Demo Tape files.
1091
+
1041
1092
  ### Script Files
1042
1093
 
1043
1094
  Plain text files for voiceover generation:
@@ -1053,8 +1104,11 @@ Today we'll learn how to create amazing screencasts.
1053
1104
  Files are matched by number:
1054
1105
 
1055
1106
  - `content/001.tape` → `scripts/001.txt` → `voiceovers/001.:ext`
1107
+ - `content/002.playwright.js` → `scripts/002.txt` → `voiceovers/002.:ext`
1056
1108
  - Segments are processed in numerical order
1057
1109
  - Missing scripts create silent segments
1110
+ - Content files can be `.tape` (Demo Tape), `.playwright.js`/`.playwright.mjs`
1111
+ (Playwright), or media files (video/image)
1058
1112
 
1059
1113
  ---
1060
1114
 
@@ -1186,8 +1240,13 @@ When exporting an episode, ScreenKit:
1186
1240
 
1187
1241
  1. **Validates** project and episode configurations
1188
1242
  2. **Generates voiceovers** from script files (if TTS enabled)
1189
- 3. **Renders terminal recordings** from tape files using
1190
- [Demo Tape](https://github.com/fnando/demotape)
1243
+ 3. **Renders content** from:
1244
+ - Terminal recordings via [Demo Tape](https://github.com/fnando/demotape)
1245
+ (`.tape` files)
1246
+ - Browser recordings via
1247
+ [@fnando/playwright-video](https://github.com/fnando/playwright-video)
1248
+ (`.playwright.js`/`.playwright.mjs` files)
1249
+ - Media files (video/image)
1191
1250
  4. **Combines segments** with crossfade transitions
1192
1251
  5. **Adds intro/outro** scenes
1193
1252
  6. **Overlays callouts** with animations
@@ -1265,6 +1324,13 @@ bundle exec screenkit ...
1265
1324
  - For macOS `say`: Verify voice name with `say -v ?`
1266
1325
  - For `espeak`: Ensure `espeak` is installed and in PATH
1267
1326
 
1327
+ #### Playwright scripts not working
1328
+
1329
+ - Install the required npm package: `npm install -D @fnando/playwright-video`
1330
+ - Ensure `playwright-video` command is available in PATH
1331
+ - Check file extension is `.playwright.js` or `.playwright.mjs`
1332
+ - Verify Playwright browsers are installed: `npx playwright install`
1333
+
1268
1334
  ---
1269
1335
 
1270
1336
  ## Contributing
data/Dockerfile CHANGED
@@ -48,6 +48,8 @@ RUN apk add --no-cache \
48
48
  jq \
49
49
  less \
50
50
  mesa-gl \
51
+ nodejs \
52
+ npm \
51
53
  nss \
52
54
  python3 \
53
55
  py3-pip \
@@ -61,9 +63,14 @@ RUN apk add --no-cache \
61
63
 
62
64
  ARG USER=screenkit
63
65
  ENV TERM=xterm-256color
64
- ENV PATH="/venv/bin:/source/bin:/${USER}/bin:$PATH"
66
+ ENV GEM_HOME=/usr/local/bundle
67
+ ENV BUNDLE_PATH=/usr/local/bundle
68
+ ENV PATH="/usr/local/bundle/bin:/venv/bin:/source/bin:/${USER}/bin:$PATH"
65
69
  ENV CHROME_BIN=/usr/bin/chromium-browser
66
70
  ENV CHROME_PATH=/usr/lib/chromium/
71
+ ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
72
+ ENV PLAYWRIGHT_BROWSERS_PATH=0
73
+ ENV FFMPEG_BIN=/usr/bin/ffmpeg
67
74
 
68
75
  # Copy binaries and fonts from builder stages
69
76
  COPY --from=binaries /bin-download/slides /usr/local/bin/slides
@@ -85,7 +92,7 @@ RUN mkdir -p /venv && chown -R ${USER}:${USER} /venv
85
92
  RUN mkdir -p /${USER}-local && chown -R ${USER}:${USER} /${USER}-local
86
93
 
87
94
  # Install screenkit gem
88
- RUN gem install screenkit && \
95
+ RUN gem install screenkit screenkit-tts-google screenkit-tts-minimax && \
89
96
  mkdir -p /usr/share/bash-completion/completions && \
90
97
  mkdir -p /usr/share/zsh/site-functions && \
91
98
  mkdir -p /usr/share/fish/vendor_completions.d && \
@@ -96,6 +103,9 @@ RUN gem install screenkit && \
96
103
  echo 'source /usr/share/bash-completion/bash_completion' >> /etc/bash/bashrc && \
97
104
  apk del build-base
98
105
 
106
+ # Install playwright-video globally
107
+ RUN npm install -g @fnando/playwright-video
108
+
99
109
  USER ${USER}
100
110
  WORKDIR /${USER}
101
111
 
@@ -10,6 +10,7 @@ module ScreenKit
10
10
  attr_accessor :options
11
11
 
12
12
  extend SchemaValidator
13
+ include ImageMagick
13
14
 
14
15
  def initialize(source:, output_path:, log_path: nil, **options)
15
16
  @source = source
@@ -18,111 +19,11 @@ module ScreenKit
18
19
  @options = options
19
20
  end
20
21
 
21
- # Wrap text to fit within the specified maximum width.
22
- # @param text [String] The text to wrap.
23
- # @param max_width [Integer] The maximum width in pixels.
24
- # @param font_size [Integer] The font size in points.
25
- # @return [Array<String>] The wrapped lines of text.
26
- def text_wrap(text, max_width:, font_size:)
27
- words = text.to_s.split(/\s+/)
28
- width_factor = 0.6
29
-
30
- [].tap do |lines|
31
- words.each do |word|
32
- line = lines.pop.to_s
33
- word_width = word.size * (font_size * width_factor)
34
- line_width = line.size * (font_size * width_factor)
35
-
36
- if line_width + word_width <= max_width
37
- line = [(line unless line.empty?), word].compact.join(" ")
38
- lines << line
39
- else
40
- lines << line
41
- lines << word
42
- end
43
- end
44
- end
45
- end
46
-
47
- # Escape text for use in ImageMagick caption.
48
- def escape_text(text)
49
- text.gsub("'", "\\\\'")
50
- end
51
-
52
22
  # Remove a file if it exists.
53
23
  # @param path [String] The file path to remove.
54
24
  def remove_file(path)
55
25
  File.unlink(path) if path && File.exist?(path)
56
26
  end
57
-
58
- # Render text into an image using MiniMagick.
59
- # @param text [String] The text to render.
60
- # @param style [TextStyle] The text style to apply.
61
- # @param width [Integer] The width of the text image.
62
- # @param type [String] The ImageMagick text type (e.g., "caption").
63
- # @return [Array] The path to the generated text image, width, and
64
- # height.
65
- def render_text_image(text:, style:, width:, type:)
66
- return [nil, 0, 0] if text.to_s.empty?
67
-
68
- image = MiniMagick::Image.open(
69
- create_text_image(text:, style:, width:, type:)
70
- )
71
-
72
- [image.path, image.width, image.height]
73
- end
74
-
75
- # Convert values to high resolution (2x).
76
- # @param value [Object] The value to convert.
77
- # @return [Object] The converted value.
78
- def hi_res(value)
79
- case value
80
- when Array
81
- value.map { hi_res(it) }
82
- when Hash
83
- value.transform_values { hi_res(it) }
84
- when Numeric
85
- value * 2
86
- else
87
- value
88
- end
89
- end
90
-
91
- # Create a text image using MiniMagick.
92
- # @param text [String] The text to render.
93
- # @param style [TextStyle] The text style to apply.
94
- # @param width [Integer] The width of the text image.
95
- # @param type [String] The ImageMagick text type (e.g., "caption").
96
- # @return [Array] The path to the generated text image, and the actual
97
- # `Tempfile` instance.
98
- def create_text_image(text:, style:, width:, type:)
99
- hash = SecureRandom.hex(10)
100
- tmp_path = File.join(Dir.tmpdir, "callout-text-#{hash}.png")
101
- FileUtils.mkdir_p(File.dirname(tmp_path))
102
-
103
- MiniMagick.convert do |image|
104
- unless type == "label"
105
- image << "-size"
106
- image << "#{width}x"
107
- end
108
-
109
- image << "-background"
110
- image << "none"
111
- image << "-fill"
112
- image << style.color
113
- image << "-font"
114
- image << style.font_path.to_s
115
- image << "-pointsize"
116
- image << style.size.to_s
117
- image << "#{type}:#{escape_text(text)}"
118
- image << "PNG:#{tmp_path}"
119
- end
120
-
121
- tmp_path
122
- rescue MiniMagick::Error => error
123
- retry if error.message.include?("No such file or directory")
124
- raise
125
- end
126
27
  end
127
28
  end
128
29
  end
@@ -16,7 +16,7 @@ module ScreenKit
16
16
 
17
17
  no_commands do
18
18
  def config
19
- @config ||= Config::Project.load_file(options.config)
19
+ @config ||= Config.load_file(options.config)
20
20
  end
21
21
  end
22
22
  end
@@ -69,10 +69,15 @@ module ScreenKit
69
69
  def export
70
70
  puts Banner.banner if options.banner
71
71
 
72
- episode_config = File.join(options.dir, "config.yml")
72
+ project_config = if File.file?(options.config)
73
+ Config.load_yaml_file(options.config)
74
+ else
75
+ {}
76
+ end
73
77
 
78
+ episode_config = File.join(options.dir, "config.yml")
74
79
  episode_config = if File.file?(episode_config)
75
- Config::Episode.load_file(episode_config)
80
+ Config.load_yaml_file(episode_config)
76
81
  else
77
82
  {}
78
83
  end
@@ -85,8 +90,7 @@ module ScreenKit
85
90
  !options.overwrite_voiceover
86
91
 
87
92
  exporter = ScreenKit::Exporter::Episode.new(
88
- project_config: config,
89
- config: episode_config,
93
+ config: Config.load(project_config.deep_merge(episode_config)),
90
94
  options:
91
95
  )
92
96
 
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenKit
4
+ class Config
5
+ using CoreExt
6
+ extend SchemaValidator
7
+
8
+ # The episode title.
9
+ attr_reader :title
10
+
11
+ # The directory where episode source files are stored.
12
+ attr_reader :episode_dir
13
+
14
+ # The directory where resources files are stored.
15
+ attr_reader :resources_dir
16
+
17
+ # The output directory for exported files.
18
+ attr_reader :output_dir
19
+
20
+ # Callout styles
21
+ attr_reader :callout_styles
22
+
23
+ # Scene configurations
24
+ attr_reader :scenes
25
+
26
+ # TTS configuration
27
+ attr_reader :tts
28
+
29
+ # The backtrack music configuration.
30
+ attr_reader :backtrack
31
+
32
+ # The watermark configuration.
33
+ attr_reader :watermark
34
+
35
+ # The demotape configuration.
36
+ attr_reader :demotape
37
+
38
+ # The Playwright configuration.
39
+ attr_reader :playwright
40
+
41
+ def self.schema_path
42
+ @schema_path ||=
43
+ ScreenKit.root_dir.join("schemas/config.json")
44
+ end
45
+
46
+ def self.load_yaml_file(path)
47
+ unless File.file?(path)
48
+ raise FileNotFoundError, "Config file not found: #{path}"
49
+ end
50
+
51
+ template = File.read(path)
52
+ contents = ERB.new(template).result
53
+
54
+ YAML.load(contents, symbolize_names: true)
55
+ end
56
+
57
+ def self.load_file(path)
58
+ load(load_yaml_file(path))
59
+ end
60
+
61
+ def self.load(config)
62
+ validate!(config)
63
+
64
+ new(**config)
65
+ end
66
+
67
+ def initialize(**kwargs)
68
+ kwargs.each do |key, value|
69
+ value = process(key, value)
70
+ instance_variable_set(:"@#{key}", value)
71
+ end
72
+ end
73
+
74
+ def to_h
75
+ instance_variables.each_with_object({}) do |var, hash|
76
+ key = var.to_s.delete_prefix("@").to_sym
77
+ hash[key] = instance_variable_get(var).as_json
78
+ end
79
+ end
80
+
81
+ def process(key, value)
82
+ case key.to_sym
83
+ when :resources_dir
84
+ Array(value)
85
+ when /_(dir|path)$/
86
+ Pathname(value)
87
+ else
88
+ value
89
+ end
90
+ end
91
+ end
92
+ end
@@ -6,7 +6,8 @@ module ScreenKit
6
6
  def self.audio = %w[mp3 wav m4a aac aiff]
7
7
  def self.image = %w[gif jpg jpeg png tiff]
8
8
  def self.demotape = %w[tape]
9
+ def self.playwright = %w[playwright.js playwright.mjs]
9
10
 
10
- def self.all = video + image + demotape
11
+ def self.all = video + image + demotape + playwright
11
12
  end
12
13
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenKit
4
+ module CoreExt
5
+ refine NilClass do
6
+ def deep_merge(other)
7
+ {}.deep_merge(other)
8
+ end
9
+ end
10
+
11
+ refine Hash do
12
+ def deep_merge(other)
13
+ other = {} if other.nil?
14
+
15
+ merger = lambda do |_key, v1, v2|
16
+ if v1.is_a?(Hash) && v2.is_a?(Hash)
17
+ v1.merge(v2, &merger)
18
+ else
19
+ v2
20
+ end
21
+ end
22
+
23
+ merge(other, &merger)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -4,6 +4,12 @@ require "json"
4
4
 
5
5
  module ScreenKit
6
6
  module CoreExt
7
+ refine Pathname do
8
+ def as_json(*)
9
+ to_s
10
+ end
11
+ end
12
+
7
13
  refine JSON.singleton_class do
8
14
  def pretty_generate(target, *)
9
15
  super(target.as_json, *)
@@ -2,3 +2,4 @@
2
2
 
3
3
  require_relative "core_ext/string"
4
4
  require_relative "core_ext/json"
5
+ require_relative "core_ext/hash"
@@ -11,12 +11,8 @@ module ScreenKit
11
11
  # Each file will be used as a segment in the episode.
12
12
  CONTENT_PATTERN = "content/**/*.{#{ContentType.all.join(',')}}".freeze
13
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]
14
+ # The merged result of project and episode configurations.
15
+ # @return [Hash]
20
16
  attr_reader :config
21
17
 
22
18
  # The export options.
@@ -29,8 +25,7 @@ module ScreenKit
29
25
  # The logfile for logging export details.
30
26
  attr_reader :logfile
31
27
 
32
- def initialize(project_config:, config:, options:)
33
- @project_config = project_config
28
+ def initialize(config:, options:)
34
29
  @config = config
35
30
  @options = options
36
31
  @mutex = Mutex.new
@@ -44,33 +39,23 @@ module ScreenKit
44
39
  end
45
40
 
46
41
  def demotape_options
47
- (config.demotape || {}).merge(project_config.demotape || {})
42
+ config.demotape
43
+ end
44
+
45
+ def playwright_options
46
+ config.playwright
48
47
  end
49
48
 
50
49
  def tts_engine
51
50
  tts_engines.first
52
51
  end
53
52
 
54
- def tts_config
55
- @tts_config ||= begin
56
- project_tts = if project_config.tts.is_a?(Hash)
57
- [project_config.tts]
58
- else
59
- Array(project_config.tts)
60
- end
61
-
62
- episode_tts = if config.tts.is_a?(Hash)
63
- [config.tts]
64
- else
65
- Array(config.tts)
66
- end
67
-
68
- episode_tts + project_tts
69
- end
53
+ def tts_options
54
+ config.tts
70
55
  end
71
56
 
72
57
  def tts_engines
73
- @tts_engines ||= tts_config.filter_map do |opts|
58
+ @tts_engines ||= tts_options.filter_map do |opts|
74
59
  next unless opts[:enabled]
75
60
 
76
61
  api_key = options.tts_api_key
@@ -197,7 +182,7 @@ module ScreenKit
197
182
  crossfade_duration = Duration.parse(
198
183
  scenes.dig(:segment, :crossfade_duration) || 0.5
199
184
  )
200
- watermark = Watermark.new(config.watermark || project_config.watermark)
185
+ watermark = Watermark.new(config.watermark)
201
186
 
202
187
  watermark_path = if watermark.path
203
188
  source.search(watermark.path)
@@ -418,11 +403,7 @@ module ScreenKit
418
403
  def prelude
419
404
  logfile.json_log(
420
405
  :config,
421
- options.merge(
422
- pwd: Dir.pwd,
423
- episode_config: config.to_h,
424
- project_config: project_config.to_h
425
- )
406
+ options.merge(pwd: Dir.pwd, config:)
426
407
  )
427
408
 
428
409
  log(
@@ -485,7 +466,7 @@ module ScreenKit
485
466
  def output_dir
486
467
  @output_dir ||= Pathname(
487
468
  format(
488
- options.output_dir || project_config.output_dir.to_s,
469
+ options.output_dir || config.output_dir.to_s,
489
470
  episode_dirname: root_dir.basename
490
471
  )
491
472
  ).expand_path
@@ -504,11 +485,11 @@ module ScreenKit
504
485
  spinner.update("Exporting intro…")
505
486
 
506
487
  intro_config = scenes.fetch(:intro)
488
+ intro_config[:title][:text] = config.title
489
+
507
490
  log_path = logfile.create(:intro)
508
491
 
509
- Intro
510
- .new(config: intro_config, text: config.title, source:, log_path:)
511
- .export(intro_path)
492
+ Intro.new(config: intro_config, source:, log_path:).export(intro_path)
512
493
 
513
494
  spinner.stop
514
495
  end
@@ -542,7 +523,7 @@ module ScreenKit
542
523
  end
543
524
 
544
525
  def scenes
545
- @scenes ||= project_config.scenes.merge(config.scenes)
526
+ config.scenes
546
527
  end
547
528
 
548
529
  def project_root_dir
@@ -554,7 +535,7 @@ module ScreenKit
554
535
  end
555
536
 
556
537
  def resources_dir
557
- @resources_dir ||= project_config.resources_dir.map do |dir|
538
+ @resources_dir ||= config.resources_dir.map do |dir|
558
539
  path = dir
559
540
  path = File.expand_path(dir) if dir.start_with?("~")
560
541
  path = Pathname(format(path, episode_dir: root_dir))
@@ -564,7 +545,8 @@ module ScreenKit
564
545
  end
565
546
 
566
547
  def callout_styles
567
- (project_config.callout_styles || {}).merge(config.callout_styles || {})
548
+ @callout_styles ||= project_config.callout_styles
549
+ .deep_merge(config.callout_styles)
568
550
  end
569
551
 
570
552
  def output_video_path
@@ -578,14 +560,11 @@ module ScreenKit
578
560
  end
579
561
 
580
562
  def backtrack
581
- @backtrack ||=
582
- if config.backtrack
583
- Sound.new(input: config.backtrack, source:)
584
- elsif project_config.backtrack
585
- Sound.new(input: project_config.backtrack, source:)
586
- else
587
- Sound.new(input: mute_sound_path, source:)
588
- end
563
+ @backtrack ||= if config.backtrack
564
+ Sound.new(input: config.backtrack, source:)
565
+ else
566
+ Sound.new(input: mute_sound_path, source:)
567
+ end
589
568
  end
590
569
 
591
570
  def segments