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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 60085249659092fa92d7fab2c913f5e8d8fc3b8d9ce0ac467322ef590a08e5f9
4
- data.tar.gz: 318d53f5ba88c1b8f2edd6d19ac263f2fcee0df7e7b5cb639f760b867be6e4ad
3
+ metadata.gz: b46db7b67fdb83851578e20864e338d1317eb0c86b2d9498cbde1163742ad0bc
4
+ data.tar.gz: 7216d1335b3031308fffb8d65f9cb64aed60736248f1028bb1dbe802d9ca6e41
5
5
  SHA512:
6
- metadata.gz: 565c78ea1e6701ee66087d22f4e62614ab765e1e625b50bdbeb9a953b3b4d7980dc6f8e7cad87250e1760f2923b10747c83c867f59347d1b4c37089d5ac87bb0
7
- data.tar.gz: df2439bbb02d0bf47ecc80abdeafdd285f5c1f63782ce2a4411cb326a7d6b5edb6ddc79b36969555ad699439f4acea557265f52e1fc4a52be1d8e2d75e614a73
6
+ metadata.gz: 66c8b5c9cca753d9096af780a12f95e5ac30fd46bd978e6070505565c780493eded3e6bad4bc6d300f69a20cee2c2dca94ab73f8690c02572b826befa8714139
7
+ data.tar.gz: 85951fb67485665ce3898d3e1130d8363467f896474e5ed901c3b177e7cfd059fd4ed5b47043a0c598f48b4a64d627abc1179ab2cf68d865a9fd7422a009ab6c
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.11
15
+
16
+ - [Added] Add support for playwright scripts, so you can have browser-based
17
+ videos.
18
+
19
+ ## v0.0.10
20
+
21
+ - [Added] Add `--overwrite-content` and `--overwrite-voiceover` to individually
22
+ control overwriting content and voiceover when generating episodes.
23
+
14
24
  ## v0.0.9
15
25
 
16
26
  - [Added] Add social callouts.
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
@@ -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
@@ -41,6 +41,14 @@ module ScreenKit
41
41
  type: :boolean,
42
42
  default: false,
43
43
  desc: "Overwrite existing exported file"
44
+ option :overwrite_voiceover,
45
+ type: :boolean,
46
+ default: false,
47
+ desc: "Regenerate all voiceover audio files"
48
+ option :overwrite_content,
49
+ type: :boolean,
50
+ default: false,
51
+ desc: "Regenerate all content files (e.g., demo tapes)"
44
52
  option :match_segment,
45
53
  type: :string,
46
54
  desc: "Only export segments matching this string"
@@ -61,19 +69,28 @@ module ScreenKit
61
69
  def export
62
70
  puts Banner.banner if options.banner
63
71
 
64
- 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
65
77
 
78
+ episode_config = File.join(options.dir, "config.yml")
66
79
  episode_config = if File.file?(episode_config)
67
- Config::Episode.load_file(episode_config)
80
+ Config.load_yaml_file(episode_config)
68
81
  else
69
82
  {}
70
83
  end
71
84
 
72
85
  options.require.each { require(it) }
73
86
 
87
+ # Ensure overwrite is only set if other individual options aren't set.
88
+ options[:overwrite] = options.overwrite &&
89
+ !options.overwrite_content &&
90
+ !options.overwrite_voiceover
91
+
74
92
  exporter = ScreenKit::Exporter::Episode.new(
75
- project_config: config,
76
- config: episode_config,
93
+ config: Config.load(**project_config.deep_merge(**episode_config)),
77
94
  options:
78
95
  )
79
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,12 +25,13 @@ 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
37
32
  @logfile = Logfile.new(output_dir.join("logs"))
33
+
34
+ Thread.report_on_exception = false
38
35
  end
39
36
 
40
37
  def tts_available?
@@ -42,33 +39,23 @@ module ScreenKit
42
39
  end
43
40
 
44
41
  def demotape_options
45
- (config.demotape || {}).merge(project_config.demotape || {})
42
+ config.demotape
43
+ end
44
+
45
+ def playwright_options
46
+ config.playwright
46
47
  end
47
48
 
48
49
  def tts_engine
49
50
  tts_engines.first
50
51
  end
51
52
 
52
- def tts_config
53
- @tts_config ||= begin
54
- project_tts = if project_config.tts.is_a?(Hash)
55
- [project_config.tts]
56
- else
57
- Array(project_config.tts)
58
- end
59
-
60
- episode_tts = if config.tts.is_a?(Hash)
61
- [config.tts]
62
- else
63
- Array(config.tts)
64
- end
65
-
66
- episode_tts + project_tts
67
- end
53
+ def tts_options
54
+ config.tts
68
55
  end
69
56
 
70
57
  def tts_engines
71
- @tts_engines ||= tts_config.filter_map do |opts|
58
+ @tts_engines ||= tts_options.filter_map do |opts|
72
59
  next unless opts[:enabled]
73
60
 
74
61
  api_key = options.tts_api_key
@@ -106,6 +93,10 @@ module ScreenKit
106
93
  def export
107
94
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
108
95
 
96
+ Signal.trap("INT", "EXIT") do
97
+ exit 1
98
+ end
99
+
109
100
  cleanup_output_dir
110
101
  prelude
111
102
  create_output_dir
@@ -191,7 +182,7 @@ module ScreenKit
191
182
  crossfade_duration = Duration.parse(
192
183
  scenes.dig(:segment, :crossfade_duration) || 0.5
193
184
  )
194
- watermark = Watermark.new(config.watermark || project_config.watermark)
185
+ watermark = Watermark.new(config.watermark)
195
186
 
196
187
  watermark_path = if watermark.path
197
188
  source.search(watermark.path)
@@ -412,11 +403,7 @@ module ScreenKit
412
403
  def prelude
413
404
  logfile.json_log(
414
405
  :config,
415
- options.merge(
416
- pwd: Dir.pwd,
417
- episode_config: config.to_h,
418
- project_config: project_config.to_h
419
- )
406
+ options.merge(pwd: Dir.pwd, config:)
420
407
  )
421
408
 
422
409
  log(
@@ -479,7 +466,7 @@ module ScreenKit
479
466
  def output_dir
480
467
  @output_dir ||= Pathname(
481
468
  format(
482
- options.output_dir || project_config.output_dir.to_s,
469
+ options.output_dir || config.output_dir.to_s,
483
470
  episode_dirname: root_dir.basename
484
471
  )
485
472
  ).expand_path
@@ -498,11 +485,11 @@ module ScreenKit
498
485
  spinner.update("Exporting intro…")
499
486
 
500
487
  intro_config = scenes.fetch(:intro)
488
+ intro_config[:title][:text] = config.title
489
+
501
490
  log_path = logfile.create(:intro)
502
491
 
503
- Intro
504
- .new(config: intro_config, text: config.title, source:, log_path:)
505
- .export(intro_path)
492
+ Intro.new(config: intro_config, source:, log_path:).export(intro_path)
506
493
 
507
494
  spinner.stop
508
495
  end
@@ -536,7 +523,7 @@ module ScreenKit
536
523
  end
537
524
 
538
525
  def scenes
539
- @scenes ||= project_config.scenes.merge(config.scenes)
526
+ config.scenes
540
527
  end
541
528
 
542
529
  def project_root_dir
@@ -548,7 +535,7 @@ module ScreenKit
548
535
  end
549
536
 
550
537
  def resources_dir
551
- @resources_dir ||= project_config.resources_dir.map do |dir|
538
+ @resources_dir ||= config.resources_dir.map do |dir|
552
539
  path = dir
553
540
  path = File.expand_path(dir) if dir.start_with?("~")
554
541
  path = Pathname(format(path, episode_dir: root_dir))
@@ -558,7 +545,8 @@ module ScreenKit
558
545
  end
559
546
 
560
547
  def callout_styles
561
- (project_config.callout_styles || {}).merge(config.callout_styles || {})
548
+ @callout_styles ||= project_config.callout_styles
549
+ .deep_merge(config.callout_styles)
562
550
  end
563
551
 
564
552
  def output_video_path
@@ -572,14 +560,11 @@ module ScreenKit
572
560
  end
573
561
 
574
562
  def backtrack
575
- @backtrack ||=
576
- if config.backtrack
577
- Sound.new(input: config.backtrack, source:)
578
- elsif project_config.backtrack
579
- Sound.new(input: project_config.backtrack, source:)
580
- else
581
- Sound.new(input: mute_sound_path, source:)
582
- 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
583
568
  end
584
569
 
585
570
  def segments