screenkit 0.0.6 → 0.0.8

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: 97a973d1c6ae7d7c373be709a2233a0b935fae81c706f312c03664e730088780
4
- data.tar.gz: 2bf3c1093ee252e020635ef4f98632975eab1c77436e0769d9b2a3c107cd228c
3
+ metadata.gz: 9432b0beeacae6f145c48e9935c18d92c62c08c69ea7177a3d282db689b0d9d5
4
+ data.tar.gz: 9cbf970a8998f0cb3bd5778489faa0ecc521b800862c1d8c8112cb11e0232bcd
5
5
  SHA512:
6
- metadata.gz: 78c95717248ebfa4fab374c612656813bdf6cd0add77dd1c4dfdc65505033c793b73c1645daeb5742670dfc0e6e5aef84100fabd5ec8cc15a011e95d88fa62e0
7
- data.tar.gz: 1cca22fd0d8f922fc245be632fcbf587968dc99c402615fe8691eb1e1c45415583f8c1fd2f00c9898c3855a14fd6efe3803c3601102fe8965d563288b5acb4c8
6
+ metadata.gz: 4b952dc0d719ccdf1496b2ee0cb430e9ba8271e5e9373f1859d8132ed68febf86daa9ff063cbc05a75a314de32d8a279eb4336021344caf70e9cc297e8eaac37
7
+ data.tar.gz: 4660532603407564ab3315a0d21d6af592d5fad3179cbd7bafade4438e97708a95ba2d9eab34fd61507aee02b58c90bed5d487f7affa4951351a1d3442c37d46
@@ -7,7 +7,7 @@ on:
7
7
  paths:
8
8
  - Dockerfile
9
9
  tags:
10
- - v*
10
+ - v[0-9]+.[0-9]+.[0-9]+
11
11
  workflow_dispatch:
12
12
  inputs:
13
13
  ref:
@@ -23,7 +23,7 @@ jobs:
23
23
  build:
24
24
  runs-on: ubuntu-latest
25
25
  steps:
26
- - uses: actions/checkout@v4
26
+ - uses: actions/checkout@v6
27
27
  with:
28
28
  ref: ${{ github.event.inputs.ref }}
29
29
 
data/CHANGELOG.md CHANGED
@@ -11,6 +11,14 @@ Prefix your message with one of the following:
11
11
  - [Security] in case of vulnerabilities.
12
12
  -->
13
13
 
14
+ # v0.0.8
15
+
16
+ - [Fixed] Further improvements to support extensions.
17
+
18
+ ## v0.0.7
19
+
20
+ - [Fixed] Improve support for extensions.
21
+
14
22
  ## v0.0.6
15
23
 
16
24
  - [Added] Add `--tts-preset` option to select TTS preset when exporting
data/DOCUMENTATION.md CHANGED
@@ -719,7 +719,9 @@ tts:
719
719
 
720
720
  ### ElevenLabs Engine
721
721
 
722
- Professional AI voice synthesis.
722
+ The ElevenLabs TTS engine requires an API key. Set it via the `--tts-api-key`
723
+ option when exporting an episode. The API key must be prefixed with
724
+ `eleven_labs:`, e.g. `eleven_labs:sk_c195c0131de...`.
723
725
 
724
726
  ```yaml
725
727
  tts:
@@ -764,32 +766,50 @@ module. Custom engines must implement the `generate` method:
764
766
  module ScreenKit
765
767
  module TTS
766
768
  class CustomEngine < Base
767
- include Shell
768
-
769
769
  # Optional: Define schema path for validation
770
770
  def self.schema_path
771
- ScreenKit.root_dir.join("screenkit/schemas/tts/custom_engine.json")
771
+ File.join(__dir__, "yourschema.json")
772
772
  end
773
773
 
774
774
  # This method is required.
775
- def available?
776
- enabled? && command_exist?("some-command")
775
+ # The keyword arguments will be all the configuration options, plus
776
+ # api_key and segments. If you don't care about those, remember to use
777
+ # the `**` operator to ignore them.
778
+ def self.available?(**)
779
+ # If you're running a local command:
780
+ # command_exist?("some-command")
781
+
782
+ # If you're using an API key:
783
+ # api_key.to_s.start_with?("#{engine_name}:")
777
784
  end
778
785
 
779
786
  # This method is required.
780
787
  def generate(text:, output_path:, log_path: nil)
781
- # Optional: validate options against JSON schema.
788
+ # Optional, but recommended: validate options against JSON schema.
782
789
  self.class.validate!(options)
783
790
 
791
+ # If you need access to previous/next text, you can access the method
792
+ # `segments`.
793
+ # current_index = segments.index { it.script_content == text }
794
+ # next_text = segments[current_index + 1]&.script_content
795
+
784
796
  # Generate audio file from text
785
797
  # Write output to output_path
786
798
  # Optionally log to log_path
787
799
 
788
- # Example implementation:
789
- # run_command "some-command",
790
- # "-o", output_path.sub_ext(".wav"),
791
- # text,
792
- # log_path:
800
+ # Example calling a command (provided by ScreenKit::Shell)
801
+ # self.class.run_command "some-command",
802
+ # "-o", output_path.sub_ext(".wav"),
803
+ # text,
804
+ # log_path:
805
+
806
+ # Example using an API (provided by ScreenKit::HTTP)
807
+ # response = json_post(
808
+ # url: "https://api.example.com/tts",
809
+ # headers: {authorization: "Bearer #{api_key}"},
810
+ # api_key:,
811
+ # log_path:
812
+ # )
793
813
  end
794
814
  end
795
815
  end
@@ -802,6 +822,7 @@ end
802
822
  tts:
803
823
  - id: custom_engine
804
824
  engine: custom_engine # Camelized to CustomEngine
825
+ enabled: true
805
826
  # Add your custom options here
806
827
  api_key: your_api_key
807
828
  custom_option: value
@@ -811,6 +832,17 @@ The engine name is camelized (e.g., `custom_engine` → `CustomEngine`,
811
832
  `google_cloud` → `GoogleCloud`) and loaded as
812
833
  `ScreenKit::TTS::#{CamelizedName}`.
813
834
 
835
+ ### 3rd-party TTS Engines
836
+
837
+ - [Search Github](https://github.com/topics/screenkit-tts)
838
+ - [Google Text to Speech](https://github.com/fnando/screenkit-tts-google)
839
+ - [Minimax Text to Speech](https://github.com/fnando/screenkit-tts-minimax)
840
+
841
+ > [!TIP]
842
+ >
843
+ > If you host your TTS engine on Github, use the topic `screekit-tts`, so other
844
+ > people can find it.
845
+
814
846
  ---
815
847
 
816
848
  ## Animations
@@ -897,6 +929,12 @@ Enter
897
929
  Sleep 2s
898
930
  ```
899
931
 
932
+ When running tape files, the working directory will be the episode directory. If
933
+ you're importing anything from the parent directory, you must specify relative
934
+ paths accordingly. For instance, `episodes/001-episode-name/content/001.tape`
935
+ would need to reference `../../resources/some-file` to access
936
+ `resources/some-file` in the project's directory.
937
+
900
938
  ### Script Files
901
939
 
902
940
  Plain text files for voiceover generation:
@@ -1099,26 +1137,26 @@ be processed).
1099
1137
 
1100
1138
  ### Common Issues
1101
1139
 
1102
- **"Gem not found" error:**
1140
+ #### "Gem not found" error
1103
1141
 
1104
1142
  ```bash
1105
1143
  bundle install
1106
1144
  bundle exec screenkit ...
1107
1145
  ```
1108
1146
 
1109
- **"Schema validation failed":**
1147
+ #### Schema validation failed
1110
1148
 
1111
1149
  - Check YAML syntax
1112
1150
  - Verify required fields are present
1113
1151
  - Use schema hints with `yaml-language-server`
1114
1152
 
1115
- **Missing resources:**
1153
+ #### Missing resources
1116
1154
 
1117
1155
  - Check `resources_dir` configuration
1118
1156
  - Verify file paths are relative to resource directories
1119
1157
  - Use absolute paths for system resources
1120
1158
 
1121
- **TTS not working:**
1159
+ #### TTS not working
1122
1160
 
1123
1161
  - For ElevenLabs: Set `--tts-api-key`
1124
1162
  - For macOS `say`: Verify voice name with `say -v ?`
data/action.yml ADDED
@@ -0,0 +1,140 @@
1
+ ---
2
+ name: ScreenKit Episode
3
+ description: Export a screencast episode using Docker
4
+ inputs:
5
+ episode:
6
+ description: "Episode number (e.g. 001)"
7
+ required: true
8
+ tts_preset:
9
+ description: "TTS preset name"
10
+ default: ""
11
+ required: false
12
+ tts_api_key:
13
+ description: "TTS API key"
14
+ required: false
15
+ overwrite:
16
+ description: "Regenerate files"
17
+ default: "false"
18
+ required: false
19
+ match:
20
+ description: "Match pattern for segments (e.g. 001)"
21
+ default: ""
22
+ required: false
23
+ github_token:
24
+ description: "GitHub token for cache operations"
25
+ required: true
26
+ retention:
27
+ description: "Retention days for artifacts"
28
+ default: "2"
29
+
30
+ runs:
31
+ using: composite
32
+ steps:
33
+ - name: Generate cache keys
34
+ shell: bash
35
+ run: |
36
+ VOICEOVER_HASH=$(find episodes/${{ inputs.episode }}-*/scripts/*.txt -type f 2>/dev/null | sort | xargs sha256sum 2>/dev/null | sha256sum | cut -d' ' -f1 || echo "none")
37
+ CONTENT_HASH=$(find episodes/${{ inputs.episode }}-*/*/*.* -type f 2>/dev/null | sort | xargs sha256sum 2>/dev/null | sha256sum | cut -d' ' -f1 || echo "none")
38
+
39
+ echo "VOICEOVER_CACHE_KEY=episode-voiceover-${{ inputs.episode }}-${VOICEOVER_HASH}" >> $GITHUB_ENV
40
+ echo "VOICEOVER_RESTORE_KEY=episode-voiceover-${{ inputs.episode }}-" >> $GITHUB_ENV
41
+ echo "VIDEO_CACHE_KEY=episode-${{ inputs.episode }}-${CONTENT_HASH}" >> $GITHUB_ENV
42
+ echo "VIDEO_RESTORE_KEY=episode-${{ inputs.episode }}-" >> $GITHUB_ENV
43
+
44
+ - name: Find episode dir
45
+ shell: bash
46
+ run: |
47
+ EPISODE_DIR=$(ls -1d episodes/${{ inputs.episode }}-* | head -n 1)
48
+ echo "EPISODE_DIR=$EPISODE_DIR" >> $GITHUB_ENV
49
+
50
+ if [[ ! -d "$EPISODE_DIR" ]]; then
51
+ echo "Episode directory not found!"
52
+ echo "Available episodes:"
53
+ ls -1 episodes
54
+ exit 1
55
+ fi
56
+
57
+ - name: Restore episode voice cache
58
+ id: cache-voice
59
+ uses: actions/cache/restore@v4
60
+ with:
61
+ path: episodes/${{ inputs.episode }}-*/voiceovers
62
+ key: ${{ env.VOICEOVER_CACHE_KEY }}
63
+ restore-keys: ${{ env.VOICEOVER_RESTORE_KEY }}
64
+ enableCrossOsArchive: true
65
+
66
+ - name: Restore videos cache
67
+ id: cache-videos
68
+ uses: actions/cache/restore@v4
69
+ with:
70
+ path: output/${{ inputs.episode }}-*/videos/*.mp4
71
+ key: ${{ env.VIDEO_CACHE_KEY }}
72
+ restore-keys: ${{ env.VIDEO_RESTORE_KEY }}
73
+ enableCrossOsArchive: true
74
+
75
+ - name: Export Episode
76
+ shell: bash
77
+ id: screenkit
78
+ run: |
79
+ set -e
80
+ docker run \
81
+ --rm \
82
+ --cap-add=SYS_ADMIN \
83
+ --shm-size=2g \
84
+ --security-opt seccomp=unconfined \
85
+ -v ${{ github.workspace }}:/source \
86
+ fnando/screenkit:latest \
87
+ episode export \
88
+ --dir="${{ env.EPISODE_DIR }}" \
89
+ ${{ inputs.match != '' && format('--match {0}', inputs.match) || '' }} \
90
+ ${{ inputs.overwrite == 'true' && '--overwrite' || '--no-overwrite' }} \
91
+ ${{ inputs.tts_preset != '' && format('--tts-preset {0}', inputs.tts_preset) || '' }} \
92
+ ${{ inputs.tts_api_key != '' && format('--tts-api-key {0}', inputs.tts_api_key) || '' }}
93
+ echo "result=true" >> $GITHUB_OUTPUT
94
+
95
+ - name: Delete voice cache
96
+ if: steps.screenkit.outputs.result == 'true'
97
+ shell: bash
98
+ run: gh cache delete "${{ env.VOICEOVER_CACHE_KEY }}" || true
99
+ env:
100
+ GH_TOKEN: ${{ inputs.github_token }}
101
+
102
+ - name: Delete videos cache
103
+ if: steps.screenkit.outputs.result == 'true'
104
+ shell: bash
105
+ run: gh cache delete "${{ env.VIDEO_CACHE_KEY }}" || true
106
+ env:
107
+ GH_TOKEN: ${{ inputs.github_token }}
108
+
109
+ - name: List cache entries
110
+ shell: bash
111
+ run: |
112
+ echo "= Voiceover Cache ="
113
+ ls -la episodes/${{ inputs.episode }}-*/voiceovers || true
114
+ echo
115
+ echo "= Videos Cache ="
116
+ ls -la output/${{ inputs.episode }}-*/videos || true
117
+
118
+ - name: Save episode voiceover cache
119
+ if: steps.screenkit.outputs.result == 'true'
120
+ uses: actions/cache/save@v4
121
+ with:
122
+ path: episodes/${{ inputs.episode }}-*/voiceovers
123
+ key: ${{ env.VOICEOVER_CACHE_KEY }}
124
+ enableCrossOsArchive: true
125
+
126
+ - name: Save video recordings cache
127
+ if: steps.screenkit.outputs.result == 'true'
128
+ uses: actions/cache/save@v4
129
+ with:
130
+ path: output/${{ inputs.episode }}-*/videos/*.mp4
131
+ key: ${{ env.VIDEO_CACHE_KEY }}
132
+ enableCrossOsArchive: true
133
+
134
+ - name: Upload output artifacts
135
+ uses: actions/upload-artifact@v5
136
+ if: always()
137
+ with:
138
+ name: output
139
+ path: output
140
+ retention-days: ${{ inputs.retention }}
@@ -4,6 +4,8 @@ module ScreenKit
4
4
  class Callout
5
5
  module Styles
6
6
  class Base
7
+ require_relative "../../schema_validator"
8
+
7
9
  attr_reader :source, :output_path, :log_path
8
10
  attr_accessor :options
9
11
 
@@ -16,6 +18,11 @@ module ScreenKit
16
18
  @options = options
17
19
  end
18
20
 
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.
19
26
  def text_wrap(text, max_width:, font_size:)
20
27
  words = text.to_s.split(/\s+/)
21
28
  width_factor = 0.6
@@ -42,10 +49,19 @@ module ScreenKit
42
49
  text.gsub("'", "\\\\'")
43
50
  end
44
51
 
52
+ # Remove a file if it exists.
53
+ # @param path [String] The file path to remove.
45
54
  def remove_file(path)
46
55
  File.unlink(path) if path && File.exist?(path)
47
56
  end
48
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.
49
65
  def render_text_image(text:, style:, width:, type:)
50
66
  return [nil, 0, 0] if text.to_s.empty?
51
67
 
@@ -61,9 +61,13 @@ module ScreenKit
61
61
  def export
62
62
  puts Banner.banner if options.banner
63
63
 
64
- episode_config = Config::Episode.load_file(
65
- File.join(options.dir, "config.yml")
66
- )
64
+ episode_config = File.join(options.dir, "config.yml")
65
+
66
+ episode_config = if File.file?(episode_config)
67
+ Config::Episode.load_file(episode_config)
68
+ else
69
+ {}
70
+ end
67
71
 
68
72
  options.require.each { require(it) }
69
73
 
@@ -4,7 +4,7 @@ module ScreenKit
4
4
  module ContentType
5
5
  def self.video = %w[mp4 webm mov]
6
6
  def self.audio = %w[mp3 wav m4a aac aiff]
7
- def self.image = %w[gif jpg jpeg png]
7
+ def self.image = %w[gif jpg jpeg png tiff]
8
8
  def self.demotape = %w[tape]
9
9
 
10
10
  def self.all = video + image + demotape
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module ScreenKit
4
6
  module CoreExt
5
7
  refine JSON.singleton_class do
@@ -7,6 +7,8 @@ module ScreenKit
7
7
  unicode_normalize(:nfkd)
8
8
  .delete("'")
9
9
  .gsub(/[^\x00-\x7F]/, "")
10
+ .gsub(/([a-z\d])([A-Z])/, '\1-\2')
11
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2')
10
12
  .gsub(/[^-\w]+/xim, "-")
11
13
  .tr("_", "-")
12
14
  .gsub(/-+/xm, "-")
@@ -14,6 +16,10 @@ module ScreenKit
14
16
  .downcase
15
17
  end
16
18
 
19
+ def underscore
20
+ dasherize.tr("-", "_")
21
+ end
22
+
17
23
  def camelize(first_letter = :upper)
18
24
  split(/_|-/).map.with_index do |part, index|
19
25
  if index.zero? && first_letter == :lower
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "core_ext/string"
4
+ require_relative "core_ext/json"
@@ -27,7 +27,8 @@ module ScreenKit
27
27
  "--fps", 24,
28
28
  "--overwrite",
29
29
  "--output-path", output_path,
30
- log_path:
30
+ log_path:,
31
+ chdir: demotape_path.parent.parent
31
32
  end
32
33
 
33
34
  def options_to_args(options)
@@ -46,18 +46,11 @@ module ScreenKit
46
46
  end
47
47
 
48
48
  def tts_engine
49
- @tts_engine ||=
50
- if options.tts_preset
51
- tts_engines.find do |engine|
52
- engine.id == options.tts_preset && engine.available?
53
- end
54
- else
55
- tts_engines.find(&:available?)
56
- end
49
+ tts_engines.first
57
50
  end
58
51
 
59
- def tts_engines
60
- @tts_engines ||= begin
52
+ def tts_config
53
+ @tts_config ||= begin
61
54
  project_tts = if project_config.tts.is_a?(Hash)
62
55
  [project_config.tts]
63
56
  else
@@ -70,10 +63,23 @@ module ScreenKit
70
63
  Array(config.tts)
71
64
  end
72
65
 
73
- (episode_tts + project_tts).map do |opts|
74
- TTS.const_get(opts[:engine].camelize)
75
- .new(**opts.except(:engine), api_key: options.tts_api_key)
76
- end
66
+ episode_tts + project_tts
67
+ end
68
+ end
69
+
70
+ def tts_engines
71
+ @tts_engines ||= tts_config.filter_map do |opts|
72
+ next unless opts[:enabled]
73
+
74
+ api_key = options.tts_api_key
75
+ tts_class = TTS.const_get(opts[:engine].camelize)
76
+ tts_preset = options.tts_preset.to_s
77
+
78
+ next if !tts_preset.empty? && tts_preset != opts[:id]
79
+ next unless tts_class.available?(api_key:, **opts)
80
+
81
+ opts = opts.except(:engine, :enabled)
82
+ tts_class.new(**opts, api_key:, segments:)
77
83
  end
78
84
  end
79
85
 
@@ -177,6 +177,10 @@ module ScreenKit
177
177
  log_path:
178
178
  end
179
179
 
180
+ def script_content
181
+ @script_content ||= script_path.read if script_path.file?
182
+ end
183
+
180
184
  def create_voiceover(log_path:)
181
185
  return if voiceover_path&.file? && !episode.options.overwrite
182
186
  return unless script_path.file?
@@ -185,7 +189,7 @@ module ScreenKit
185
189
  FileUtils.mkdir_p(voiceover_path.dirname)
186
190
 
187
191
  episode.tts_engine.generate(
188
- text: script_path.read,
192
+ text: script_content,
189
193
  output_path: voiceover_path,
190
194
  log_path:
191
195
  )
@@ -9,8 +9,12 @@ module ScreenKit
9
9
  # The path to the input video file.
10
10
  attr_reader :input_path
11
11
 
12
- def initialize(input_path:)
12
+ # The path to the log file.
13
+ attr_reader :log_path
14
+
15
+ def initialize(input_path:, log_path: nil)
13
16
  @input_path = input_path
17
+ @log_path = log_path
14
18
  end
15
19
 
16
20
  def export(output_path)
@@ -26,7 +30,8 @@ module ScreenKit
26
30
  "-r", "24",
27
31
  "-c:v", "libx264",
28
32
  "-y",
29
- output_path
33
+ output_path,
34
+ log_path:
30
35
  end
31
36
  end
32
37
  end
@@ -1,106 +1,11 @@
1
1
  ---
2
2
  # yaml-language-server: $schema=https://screenkit.dev/schemas/episode.json
3
3
 
4
- # The episode title. When there are no linebreaks, they will be inferred based
5
- # on the approximate text width.
6
- title: "CREATING SCREENCASTS WITH SCREENKIT"
7
-
8
- # Callouts are informational boxes that on the screen during the
9
- # video (also known as lower thirds).
10
- #
11
- # The project's configuration can define the callout structure, while the
12
- # episode configuration can define the content and timing.
13
- callouts_styles:
14
- - type: info
15
- title: "ScreenKit"
16
- body: "Visit https://github.com/fnando/screenkit to learn more."
17
- starts_at: 3
18
- duration: 5
19
- width: 600
20
-
21
- # The background music to be played during the episode.
22
- # When not set, the backtrack will be defined by using the project's
23
- # `backtrack_dir` configuration, and a track will be randomly selected.
24
- # To disable the backtrack entirely, use `backtrack: false`.
25
- # backtrack: resources/backtrack.mp3
26
-
27
- # Define episode-specific scene configurations.
28
- # When present, this will have higher precedence over the project's
29
- # configuration, so you can have different intro/outro for each episode.
30
- # scenes:
31
- # intro:
32
- # # The duration of the intro scene in seconds.
33
- # duration: 5.5
34
- #
35
- # # The fade-in duration in seconds.
36
- # fade_in: 0
37
- #
38
- # # The fade-out duration in seconds.
39
- # fade_out: 0.5
40
- #
41
- # # The background color/image of the intro scene.
42
- # background: "#100f50"
43
- #
44
- # # The title text to be displayed in the intro scene.
45
- # title:
46
- # x: 100
47
- # y: 300
48
- # font_path: open-sans/OpenSans-ExtraBold.ttf
49
- # size: 144
50
- # color: "#ffffff"
51
- #
52
- # # The logo to be displayed in the video intro.
53
- # # Works best with a transparent PNG file in high resolution (at least 2x the
54
- # # size it'll be displayed).
55
- # logo:
56
- # path: images/logo.png
57
- # x: 100
58
- # y: 200
59
- # width: 200
60
- # height: 34
61
- #
62
- # # The sound to be played along with the logo in the intro.
63
- # # This must correspond to a sound file in the sounds directory, with any
64
- # # audio extension (e.g. mp3, m4a, wav).
65
- # sound: sounds/chime.mp3
66
- #
67
- # outro:
68
- # # The duration of the outro scene in seconds.
69
- # duration: 3.5
70
- #
71
- # # The fade-in duration in seconds.
72
- # fade_in: 0.5
73
- #
74
- # # The fade-out duration in seconds.
75
- # fade_out: 0.5
76
- #
77
- # # The background color/image of the outro scene.
78
- # background: "#100f50"
79
- #
80
- # # The sound to be played along with the logo in the outro.
81
- # sound: sounds/chime.mp3
82
- #
83
- # # The logo to be displayed in the video outro.
84
- # logo:
85
- # path: images/logo.png
86
- # x: center
87
- # y: center
88
- # width: 500
89
- #
90
- # segment:
91
- # # The duration of the crossover transition between segments in seconds.
92
- # crossfade_duration: 0.5
93
-
94
- # Define episode-specific voice configuration.
95
- # When present, this will have higher precedence over the project's
96
- # configuration, so you can have different intro/outro segments for each
97
- # episode.
98
- # tts:
99
- # engine: elevenlabs
100
- # voice_id: 56AoDkrOh6qfVPDXZ7Pt
101
- # language_code: en
102
- # voice_settings:
103
- # speed: 0.9
104
- # stability: 0.5
105
- # similarity: 0.75
106
- # style: 0.0
4
+ # The episode title (displayed in intro scene).
5
+ # Line breaks are inferred based on approximate text width.
6
+ title: <%= options.title.to_yaml[4..] %>
7
+
8
+ # Episode-specific overrides for global configuration.
9
+ # Use an editor with JSON Schema support to see available options and
10
+ # autocomplete.
11
+ # Schema: https://screenkit.dev/schemas/episode.json
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: Screenkit
3
+
4
+ on:
5
+ workflow_dispatch:
6
+ inputs:
7
+ episode:
8
+ type: string
9
+ description: "Episode number (e.g. 001)"
10
+ required: true
11
+ tts_preset:
12
+ description: "TTS preset name"
13
+ default: ""
14
+ type: choice
15
+ options:
16
+ - ""
17
+ - say
18
+ - espeak
19
+ - eleven_labs
20
+ - eleven_labs_mp3_192k
21
+ match:
22
+ type: string
23
+ description: "Match pattern for segments (e.g. 001)"
24
+ default: ""
25
+ overwrite:
26
+ type: boolean
27
+ description: "Regenerate files"
28
+
29
+ jobs:
30
+ screenkit:
31
+ runs-on: ubuntu-latest
32
+ permissions:
33
+ contents: read
34
+ actions: write
35
+
36
+ steps:
37
+ - name: Checkout repository
38
+ uses: actions/checkout@v3
39
+
40
+ - name: Run Screenkit
41
+ uses: fnando/screenkit@v1
42
+ with:
43
+ episode: ${{ github.event.inputs.episode }}
44
+ tts_preset: ${{ github.event.inputs.tts_preset }}
45
+ tts_api_key: ${{ secrets.TTS_API_KEY }}
46
+ overwrite: ${{ github.event.inputs.overwrite }}
47
+ match: ${{ github.event.inputs.match }}
48
+ github_token: ${{ secrets.GITHUB_TOKEN }}
@@ -22,6 +22,7 @@ module ScreenKit
22
22
  def copy_files
23
23
  copy_file "screenkit.yml"
24
24
  directory "resources", exclude_pattern: /DS_Store/
25
+ directory ".github", exclude_pattern: /DS_Store/
25
26
  end
26
27
 
27
28
  def bundle_install
@@ -32,6 +33,12 @@ module ScreenKit
32
33
  end
33
34
  end
34
35
 
36
+ def instructions
37
+ cmd = set_color("screenkit episode new --title TITLE", :blue)
38
+ path = set_color(destination_root, :blue)
39
+ say "\nTo create a new episode, run #{cmd} from #{path}"
40
+ end
41
+
35
42
  no_commands do
36
43
  # Add helper methods here
37
44
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenKit
4
+ module HTTP
5
+ # Sends a POST request.
6
+ # @param url [String] The request URL.
7
+ # @param params [Hash] The request parameters.
8
+ # @param headers [Hash] The request headers.
9
+ # @param api_key [String] The API key to redact from logs.
10
+ # @param log_path [String, nil] The path to log the request details.
11
+ # @return [Aitch::Response] The response.
12
+ def post(url:, params:, headers:, api_key:, log_path: nil)
13
+ if log_path
14
+ File.open(log_path, "w") do |f|
15
+ f << JSON.pretty_generate(url:, params:, headers:)
16
+ end
17
+ end
18
+
19
+ client = Aitch::Namespace.new
20
+ client.configure do |config|
21
+ config.logger = Logger.new(log_path) if log_path
22
+ end
23
+
24
+ client.post(
25
+ url:,
26
+ params:,
27
+ options: {expect: 200},
28
+ headers: headers.merge(user_agent: "ScreenKit/#{ScreenKit::VERSION}")
29
+ )
30
+ ensure
31
+ redact_file(log_path, api_key)
32
+ end
33
+
34
+ # Sends a JSON POST request.
35
+ # @param url [String] The request URL.
36
+ # @param params [Hash] The request parameters.
37
+ # @param headers [Hash] The request headers.
38
+ # @param api_key [String] The API key to redact from logs.
39
+ # @param log_path [String, nil] The path to log the request details.
40
+ # @return [Aitch::Response] The response.
41
+ def json_post(headers:, **)
42
+ headers = headers.merge(content_type: "application/json")
43
+ post(headers:, **)
44
+ end
45
+
46
+ # Redacts sensitive text from a file.
47
+ # @param path [String] The file path.
48
+ # @param text [String] The text to redact.
49
+ # @return [void]
50
+ def redact_file(path, text)
51
+ return unless path
52
+ return unless File.file?(path)
53
+
54
+ content = File.read(path).gsub(text, "[REDACTED]")
55
+ File.write(path, content)
56
+ end
57
+ end
58
+ end
@@ -20,9 +20,9 @@ module ScreenKit
20
20
  !`which #{command}`.strip.empty?
21
21
  end
22
22
 
23
- def run_command(command, *args, log_path: nil)
23
+ def run_command(command, *args, log_path: nil, **)
24
24
  args = args.flatten.compact.map(&:to_s)
25
- stdout, stderr, status = Open3.capture3(command, *args)
25
+ stdout, stderr, status = Open3.capture3(command, *args, **)
26
26
  exit_code = status.exitstatus
27
27
 
28
28
  if exit_code&.nonzero?
@@ -1,9 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json-schema"
4
+ require "aitch"
5
+ require "logger"
6
+
3
7
  module ScreenKit
4
8
  module TTS
5
9
  class Base
10
+ require_relative "../schema_validator"
11
+ require_relative "../shell"
12
+ require_relative "../http"
13
+ require_relative "../core_ext"
14
+ require_relative "../version"
15
+
6
16
  extend SchemaValidator
17
+ extend Shell
18
+ include HTTP
19
+
20
+ using CoreExt
7
21
 
8
22
  # Additional options for the tts engine.
9
23
  attr_reader :options
@@ -11,14 +25,35 @@ module ScreenKit
11
25
  # The preset name for the tts engine.
12
26
  attr_reader :id
13
27
 
14
- def initialize(id: nil, enabled: true, **options)
15
- @enabled = enabled
28
+ # The list of segments.
29
+ # This is available so that engines can contextually generate audio, for
30
+ # instance, by providing previous/next text (e.g. Eleven Labs).
31
+ attr_reader :segments
32
+
33
+ # The API key for the tts engine, if applicable.
34
+ attr_reader :api_key
35
+
36
+ # Detects if the tts engine is available.
37
+ def self.available?(**)
38
+ false
39
+ end
40
+
41
+ def self.engine_name
42
+ name.split("::").last.underscore
43
+ end
44
+
45
+ def self.api_key_prefix
46
+ "#{engine_name}:"
47
+ end
48
+
49
+ def initialize(id: nil, segments: nil, api_key: nil, **options)
50
+ @segments = Array(segments)
16
51
  @options = options
17
52
  @id = id
18
- end
19
53
 
20
- def enabled?
21
- @enabled
54
+ return unless api_key
55
+
56
+ @api_key = api_key.delete_prefix("#{self.class.engine_name}:")
22
57
  end
23
58
  end
24
59
  end
@@ -3,44 +3,40 @@
3
3
  module ScreenKit
4
4
  module TTS
5
5
  class ElevenLabs < Base
6
+ include HTTP
7
+
6
8
  def self.schema_path
7
9
  ScreenKit.root_dir
8
10
  .join("screenkit/schemas/tts/elevenlabs.json")
9
11
  end
10
12
 
11
- # The Eleven Labs API key.
12
- attr_reader :api_key
13
-
14
- def initialize(api_key:, **)
15
- super(**)
16
- @api_key = api_key
13
+ def self.available?(api_key: nil, **)
14
+ api_key.to_s.start_with?(api_key_prefix)
17
15
  end
18
16
 
19
- def available?
20
- enabled? && !api_key.to_s.empty?
17
+ def all_texts
18
+ @all_texts ||= segments.map(&:script_content)
21
19
  end
22
20
 
23
21
  def generate(output_path:, text:, log_path: nil)
24
- self.class.validate!(options)
25
- voice_id = options.delete(:voice_id)
22
+ voice_id = options[:voice_id]
23
+
24
+ current_index = all_texts.index { it == text }
26
25
 
27
- if log_path
28
- File.open(log_path, "w") do |f|
29
- f << JSON.pretty_generate(options.merge(text:))
30
- end
26
+ if current_index
27
+ previous_text = all_texts[current_index - 1]
28
+ next_text = all_texts[current_index + 1]
31
29
  end
32
30
 
33
- require "aitch"
31
+ params = options.merge(text:, previous_text:, next_text:)
32
+ .except(:voice_id)
34
33
 
35
- response = Aitch.post(
34
+ response = json_post(
36
35
  url: "https://api.elevenlabs.io/v1/text-to-speech/#{voice_id}",
37
- body: JSON.dump(options.merge(text:)),
38
- options: {expect: 200},
39
- headers: {
40
- "content-type": "application/json",
41
- "user-agent": "ScreenKit/#{ScreenKit::VERSION}",
42
- "xi-api-key": api_key
43
- }
36
+ params:,
37
+ headers: {"xi-api-key": api_key},
38
+ api_key:,
39
+ log_path:
44
40
  )
45
41
 
46
42
  File.binwrite(output_path, response.body)
@@ -3,14 +3,14 @@
3
3
  module ScreenKit
4
4
  module TTS
5
5
  class Espeak < Base
6
- include Shell
6
+ extend Shell
7
7
 
8
- def self.schema_path
9
- ScreenKit.root_dir.join("screenkit/schemas/tts/espeak.json")
8
+ def self.available?(**)
9
+ command_exist?("espeak")
10
10
  end
11
11
 
12
- def available?
13
- enabled? && command_exist?("espeak")
12
+ def self.schema_path
13
+ ScreenKit.root_dir.join("screenkit/schemas/tts/espeak.json")
14
14
  end
15
15
 
16
16
  def generate(text:, output_path:, log_path: nil)
@@ -18,12 +18,12 @@ module ScreenKit
18
18
 
19
19
  {voice: nil, rate: nil}.merge(options) => {voice:, rate:}
20
20
 
21
- run_command "espeak",
22
- (["-v", voice] if voice),
23
- (["-s", rate] if rate),
24
- "-w", output_path.sub_ext(".wav"),
25
- text,
26
- log_path:
21
+ self.class.run_command "espeak",
22
+ (["-v", voice] if voice),
23
+ (["-s", rate] if rate),
24
+ "-w", output_path.sub_ext(".wav"),
25
+ text,
26
+ log_path:
27
27
  end
28
28
  end
29
29
  end
@@ -3,14 +3,14 @@
3
3
  module ScreenKit
4
4
  module TTS
5
5
  class Say < Base
6
- include Shell
6
+ extend Shell
7
7
 
8
8
  def self.schema_path
9
9
  ScreenKit.root_dir.join("screenkit/schemas/tts/say.json")
10
10
  end
11
11
 
12
- def available?
13
- enabled? && command_exist?("say")
12
+ def self.available?(**)
13
+ command_exist?("say")
14
14
  end
15
15
 
16
16
  def generate(text:, output_path:, log_path: nil)
@@ -18,12 +18,12 @@ module ScreenKit
18
18
 
19
19
  {voice: nil, rate: nil}.merge(options) => {voice:, rate:}
20
20
 
21
- run_command "say",
22
- (["-v", voice] if voice),
23
- (["-r", rate] if rate),
24
- "-o", output_path.sub_ext(".aiff"),
25
- text,
26
- log_path:
21
+ self.class.run_command "say",
22
+ (["-v", voice] if voice),
23
+ (["-r", rate] if rate),
24
+ "-o", output_path.sub_ext(".aiff"),
25
+ text,
26
+ log_path:
27
27
  end
28
28
  end
29
29
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ScreenKit
4
- VERSION = "0.0.6"
4
+ VERSION = "0.0.8"
5
5
  end
data/lib/screenkit.rb CHANGED
@@ -1,3 +1,81 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "screen_kit"
3
+ require "thor"
4
+ require "thor/completion"
5
+ require "yaml"
6
+ require "json-schema"
7
+ require "mini_magick"
8
+ require "pathname"
9
+ require "tty-spinner"
10
+ require "etc"
11
+ require "securerandom"
12
+ require "demo_tape/duration"
13
+ require "aitch"
14
+
15
+ module ScreenKit
16
+ require_relative "screenkit/version"
17
+ require_relative "screenkit/duration"
18
+ require_relative "screenkit/core_ext"
19
+ require_relative "screenkit/content_type"
20
+ require_relative "screenkit/anchor"
21
+ require_relative "screenkit/banner"
22
+ require_relative "screenkit/time_formatter"
23
+ require_relative "screenkit/spacing"
24
+ require_relative "screenkit/watermark"
25
+ require_relative "screenkit/spinner"
26
+ require_relative "screenkit/shell"
27
+ require_relative "screenkit/http"
28
+ require_relative "screenkit/schema_validator"
29
+ require_relative "screenkit/generators/project"
30
+ require_relative "screenkit/generators/episode"
31
+ require_relative "screenkit/config/base"
32
+ require_relative "screenkit/config/project"
33
+ require_relative "screenkit/config/episode"
34
+ require_relative "screenkit/callout"
35
+ require_relative "screenkit/callout/text_style"
36
+ require_relative "screenkit/callout/styles/base"
37
+ require_relative "screenkit/transition"
38
+ require_relative "screenkit/parallel_processor"
39
+ require_relative "screenkit/cli"
40
+ require_relative "screenkit/cli/base"
41
+ require_relative "screenkit/cli/episode"
42
+ require_relative "screenkit/cli/root"
43
+ require_relative "screenkit/animation_filters"
44
+ require_relative "screenkit/tts/base"
45
+ require_relative "screenkit/path_lookup"
46
+ require_relative "screenkit/sound"
47
+ require_relative "screenkit/utils"
48
+ require_relative "screenkit/logfile"
49
+ require_relative "screenkit/exporter/intro"
50
+ require_relative "screenkit/exporter/outro"
51
+ require_relative "screenkit/exporter/demotape"
52
+ require_relative "screenkit/exporter/episode"
53
+ require_relative "screenkit/exporter/segment"
54
+ require_relative "screenkit/exporter/image"
55
+ require_relative "screenkit/exporter/video"
56
+
57
+ require_files = lambda do |pattern|
58
+ Gem.find_files_from_load_path(pattern).each do |path|
59
+ next if path.include?("test")
60
+
61
+ require(path)
62
+ end
63
+ end
64
+
65
+ # Load all files that may be available as plugins.
66
+ require_files.call("screenkit/callout/styles/*.rb")
67
+ require_files.call("screenkit/tts/*.rb")
68
+
69
+ def self.root_dir
70
+ @root_dir ||= Pathname(__dir__)
71
+ end
72
+
73
+ # Raised when the configuration schema is invalid.
74
+ InvalidConfigSchemaError = Class.new(StandardError)
75
+
76
+ # Raised when a file is not found.
77
+ FileNotFoundError = Class.new(StandardError)
78
+
79
+ # Raised when a file entry is not found in the lookup.
80
+ FileEntryNotFoundError = Class.new(StandardError)
81
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: screenkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.6
4
+ version: 0.0.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nando Vieira
@@ -276,10 +276,10 @@ files:
276
276
  - LICENSE.md
277
277
  - README.md
278
278
  - Rakefile
279
+ - action.yml
279
280
  - bin/console
280
281
  - bin/setup
281
282
  - exe/screenkit
282
- - lib/screen_kit.rb
283
283
  - lib/screenkit.rb
284
284
  - lib/screenkit/anchor.rb
285
285
  - lib/screenkit/animation_filters.rb
@@ -298,6 +298,7 @@ files:
298
298
  - lib/screenkit/config/episode.rb
299
299
  - lib/screenkit/config/project.rb
300
300
  - lib/screenkit/content_type.rb
301
+ - lib/screenkit/core_ext.rb
301
302
  - lib/screenkit/core_ext/json.rb
302
303
  - lib/screenkit/core_ext/string.rb
303
304
  - lib/screenkit/duration.rb
@@ -314,6 +315,7 @@ files:
314
315
  - lib/screenkit/generators/episode/content/001.tape
315
316
  - lib/screenkit/generators/episode/scripts/001.txt
316
317
  - lib/screenkit/generators/project.rb
318
+ - lib/screenkit/generators/project/.github/screenkit.yml
317
319
  - lib/screenkit/generators/project/Gemfile.erb
318
320
  - lib/screenkit/generators/project/resources/backtracks/default.aac
319
321
  - lib/screenkit/generators/project/resources/fonts/open-sans/OFL.txt
@@ -326,6 +328,7 @@ files:
326
328
  - lib/screenkit/generators/project/resources/sounds/pop.mp3
327
329
  - lib/screenkit/generators/project/resources/sounds/whoosh.mp3
328
330
  - lib/screenkit/generators/project/screenkit.yml
331
+ - lib/screenkit/http.rb
329
332
  - lib/screenkit/logfile.rb
330
333
  - lib/screenkit/parallel_processor.rb
331
334
  - lib/screenkit/path_lookup.rb
@@ -385,10 +388,10 @@ metadata:
385
388
  rubygems_mfa_required: 'true'
386
389
  homepage_uri: https://github.com/fnando/screenkit
387
390
  bug_tracker_uri: https://github.com/fnando/screenkit/issues
388
- source_code_uri: https://github.com/fnando/screenkit/tree/v0.0.6
389
- changelog_uri: https://github.com/fnando/screenkit/tree/v0.0.6/CHANGELOG.md
390
- documentation_uri: https://github.com/fnando/screenkit/tree/v0.0.6/README.md
391
- license_uri: https://github.com/fnando/screenkit/tree/v0.0.6/LICENSE.md
391
+ source_code_uri: https://github.com/fnando/screenkit/tree/v0.0.8
392
+ changelog_uri: https://github.com/fnando/screenkit/tree/v0.0.8/CHANGELOG.md
393
+ documentation_uri: https://github.com/fnando/screenkit/tree/v0.0.8/README.md
394
+ license_uri: https://github.com/fnando/screenkit/tree/v0.0.8/LICENSE.md
392
395
  rdoc_options: []
393
396
  require_paths:
394
397
  - lib
data/lib/screen_kit.rb DELETED
@@ -1,80 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "thor"
4
- require "thor/completion"
5
- require "yaml"
6
- require "json-schema"
7
- require "mini_magick"
8
- require "pathname"
9
- require "tty-spinner"
10
- require "etc"
11
- require "securerandom"
12
- require "demo_tape/duration"
13
-
14
- module ScreenKit
15
- require_relative "screenkit/version"
16
- require_relative "screenkit/duration"
17
- require_relative "screenkit/core_ext/string"
18
- require_relative "screenkit/core_ext/json"
19
- require_relative "screenkit/content_type"
20
- require_relative "screenkit/anchor"
21
- require_relative "screenkit/banner"
22
- require_relative "screenkit/time_formatter"
23
- require_relative "screenkit/spacing"
24
- require_relative "screenkit/watermark"
25
- require_relative "screenkit/spinner"
26
- require_relative "screenkit/shell"
27
- require_relative "screenkit/schema_validator"
28
- require_relative "screenkit/generators/project"
29
- require_relative "screenkit/generators/episode"
30
- require_relative "screenkit/config/base"
31
- require_relative "screenkit/config/project"
32
- require_relative "screenkit/config/episode"
33
- require_relative "screenkit/callout"
34
- require_relative "screenkit/callout/text_style"
35
- require_relative "screenkit/callout/styles/base"
36
- require_relative "screenkit/transition"
37
- require_relative "screenkit/parallel_processor"
38
- require_relative "screenkit/cli"
39
- require_relative "screenkit/cli/base"
40
- require_relative "screenkit/cli/episode"
41
- require_relative "screenkit/cli/root"
42
- require_relative "screenkit/animation_filters"
43
- require_relative "screenkit/tts/base"
44
- require_relative "screenkit/path_lookup"
45
- require_relative "screenkit/sound"
46
- require_relative "screenkit/utils"
47
- require_relative "screenkit/logfile"
48
- require_relative "screenkit/exporter/intro"
49
- require_relative "screenkit/exporter/outro"
50
- require_relative "screenkit/exporter/demotape"
51
- require_relative "screenkit/exporter/episode"
52
- require_relative "screenkit/exporter/segment"
53
- require_relative "screenkit/exporter/image"
54
- require_relative "screenkit/exporter/video"
55
-
56
- require_files = lambda do |pattern|
57
- Gem.find_files_from_load_path(pattern).each do |path|
58
- next if path.include?("test")
59
-
60
- require(path)
61
- end
62
- end
63
-
64
- # Load all files that may be available as plugins.
65
- require_files.call("screenkit/callout/styles/*.rb")
66
- require_files.call("screenkit/tts/*.rb")
67
-
68
- def self.root_dir
69
- @root_dir ||= Pathname(__dir__)
70
- end
71
-
72
- # Raised when the configuration schema is invalid.
73
- InvalidConfigSchemaError = Class.new(StandardError)
74
-
75
- # Raised when a file is not found.
76
- FileNotFoundError = Class.new(StandardError)
77
-
78
- # Raised when a file entry is not found in the lookup.
79
- FileEntryNotFoundError = Class.new(StandardError)
80
- end