screenkit 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. checksums.yaml +7 -0
  2. data/.github/CODEOWNERS +4 -0
  3. data/.github/FUNDING.yml +4 -0
  4. data/.github/ISSUE_TEMPLATE/bug_report.md +41 -0
  5. data/.github/ISSUE_TEMPLATE/config.yml +5 -0
  6. data/.github/ISSUE_TEMPLATE/feature_request.md +23 -0
  7. data/.github/PULL_REQUEST_TEMPLATE.md +38 -0
  8. data/.github/dependabot.yml +15 -0
  9. data/.github/workflows/ruby-tests.yml +51 -0
  10. data/.gitignore +12 -0
  11. data/.rubocop.yml +14 -0
  12. data/CHANGELOG.md +16 -0
  13. data/CODE_OF_CONDUCT.md +74 -0
  14. data/CONTRIBUTING.md +80 -0
  15. data/DOCUMENTATION.md +972 -0
  16. data/Gemfile +5 -0
  17. data/LICENSE.md +20 -0
  18. data/README.md +49 -0
  19. data/Rakefile +15 -0
  20. data/bin/console +16 -0
  21. data/bin/setup +10 -0
  22. data/exe/screenkit +5 -0
  23. data/lib/screen_kit.rb +79 -0
  24. data/lib/screenkit/anchor.rb +19 -0
  25. data/lib/screenkit/animation_filters.rb +114 -0
  26. data/lib/screenkit/banner.rb +46 -0
  27. data/lib/screenkit/callout/styles/base.rb +101 -0
  28. data/lib/screenkit/callout/styles/default.rb +144 -0
  29. data/lib/screenkit/callout/styles/inline_block.rb +123 -0
  30. data/lib/screenkit/callout/text_style.rb +44 -0
  31. data/lib/screenkit/callout.rb +98 -0
  32. data/lib/screenkit/cli/base.rb +24 -0
  33. data/lib/screenkit/cli/episode.rb +78 -0
  34. data/lib/screenkit/cli/root.rb +73 -0
  35. data/lib/screenkit/cli.rb +9 -0
  36. data/lib/screenkit/config/base.rb +51 -0
  37. data/lib/screenkit/config/episode.rb +42 -0
  38. data/lib/screenkit/config/project.rb +47 -0
  39. data/lib/screenkit/content_type.rb +12 -0
  40. data/lib/screenkit/core_ext/json.rb +47 -0
  41. data/lib/screenkit/core_ext/string.rb +28 -0
  42. data/lib/screenkit/exporter/demotape.rb +26 -0
  43. data/lib/screenkit/exporter/episode.rb +565 -0
  44. data/lib/screenkit/exporter/image.rb +31 -0
  45. data/lib/screenkit/exporter/intro.rb +261 -0
  46. data/lib/screenkit/exporter/outro.rb +183 -0
  47. data/lib/screenkit/exporter/segment.rb +258 -0
  48. data/lib/screenkit/exporter/video.rb +33 -0
  49. data/lib/screenkit/generators/episode/config.yml.erb +106 -0
  50. data/lib/screenkit/generators/episode/content/001.tape +4 -0
  51. data/lib/screenkit/generators/episode/scripts/001.txt +1 -0
  52. data/lib/screenkit/generators/episode.rb +31 -0
  53. data/lib/screenkit/generators/project/Gemfile.erb +7 -0
  54. data/lib/screenkit/generators/project/resources/backtracks/default.aac +0 -0
  55. data/lib/screenkit/generators/project/resources/fonts/opensans/OFL.txt +93 -0
  56. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-Bold.ttf +0 -0
  57. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-BoldItalic.ttf +0 -0
  58. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-ExtraBold.ttf +0 -0
  59. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-ExtraBoldItalic.ttf +0 -0
  60. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-Italic.ttf +0 -0
  61. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-Light.ttf +0 -0
  62. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-LightItalic.ttf +0 -0
  63. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-Medium.ttf +0 -0
  64. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-MediumItalic.ttf +0 -0
  65. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-Regular.ttf +0 -0
  66. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-SemiBold.ttf +0 -0
  67. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans-SemiBoldItalic.ttf +0 -0
  68. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-Bold.ttf +0 -0
  69. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-BoldItalic.ttf +0 -0
  70. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-ExtraBold.ttf +0 -0
  71. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-ExtraBoldItalic.ttf +0 -0
  72. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-Italic.ttf +0 -0
  73. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-Light.ttf +0 -0
  74. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-LightItalic.ttf +0 -0
  75. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-Medium.ttf +0 -0
  76. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-MediumItalic.ttf +0 -0
  77. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-Regular.ttf +0 -0
  78. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-SemiBold.ttf +0 -0
  79. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_Condensed-SemiBoldItalic.ttf +0 -0
  80. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-Bold.ttf +0 -0
  81. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-BoldItalic.ttf +0 -0
  82. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-ExtraBold.ttf +0 -0
  83. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-ExtraBoldItalic.ttf +0 -0
  84. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-Italic.ttf +0 -0
  85. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-Light.ttf +0 -0
  86. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-LightItalic.ttf +0 -0
  87. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-Medium.ttf +0 -0
  88. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-MediumItalic.ttf +0 -0
  89. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-Regular.ttf +0 -0
  90. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-SemiBold.ttf +0 -0
  91. data/lib/screenkit/generators/project/resources/fonts/opensans/OpenSans_SemiCondensed-SemiBoldItalic.ttf +0 -0
  92. data/lib/screenkit/generators/project/resources/fonts/opensans/README.txt +100 -0
  93. data/lib/screenkit/generators/project/resources/images/logo.png +0 -0
  94. data/lib/screenkit/generators/project/resources/images/watermark.png +0 -0
  95. data/lib/screenkit/generators/project/resources/sounds/chime.mp3 +0 -0
  96. data/lib/screenkit/generators/project/resources/sounds/pop.mp3 +0 -0
  97. data/lib/screenkit/generators/project/resources/sounds/whoosh.mp3 +0 -0
  98. data/lib/screenkit/generators/project/screenkit.yml +189 -0
  99. data/lib/screenkit/generators/project.rb +34 -0
  100. data/lib/screenkit/logfile.rb +33 -0
  101. data/lib/screenkit/parallel_processor.rb +50 -0
  102. data/lib/screenkit/path_lookup.rb +27 -0
  103. data/lib/screenkit/resources/mute.mp3 +0 -0
  104. data/lib/screenkit/resources/transparent.png +0 -0
  105. data/lib/screenkit/schema_validator.rb +14 -0
  106. data/lib/screenkit/schemas/callouts/default.json +44 -0
  107. data/lib/screenkit/schemas/callouts/inline_block.json +20 -0
  108. data/lib/screenkit/schemas/episode.json +74 -0
  109. data/lib/screenkit/schemas/project.json +37 -0
  110. data/lib/screenkit/schemas/refs/anchor.json +17 -0
  111. data/lib/screenkit/schemas/refs/animation.json +7 -0
  112. data/lib/screenkit/schemas/refs/background.json +14 -0
  113. data/lib/screenkit/schemas/refs/callout.json +27 -0
  114. data/lib/screenkit/schemas/refs/color.json +7 -0
  115. data/lib/screenkit/schemas/refs/directory.json +20 -0
  116. data/lib/screenkit/schemas/refs/intro.json +30 -0
  117. data/lib/screenkit/schemas/refs/logo.json +26 -0
  118. data/lib/screenkit/schemas/refs/outro.json +26 -0
  119. data/lib/screenkit/schemas/refs/position.json +38 -0
  120. data/lib/screenkit/schemas/refs/scenes.json +27 -0
  121. data/lib/screenkit/schemas/refs/size.json +19 -0
  122. data/lib/screenkit/schemas/refs/sound.json +36 -0
  123. data/lib/screenkit/schemas/refs/spacing.json +22 -0
  124. data/lib/screenkit/schemas/refs/text_style.json +18 -0
  125. data/lib/screenkit/schemas/refs/transition.json +15 -0
  126. data/lib/screenkit/schemas/refs/tts.json +47 -0
  127. data/lib/screenkit/schemas/refs/watermark.json +18 -0
  128. data/lib/screenkit/schemas/tts/elevenlabs.json +67 -0
  129. data/lib/screenkit/schemas/tts/say.json +16 -0
  130. data/lib/screenkit/shell.rb +58 -0
  131. data/lib/screenkit/sound.rb +44 -0
  132. data/lib/screenkit/spacing.rb +23 -0
  133. data/lib/screenkit/spinner.rb +39 -0
  134. data/lib/screenkit/transition.rb +16 -0
  135. data/lib/screenkit/tts/eleven_labs.rb +51 -0
  136. data/lib/screenkit/tts/say.rb +31 -0
  137. data/lib/screenkit/utils.rb +87 -0
  138. data/lib/screenkit/version.rb +5 -0
  139. data/lib/screenkit/watermark.rb +34 -0
  140. data/lib/screenkit.rb +3 -0
  141. data/screenkit.gemspec +56 -0
  142. metadata +426 -0
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
data/LICENSE.md ADDED
@@ -0,0 +1,20 @@
1
+ # The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Nando Vieira
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # screenkit
2
+
3
+ [![Tests](https://github.com/fnando/screenkit/workflows/ruby-tests/badge.svg)](https://github.com/fnando/screenkit)
4
+ [![Gem](https://img.shields.io/gem/v/screenkit.svg)](https://rubygems.org/gems/screenkit)
5
+ [![Gem](https://img.shields.io/gem/dt/screenkit.svg)](https://rubygems.org/gems/screenkit)
6
+ [![MIT License](https://img.shields.io/:License-MIT-blue.svg)](https://tldrlegal.com/license/mit-license)
7
+
8
+ Terminal to screencast, simplified
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ gem install screenkit
14
+ ```
15
+
16
+ Or add the following line to your project's Gemfile:
17
+
18
+ ```ruby
19
+ gem "screenkit"
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ TODO: Write gem usage
25
+
26
+ ## Maintainer
27
+
28
+ - [Nando Vieira](https://github.com/fnando)
29
+
30
+ ## Contributors
31
+
32
+ - https://github.com/fnando/screenkit/contributors
33
+
34
+ ## Contributing
35
+
36
+ For more details about how to contribute, please read
37
+ https://github.com/fnando/screenkit/blob/main/CONTRIBUTING.md.
38
+
39
+ ## License
40
+
41
+ The gem is available as open source under the terms of the
42
+ [MIT License](https://opensource.org/licenses/MIT). A copy of the license can be
43
+ found at https://github.com/fnando/screenkit/blob/main/LICENSE.md.
44
+
45
+ ## Code of Conduct
46
+
47
+ Everyone interacting in the screenkit project's codebases, issue trackers, chat
48
+ rooms and mailing lists is expected to follow the
49
+ [code of conduct](https://github.com/fnando/screenkit/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+ require "rubocop/rake_task"
6
+
7
+ Rake::TestTask.new(:test) do |t|
8
+ t.libs << "test"
9
+ t.libs << "lib"
10
+ t.test_files = FileList["test/**/*_test.rb"]
11
+ end
12
+
13
+ RuboCop::RakeTask.new
14
+
15
+ task default: %i[test rubocop]
data/bin/console ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "screen_kit"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ begin
11
+ require "pry"
12
+ Pry.start
13
+ rescue LoadError
14
+ require "irb"
15
+ IRB.start(__FILE__)
16
+ end
data/bin/setup ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -euo pipefail
4
+
5
+ IFS=$'\n\t'
6
+ set -vx
7
+
8
+ bundle install
9
+
10
+ # Do any other automated setup that you need to do here
data/exe/screenkit ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/screenkit"
5
+ ScreenKit::CLI.start
data/lib/screen_kit.rb ADDED
@@ -0,0 +1,79 @@
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
+
13
+ module ScreenKit
14
+ require_relative "screenkit/version"
15
+ require_relative "screenkit/core_ext/string"
16
+ require_relative "screenkit/core_ext/json"
17
+ require_relative "screenkit/content_type"
18
+ require_relative "screenkit/anchor"
19
+ require_relative "screenkit/banner"
20
+ require_relative "screenkit/spacing"
21
+ require_relative "screenkit/watermark"
22
+ require_relative "screenkit/spinner"
23
+ require_relative "screenkit/shell"
24
+ require_relative "screenkit/schema_validator"
25
+ require_relative "screenkit/generators/project"
26
+ require_relative "screenkit/generators/episode"
27
+ require_relative "screenkit/config/base"
28
+ require_relative "screenkit/config/project"
29
+ require_relative "screenkit/config/episode"
30
+ require_relative "screenkit/callout"
31
+ require_relative "screenkit/callout/text_style"
32
+ require_relative "screenkit/transition"
33
+ require_relative "screenkit/parallel_processor"
34
+ require_relative "screenkit/callout/styles/base"
35
+ require_relative "screenkit/callout/styles/default"
36
+ require_relative "screenkit/cli"
37
+ require_relative "screenkit/cli/base"
38
+ require_relative "screenkit/cli/episode"
39
+ require_relative "screenkit/cli/root"
40
+ require_relative "screenkit/tts/say"
41
+ require_relative "screenkit/tts/eleven_labs"
42
+ require_relative "screenkit/animation_filters"
43
+ require_relative "screenkit/path_lookup"
44
+ require_relative "screenkit/sound"
45
+ require_relative "screenkit/utils"
46
+ require_relative "screenkit/logfile"
47
+ require_relative "screenkit/exporter/intro"
48
+ require_relative "screenkit/exporter/outro"
49
+ require_relative "screenkit/exporter/demotape"
50
+ require_relative "screenkit/exporter/episode"
51
+ require_relative "screenkit/exporter/segment"
52
+ require_relative "screenkit/exporter/image"
53
+ require_relative "screenkit/exporter/video"
54
+
55
+ def self.root_dir
56
+ @root_dir ||= Pathname(__dir__)
57
+ end
58
+
59
+ # Raised when the configuration schema is invalid.
60
+ InvalidConfigSchemaError = Class.new(StandardError)
61
+
62
+ # Raised when a file is not found.
63
+ FileNotFoundError = Class.new(StandardError)
64
+
65
+ # Raised when a file entry is not found in the lookup.
66
+ FileEntryNotFoundError = Class.new(StandardError)
67
+
68
+ require_files = lambda do |pattern|
69
+ Gem.find_files_from_load_path(pattern).each do |path|
70
+ next if path.include?("test")
71
+
72
+ require(path)
73
+ end
74
+ end
75
+
76
+ # Load all files that may be available as plugins.
77
+ require_files.call("screenkit/callout/styles/*.rb")
78
+ require_files.call("screenkit/callout/tts/*.rb")
79
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenKit
4
+ class Anchor
5
+ # The vertical alignment (:top, :center, :bottom).
6
+ attr_reader :vertical
7
+
8
+ # The horizontal alignment (:left, :center, :right).
9
+ attr_reader :horizontal
10
+
11
+ def initialize(value)
12
+ @horizontal, @vertical = (Array(value) * 2).take(2)
13
+ end
14
+
15
+ def as_json(*)
16
+ [horizontal, vertical]
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenKit
4
+ class AnimationFilters
5
+ attr_reader :callout_index, :input_stream, :output_stream, :index,
6
+ :starts_at, :ends_at, :x, :y, :animation_duration,
7
+ :content_duration, :image_width, :image_height,
8
+ :callout_duration, :fade_out_start, :out_start
9
+
10
+ def initialize(
11
+ callout_index:,
12
+ input_stream:,
13
+ output_stream:,
14
+ index:,
15
+ starts_at:,
16
+ ends_at:,
17
+ x:,
18
+ y:,
19
+ animation_duration:,
20
+ content_duration:,
21
+ image_width:,
22
+ image_height:
23
+ )
24
+ @callout_index = callout_index
25
+ @input_stream = input_stream
26
+ @output_stream = output_stream
27
+ @index = index
28
+ @starts_at = starts_at
29
+ @ends_at = ends_at
30
+ @x = x
31
+ @y = y
32
+ @animation_duration = animation_duration
33
+ @content_duration = content_duration
34
+ @image_width = image_width
35
+ @image_height = image_height
36
+
37
+ # Calculated values used by animation methods
38
+ @callout_duration = ends_at - starts_at
39
+ @fade_out_start = callout_duration - animation_duration
40
+ @out_start = ends_at - animation_duration
41
+ end
42
+
43
+ def fade
44
+ filters = []
45
+
46
+ # Scale callout and apply fade in and fade out
47
+ # Fade in starts at 0, fade out starts at
48
+ # (callout_duration - animation_duration)
49
+ filters <<
50
+ "[#{callout_index}:v]scale=#{image_width}:#{image_height},fade=t=in:" \
51
+ "st=0:d=#{animation_duration}:alpha=1,fade=t=out:" \
52
+ "st=#{fade_out_start}:d=#{animation_duration}:alpha=1" \
53
+ "[callout#{index}_faded]"
54
+
55
+ # Use setpts to delay the callout's presentation timestamp to sync with
56
+ # starts_at, then overlay it
57
+ filters <<
58
+ "[callout#{index}_faded]setpts=PTS+#{starts_at}/TB" \
59
+ "[callout#{index}_delayed]"
60
+ filters <<
61
+ "[#{input_stream}][callout#{index}_delayed]overlay=x=#{x}:y=#{y}:" \
62
+ "enable='between(t,#{starts_at},#{ends_at})'" \
63
+ "[#{output_stream}]"
64
+
65
+ {video: filters, out_start:}
66
+ end
67
+
68
+ def slide
69
+ filters = []
70
+
71
+ # Scale and split callout for blur effect
72
+ filters << "[#{callout_index}:v]scale=#{image_width}:" \
73
+ "#{image_height}[callout#{index}_base]"
74
+ filters << "[callout#{index}_base]split=3[callout#{index}_blur_in]" \
75
+ "[callout#{index}_sharp][callout#{index}_blur_out]"
76
+
77
+ # Create blurred versions for motion and delay them to the correct
78
+ # timeline position
79
+ filters <<
80
+ "[callout#{index}_blur_in]boxblur=20:1,setpts=PTS+#{starts_at}/TB" \
81
+ "[callout#{index}_blurred_in]"
82
+ filters <<
83
+ "[callout#{index}_sharp]setpts=PTS+#{starts_at}/TB" \
84
+ "[callout#{index}_sharp_delayed]"
85
+ filters <<
86
+ "[callout#{index}_blur_out]boxblur=20:1,setpts=PTS+#{starts_at}/TB" \
87
+ "[callout#{index}_blurred_out]"
88
+
89
+ # Overlay blurred version during slide in
90
+ # Animation runs from 0 to animation_duration in callout's timeline
91
+ filters <<
92
+ "[#{input_stream}][callout#{index}_blurred_in]overlay=x=" \
93
+ "'if(lt(t,#{starts_at + animation_duration}),-W+((t-#{starts_at})*" \
94
+ "(W+#{x})/#{animation_duration}),#{x})':y=#{y}:enable=" \
95
+ "'between(t,#{starts_at},#{starts_at + animation_duration})'" \
96
+ "[#{output_stream}_in]"
97
+
98
+ # Overlay sharp version while visible
99
+ filters <<
100
+ "[#{output_stream}_in][callout#{index}_sharp_delayed]overlay=x=#{x}:" \
101
+ "y=#{y}:enable='between(t,#{starts_at + animation_duration}," \
102
+ "#{out_start})'[#{output_stream}_hold]"
103
+
104
+ # Overlay blurred version during slide out (to the left)
105
+ filters <<
106
+ "[#{output_stream}_hold][callout#{index}_blurred_out]overlay=x=" \
107
+ "'if(lt(t,#{ends_at}),#{x}-((t-#{out_start})*(W+#{x})/" \
108
+ "#{animation_duration}),-W)':y=#{y}:enable='between(t,#{out_start}," \
109
+ "#{ends_at})'[#{output_stream}]"
110
+
111
+ {video: filters, out_start:}
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenKit
4
+ class Banner
5
+ def self.source
6
+ version = "v#{VERSION}"
7
+ version_line = "┃#{' ' * (69 - version.size)}#{version} ┃ ║"
8
+
9
+ <<~TEXT
10
+ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
11
+ ┃ Terminal to screencast, simplified ┃═╗
12
+ ┃ ███████╗ ██████╗██████╗ ███████╗███████╗███╗ ██╗██╗ ██╗██╗████████╗ ┃ ║
13
+ ┃ ██╔════╝██╔════╝██╔══██╗██╔════╝██╔════╝████╗ ██║██║ ██╔╝██║╚══██╔══╝ ┃ ║
14
+ ┃ ███████╗██║ ██████╔╝█████╗ █████╗ ██╔██╗ ██║█████╔╝ ██║ ██║ ┃ ║
15
+ ┃ ╚════██║██║ ██╔══██╗██╔══╝ ██╔══╝ ██║╚██╗██║██╔═██╗ ██║ ██║ ┃ ║
16
+ ┃ ███████║╚██████╗██║ ██║███████╗███████╗██║ ╚████║██║ ██╗██║ ██║ ┃ ║
17
+ ┃ ╚══════╝ ╚═════╝╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚═╝ ┃ ║
18
+ #{version_line}
19
+ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ ║
20
+ ╚══════════════════════════════════════════════════════════════════════════╝
21
+ TEXT
22
+ end
23
+
24
+ def self.banner
25
+ return source if !$stdout.tty? || ENV["NO_COLOR"]
26
+
27
+ colors = ["\e[31m", "\e[32m", "\e[33m", "\e[34m", "\e[35m", "\e[36m"]
28
+ text = colors.sample
29
+ accent = (colors - [text]).sample
30
+ clear = "\e[0m"
31
+
32
+ chars = source.each_char.with_object([]) do |char, buffer|
33
+ buffer << case char
34
+ when /[A-Za-z0-9.,]/
35
+ "#{text}#{char}#{clear}"
36
+ when "╚", "═", "║", "╝", "╔", "╗"
37
+ "\e[37m#{char}#{clear}"
38
+ else
39
+ "#{accent}#{char}#{clear}"
40
+ end
41
+ end
42
+
43
+ chars.join
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenKit
4
+ class Callout
5
+ module Styles
6
+ class Base
7
+ def text_wrap(text, max_width:, font_size:)
8
+ words = text.to_s.split(/\s+/)
9
+ width_factor = 0.6
10
+
11
+ [].tap do |lines|
12
+ words.each do |word|
13
+ line = lines.pop.to_s
14
+ word_width = word.size * (font_size * width_factor)
15
+ line_width = line.size * (font_size * width_factor)
16
+
17
+ if line_width + word_width <= max_width
18
+ line = [(line unless line.empty?), word].compact.join(" ")
19
+ lines << line
20
+ else
21
+ lines << line
22
+ lines << word
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ # Escape text for use in ImageMagick caption.
29
+ def escape_text(text)
30
+ text.gsub("'", "\\\\'")
31
+ end
32
+
33
+ def remove_file(path)
34
+ File.unlink(path) if path && File.exist?(path)
35
+ end
36
+
37
+ def render_text_image(text:, style:, width:, type:)
38
+ return [nil, 0, 0] if text.to_s.empty?
39
+
40
+ image = MiniMagick::Image.open(
41
+ create_text_image(text:, style:, width:, type:)
42
+ )
43
+
44
+ [image.path, image.width, image.height]
45
+ end
46
+
47
+ # Convert values to high resolution (2x).
48
+ # @param value [Object] The value to convert.
49
+ # @return [Object] The converted value.
50
+ def hi_res(value)
51
+ case value
52
+ when Array
53
+ value.map { hi_res(it) }
54
+ when Hash
55
+ value.transform_values { hi_res(it) }
56
+ when Numeric
57
+ value * 2
58
+ else
59
+ value
60
+ end
61
+ end
62
+
63
+ # Create a text image using MiniMagick.
64
+ # @param text [String] The text to render.
65
+ # @param style [TextStyle] The text style to apply.
66
+ # @param width [Integer] The width of the text image.
67
+ # @param type [String] The ImageMagick text type (e.g., "caption").
68
+ # @return [Array] The path to the generated text image, and the actual
69
+ # `Tempfile` instance.
70
+ def create_text_image(text:, style:, width:, type:)
71
+ hash = SecureRandom.hex(10)
72
+ tmp_path = File.join(Dir.tmpdir, "callout-text-#{hash}.png")
73
+ FileUtils.mkdir_p(File.dirname(tmp_path))
74
+
75
+ MiniMagick.convert do |image|
76
+ unless type == "label"
77
+ image << "-size"
78
+ image << "#{width}x"
79
+ end
80
+
81
+ image << "-background"
82
+ image << "none"
83
+ image << "-fill"
84
+ image << style.color
85
+ image << "-font"
86
+ image << style.font_path.to_s
87
+ image << "-pointsize"
88
+ image << style.size.to_s
89
+ image << "#{type}:#{escape_text(text)}"
90
+ image << "PNG:#{tmp_path}"
91
+ end
92
+
93
+ tmp_path
94
+ rescue MiniMagick::Error => error
95
+ retry if error.message.include?("No such file or directory")
96
+ raise
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mini_magick"
4
+
5
+ module ScreenKit
6
+ class Callout
7
+ module Styles
8
+ class Default < Base
9
+ extend SchemaValidator
10
+
11
+ attr_reader :background_color, :body, :body_style,
12
+ :output_path, :padding, :shadow,
13
+ :title, :title_style, :width, :source
14
+
15
+ def self.schema_path
16
+ ScreenKit.root_dir.join("screenkit/schemas/callouts/default.json")
17
+ end
18
+
19
+ def initialize(source:, **kwargs) # rubocop:disable Lint/MissingSuper
20
+ self.class.validate!(kwargs)
21
+ @source = source
22
+
23
+ # Set default values
24
+ kwargs[:shadow] = case kwargs[:shadow]
25
+ when false
26
+ {color: "#ffffffff", offset: 0}
27
+ when Integer
28
+ {color: "#00000080", offset: kwargs[:shadow]}
29
+ when String
30
+ {color: kwargs[:shadow], offset: 20}
31
+ else
32
+ kwargs[:shadow]
33
+ end
34
+
35
+ kwargs = hi_res({width: 600}.merge(kwargs))
36
+
37
+ kwargs.each do |key, value|
38
+ value = case key
39
+ when :body_style, :title_style
40
+ TextStyle.new(source:, **value)
41
+ when :padding
42
+ Spacing.new(value)
43
+ else
44
+ value
45
+ end
46
+
47
+ instance_variable_set(:"@#{key}", value)
48
+ end
49
+ end
50
+
51
+ def as_json(*)
52
+ {
53
+ background_color:,
54
+ body:,
55
+ body_style: body_style.as_json,
56
+ output_path:,
57
+ padding: padding.as_json,
58
+ shadow:,
59
+ title:,
60
+ title_style: title_style.as_json,
61
+ width:
62
+ }
63
+ end
64
+
65
+ def render
66
+ title_path, _, title_height =
67
+ *render_text_image(text: title,
68
+ style: title_style,
69
+ width: text_width,
70
+ type: "caption")
71
+ body_path, _, body_height =
72
+ *render_text_image(text: body,
73
+ style: body_style,
74
+ width: text_width,
75
+ type: "caption")
76
+ text_gap = if title_path && body_path
77
+ (title_style.size * hi_res(0.2)).round
78
+ else
79
+ 0
80
+ end
81
+
82
+ image_width = width
83
+ image_height = padding.vertical +
84
+ title_height +
85
+ text_gap +
86
+ body_height +
87
+ shadow[:offset]
88
+
89
+ MiniMagick.convert do |image|
90
+ # Create transparent canvas
91
+ image << "-size"
92
+ image << "#{image_width}x#{image_height}"
93
+ image << "xc:none"
94
+
95
+ # Draw rectangle shadow
96
+ image << "-fill"
97
+ image << shadow[:color]
98
+ image << "-draw"
99
+ image << "rectangle 0,#{shadow[:offset]}," \
100
+ "#{width - shadow[:offset]},#{image_height}"
101
+
102
+ # Draw rectangle background
103
+ image << "-fill"
104
+ image << background_color
105
+ image << "-draw"
106
+ image << "rectangle #{shadow[:offset]},0," \
107
+ "#{image_width},#{image_height - shadow[:offset]}"
108
+
109
+ # Composite title
110
+ if title_path
111
+ image << title_path
112
+ image << "-geometry"
113
+ image << "+#{text_x}+#{padding.left}"
114
+ image << "-composite"
115
+ end
116
+
117
+ # Composite body
118
+ if body_path
119
+ image << body_path
120
+ image << "-geometry"
121
+ image << "+#{text_x}+#{padding.left + title_height + text_gap}"
122
+ image << "-composite"
123
+ end
124
+
125
+ image << "PNG:#{output_path}"
126
+ end
127
+
128
+ output_path
129
+ rescue MiniMagick::Error => error
130
+ retry if error.message.include?("No such file or directory")
131
+ raise
132
+ ensure
133
+ remove_file(title_path)
134
+ remove_file(body_path)
135
+ end
136
+
137
+ private def text_width = width - padding.horizontal
138
+ private def text_x = shadow[:offset] + padding.left
139
+ private def title_y = padding.top
140
+ private def body_y = title_y + text_gap + title_style.size
141
+ end
142
+ end
143
+ end
144
+ end