runway-ruby 0.1.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 (46) hide show
  1. checksums.yaml +7 -0
  2. data/.tool-versions +1 -0
  3. data/LICENSE.txt +21 -0
  4. data/OPENAPI_VALIDATION.md +100 -0
  5. data/README.md +789 -0
  6. data/Rakefile +11 -0
  7. data/lib/runway_ml/character_performance.rb +106 -0
  8. data/lib/runway_ml/client.rb +84 -0
  9. data/lib/runway_ml/contract_validator.rb +151 -0
  10. data/lib/runway_ml/errors.rb +179 -0
  11. data/lib/runway_ml/image_processor.rb +128 -0
  12. data/lib/runway_ml/image_to_video.rb +158 -0
  13. data/lib/runway_ml/media_processor.rb +343 -0
  14. data/lib/runway_ml/openapi_spec_loader.rb +120 -0
  15. data/lib/runway_ml/organization.rb +89 -0
  16. data/lib/runway_ml/sound_effect.rb +57 -0
  17. data/lib/runway_ml/spec_coverage_analyzer.rb +134 -0
  18. data/lib/runway_ml/speech_to_speech.rb +90 -0
  19. data/lib/runway_ml/task.rb +116 -0
  20. data/lib/runway_ml/test_client.rb +62 -0
  21. data/lib/runway_ml/text_to_speech.rb +52 -0
  22. data/lib/runway_ml/text_to_video.rb +103 -0
  23. data/lib/runway_ml/uploads.rb +74 -0
  24. data/lib/runway_ml/validator_builder.rb +95 -0
  25. data/lib/runway_ml/validators/base_validators.rb +338 -0
  26. data/lib/runway_ml/validators/character_performance_validator.rb +41 -0
  27. data/lib/runway_ml/validators/gen3a_turbo_validator.rb +48 -0
  28. data/lib/runway_ml/validators/gen4_turbo_validator.rb +50 -0
  29. data/lib/runway_ml/validators/prompt_image_validator.rb +97 -0
  30. data/lib/runway_ml/validators/sound_effect_validator.rb +27 -0
  31. data/lib/runway_ml/validators/speech_to_speech_validator.rb +57 -0
  32. data/lib/runway_ml/validators/text_to_speech_validator.rb +28 -0
  33. data/lib/runway_ml/validators/text_veo3_stable_validator.rb +35 -0
  34. data/lib/runway_ml/validators/text_veo3_validator.rb +33 -0
  35. data/lib/runway_ml/validators/uploads_validator.rb +44 -0
  36. data/lib/runway_ml/validators/veo3_stable_validator.rb +43 -0
  37. data/lib/runway_ml/validators/veo3_validator.rb +46 -0
  38. data/lib/runway_ml/validators/voice_dubbing_validator.rb +128 -0
  39. data/lib/runway_ml/validators/voice_isolation_validator.rb +35 -0
  40. data/lib/runway_ml/validators/voice_validator.rb +32 -0
  41. data/lib/runway_ml/version.rb +5 -0
  42. data/lib/runway_ml/voice_dubbing.rb +74 -0
  43. data/lib/runway_ml/voice_isolation.rb +57 -0
  44. data/lib/runway_ml.rb +81 -0
  45. data/lib/tasks/openapi.rake +33 -0
  46. metadata +90 -0
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_validators"
4
+ require_relative "voice_validator"
5
+
6
+ module RunwayML
7
+ module Validators
8
+ class SpeechToSpeechValidator
9
+ def initialize
10
+ @voice_validator = VoiceValidator.new
11
+ end
12
+
13
+ def validate(media:, voice:, remove_background_noise:)
14
+ errors = {}
15
+
16
+ validate_media(media, errors)
17
+ voice_validator.validate(voice, errors)
18
+ validate_remove_background_noise(remove_background_noise, errors)
19
+
20
+ { errors: errors }
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :voice_validator
26
+
27
+ def validate_media(media, errors)
28
+ if media.nil?
29
+ errors[:media] = "cannot be empty"
30
+ return
31
+ end
32
+
33
+ unless media.is_a?(Hash)
34
+ errors[:media] = "must be a hash"
35
+ return
36
+ end
37
+
38
+ media_type = media[:type]
39
+ media_uri = media[:uri]
40
+
41
+ unless SpeechToSpeech::VALID_MEDIA_TYPES.include?(media_type)
42
+ errors[:media] = "type must be one of: #{SpeechToSpeech::VALID_MEDIA_TYPES.join(', ')}"
43
+ end
44
+
45
+ if media_uri.nil? || media_uri.empty?
46
+ errors[:media] = "uri cannot be empty" if errors[:media].nil?
47
+ end
48
+ end
49
+
50
+ def validate_remove_background_noise(remove_background_noise, errors)
51
+ unless [ true, false ].include?(remove_background_noise)
52
+ errors[:remove_background_noise] = "must be a boolean"
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_validators"
4
+ require_relative "voice_validator"
5
+
6
+ module RunwayML
7
+ module Validators
8
+ class TextToSpeechValidator
9
+ def initialize
10
+ @prompt_text_validator = PromptTextValidator.new(max_length: 1000, required: true)
11
+ @voice_validator = VoiceValidator.new
12
+ end
13
+
14
+ def validate(prompt_text:, voice:)
15
+ errors = {}
16
+
17
+ prompt_text_validator.validate(prompt_text, errors)
18
+ voice_validator.validate(voice, errors)
19
+
20
+ { errors: errors }
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :prompt_text_validator, :voice_validator
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_validators"
4
+
5
+ module RunwayML
6
+ module Validators
7
+ class TextVeo3StableValidator
8
+ def initialize
9
+ @ratio_validator = RatioValidator.new(
10
+ valid_ratios: [ "1280:720", "720:1280", "1080:1920", "1920:1080" ]
11
+ )
12
+ @prompt_text_validator = PromptTextValidator.new(max_length: 1000, required: true)
13
+ @duration_validator = DurationValidator.new(exact: 8)
14
+ end
15
+
16
+ def validate(prompt_text:, ratio:, duration:, audio: nil)
17
+ errors = {}
18
+
19
+ ratio_validator.validate(ratio, errors)
20
+ prompt_text_validator.validate(prompt_text, errors)
21
+ duration_validator.validate(duration, errors)
22
+
23
+ if !audio.nil?
24
+ errors[:audio] = "is not supported for model veo3"
25
+ end
26
+
27
+ { errors: errors }
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :ratio_validator, :prompt_text_validator, :duration_validator
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_validators"
4
+
5
+ module RunwayML
6
+ module Validators
7
+ class TextVeo3Validator
8
+ def initialize
9
+ @ratio_validator = RatioValidator.new(
10
+ valid_ratios: [ "1280:720", "720:1280", "1080:1920", "1920:1080" ]
11
+ )
12
+ @prompt_text_validator = PromptTextValidator.new(max_length: 1000, required: true)
13
+ @duration_validator = DurationValidator.new(valid_values: [ 4, 6, 8 ])
14
+ @audio_validator = AudioValidator.new
15
+ end
16
+
17
+ def validate(prompt_text:, ratio:, duration:, audio: nil)
18
+ errors = {}
19
+
20
+ ratio_validator.validate(ratio, errors)
21
+ prompt_text_validator.validate(prompt_text, errors)
22
+ duration_validator.validate(duration, errors)
23
+ audio_validator.validate(audio, errors)
24
+
25
+ { errors: errors }
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :ratio_validator, :prompt_text_validator, :duration_validator, :audio_validator
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RunwayML
4
+ module Validators
5
+ class UploadsValidator
6
+ def initialize
7
+ end
8
+
9
+ def validate(filename:)
10
+ errors = {}
11
+
12
+ validate_filename(filename, errors)
13
+
14
+ { errors: errors }
15
+ end
16
+
17
+ private
18
+
19
+ def validate_filename(filename, errors)
20
+ unless filename.is_a?(String)
21
+ errors[:filename] = "must be a string"
22
+ return
23
+ end
24
+
25
+ if filename.nil? || filename.empty?
26
+ errors[:filename] = "cannot be empty"
27
+ return
28
+ end
29
+
30
+ if filename.length < 3 || filename.length > 255
31
+ errors[:filename] = "must be between 3 and 255 characters"
32
+ return
33
+ end
34
+
35
+ # Extract extension
36
+ extension = filename.split(".").last&.downcase
37
+ unless extension && Uploads::SUPPORTED_EXTENSIONS.key?(extension)
38
+ errors[:filename] = "must have a valid extension. Supported: #{Uploads::SUPPORTED_EXTENSIONS.keys.join(', ')}"
39
+ nil
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_validators"
4
+ require_relative "prompt_image_validator"
5
+
6
+ module RunwayML
7
+ module Validators
8
+ class Veo3StableValidator
9
+ def initialize(image_processor:)
10
+ @image_processor = image_processor
11
+ @ratio_validator = RatioValidator.new(
12
+ valid_ratios: [ "1280:720", "720:1280", "1080:1920", "1920:1080" ]
13
+ )
14
+ @prompt_text_validator = PromptTextValidator.new(max_length: 1000, required: false)
15
+ @duration_validator = DurationValidator.new(exact: 8)
16
+ @prompt_image_validator = PromptImageValidator.new(
17
+ image_processor: image_processor,
18
+ array_size: 1,
19
+ valid_positions: [ "first" ]
20
+ )
21
+ end
22
+
23
+ def validate(prompt_image:, prompt_text:, ratio:, duration:)
24
+ errors = {}
25
+
26
+ ratio_validator.validate(ratio, errors)
27
+ prompt_text_validator.validate(prompt_text, errors)
28
+ duration_validator.validate(duration, errors)
29
+
30
+ # Process and validate prompt_image
31
+ processed_prompt_image = image_processor.process(prompt_image, errors)
32
+ prompt_image_validator.validate(processed_prompt_image, errors)
33
+
34
+ { errors: errors, processed_prompt_image: processed_prompt_image }
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :image_processor, :ratio_validator, :prompt_text_validator,
40
+ :duration_validator, :prompt_image_validator
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_validators"
4
+ require_relative "prompt_image_validator"
5
+
6
+ module RunwayML
7
+ module Validators
8
+ class Veo3Validator
9
+ def initialize(image_processor:)
10
+ @image_processor = image_processor
11
+ @ratio_validator = RatioValidator.new(
12
+ valid_ratios: [ "1280:720", "720:1280", "1080:1920", "1920:1080" ]
13
+ )
14
+ @prompt_text_validator = PromptTextValidator.new(max_length: 1000, required: true)
15
+ @duration_validator = DurationValidator.new(valid_values: [ 4, 6, 8 ])
16
+ @audio_validator = AudioValidator.new
17
+ @prompt_image_validator = PromptImageValidator.new(
18
+ image_processor: image_processor,
19
+ array_size: 1..2,
20
+ valid_positions: [ "first", "last" ],
21
+ only_first_frame: true
22
+ )
23
+ end
24
+
25
+ def validate(prompt_image:, prompt_text:, ratio:, duration:, audio:)
26
+ errors = {}
27
+
28
+ ratio_validator.validate(ratio, errors)
29
+ prompt_text_validator.validate(prompt_text, errors)
30
+ duration_validator.validate(duration, errors)
31
+ audio_validator.validate(audio, errors)
32
+
33
+ # Process and validate prompt_image
34
+ processed_prompt_image = image_processor.process(prompt_image, errors)
35
+ prompt_image_validator.validate(processed_prompt_image, errors)
36
+
37
+ { errors: errors, processed_prompt_image: processed_prompt_image }
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :image_processor, :ratio_validator, :prompt_text_validator,
43
+ :duration_validator, :audio_validator, :prompt_image_validator
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_validators"
4
+
5
+ module RunwayML
6
+ module Validators
7
+ class VoiceDubbingValidator
8
+ def initialize
9
+ @audio_uri_validator = AudioUriValidator.new
10
+ end
11
+
12
+ def validate(audio_uri:, target_lang:, disable_voice_cloning:, drop_background_audio:, num_speakers:)
13
+ errors = {}
14
+
15
+ validate_audio_uri(audio_uri, errors)
16
+ validate_target_lang(target_lang, errors)
17
+ validate_disable_voice_cloning(disable_voice_cloning, errors)
18
+ validate_drop_background_audio(drop_background_audio, errors)
19
+ validate_num_speakers(num_speakers, errors)
20
+
21
+ { errors: errors }
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :audio_uri_validator
27
+
28
+ def validate_audio_uri(audio_uri, errors)
29
+ if audio_uri.nil? || (audio_uri.is_a?(String) && audio_uri.empty?)
30
+ errors[:audio_uri] = "cannot be empty"
31
+ return
32
+ end
33
+
34
+ audio_uri_validator.validate(audio_uri, errors, field: :audio_uri)
35
+ end
36
+
37
+ def validate_target_lang(target_lang, errors)
38
+ if target_lang.nil? || target_lang.empty?
39
+ errors[:target_lang] = "cannot be empty"
40
+ return
41
+ end
42
+
43
+ unless VoiceDubbing::VALID_TARGET_LANGS.include?(target_lang)
44
+ errors[:target_lang] = "must be one of: #{VoiceDubbing::VALID_TARGET_LANGS.join(', ')}"
45
+ end
46
+ end
47
+
48
+ def validate_disable_voice_cloning(disable_voice_cloning, errors)
49
+ return if disable_voice_cloning.nil?
50
+
51
+ unless [ true, false ].include?(disable_voice_cloning)
52
+ errors[:disable_voice_cloning] = "must be a boolean"
53
+ end
54
+ end
55
+
56
+ def validate_drop_background_audio(drop_background_audio, errors)
57
+ return if drop_background_audio.nil?
58
+
59
+ unless [ true, false ].include?(drop_background_audio)
60
+ errors[:drop_background_audio] = "must be a boolean"
61
+ end
62
+ end
63
+
64
+ def validate_num_speakers(num_speakers, errors)
65
+ return if num_speakers.nil?
66
+
67
+ unless num_speakers.is_a?(Integer)
68
+ errors[:num_speakers] = "must be an integer"
69
+ return
70
+ end
71
+
72
+ if num_speakers < 0 || num_speakers > 9007199254740991
73
+ errors[:num_speakers] = "must be between 0 and 9007199254740991"
74
+ end
75
+ end
76
+ end
77
+
78
+ class AudioUriValidator
79
+ VALID_CONTENT_TYPES = [ "audio/mpeg", "audio/mp3", "audio/wav", "audio/flac", "audio/m4a", "audio/aac", "audio/ogg", "audio/webm" ].freeze
80
+
81
+ def validate(uri, errors, field: :audio_uri)
82
+ return unless uri.is_a?(String)
83
+
84
+ if uri.length < 13
85
+ errors[field] = "URI must be at least 13 characters"
86
+ return
87
+ end
88
+
89
+ if uri.start_with?("https://")
90
+ validate_https_url(uri, errors, field)
91
+ elsif uri.start_with?("runway://")
92
+ validate_runway_uri(uri, errors, field)
93
+ elsif uri.start_with?("data:audio/")
94
+ validate_data_uri(uri, errors, field)
95
+ else
96
+ errors[field] = "must be a valid HTTPS URL, Runway URI (runway://), or data URI (data:audio/)"
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ def validate_https_url(uri, errors, field)
103
+ if uri.length > 2048
104
+ errors[field] = "HTTPS URL must be at most 2048 characters"
105
+ end
106
+ end
107
+
108
+ def validate_runway_uri(uri, errors, field)
109
+ if uri.length > 5000
110
+ errors[field] = "Runway URI must be at most 5000 characters"
111
+ end
112
+ end
113
+
114
+ def validate_data_uri(uri, errors, field)
115
+ if uri.length > 16777216
116
+ errors[field] = "Data URI must be at most 16777216 characters"
117
+ return
118
+ end
119
+
120
+ content_type_match = uri.match(/^data:(audio\/[^;,]+)/)
121
+ return unless content_type_match
122
+
123
+ content_type = content_type_match[1]
124
+ errors[field] = "unsupported audio type '#{content_type}'" unless VALID_CONTENT_TYPES.include?(content_type)
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_validators"
4
+ require_relative "voice_dubbing_validator"
5
+
6
+ module RunwayML
7
+ module Validators
8
+ class VoiceIsolationValidator
9
+ def initialize
10
+ @audio_uri_validator = AudioUriValidator.new
11
+ end
12
+
13
+ def validate(audio_uri:)
14
+ errors = {}
15
+
16
+ validate_audio_uri(audio_uri, errors)
17
+
18
+ { errors: errors }
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :audio_uri_validator
24
+
25
+ def validate_audio_uri(audio_uri, errors)
26
+ if audio_uri.nil? || (audio_uri.is_a?(String) && audio_uri.empty?)
27
+ errors[:audio_uri] = "cannot be empty"
28
+ return
29
+ end
30
+
31
+ audio_uri_validator.validate(audio_uri, errors, field: :audio_uri)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_validators"
4
+
5
+ module RunwayML
6
+ module Validators
7
+ class VoiceValidator
8
+ def validate(voice, errors)
9
+ if voice.nil?
10
+ errors[:voice] = "cannot be empty"
11
+ return
12
+ end
13
+
14
+ unless voice.is_a?(Hash)
15
+ errors[:voice] = "must be a hash"
16
+ return
17
+ end
18
+
19
+ voice_type = voice[:type]
20
+ preset_id = voice[:presetId]
21
+
22
+ unless SpeechToSpeech::VALID_VOICE_TYPES.include?(voice_type)
23
+ errors[:voice] = "type must be one of: #{SpeechToSpeech::VALID_VOICE_TYPES.join(', ')}"
24
+ end
25
+
26
+ unless SpeechToSpeech::VALID_PRESET_IDS.include?(preset_id)
27
+ errors[:voice] = "presetId must be one of: #{SpeechToSpeech::VALID_PRESET_IDS.join(', ')}" if errors[:voice].nil?
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RunwayML
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "media_processor"
5
+ require_relative "validators/voice_dubbing_validator"
6
+
7
+ module RunwayML
8
+ class VoiceDubbing
9
+ VALID_MODELS = [ "eleven_voice_dubbing" ].freeze
10
+ VALID_TARGET_LANGS = [
11
+ "en", "hi", "pt", "zh", "es", "fr", "de", "ja", "ar", "ru", "ko", "id", "it", "nl", "tr",
12
+ "pl", "sv", "fil", "ms", "ro", "uk", "el", "cs", "da", "fi", "bg", "hr", "sk", "ta"
13
+ ].freeze
14
+
15
+ def initialize(client:)
16
+ @client = client
17
+ end
18
+
19
+ def create(model:, audio_uri:, target_lang:, disable_voice_cloning: nil, drop_background_audio: nil, num_speakers: nil, auto_upload: true)
20
+ errors = {}
21
+
22
+ unless VALID_MODELS.include?(model)
23
+ errors[:model] = "must be one of: #{VALID_MODELS.join(', ')}"
24
+ raise ValidationError, errors
25
+ end
26
+
27
+ # Process audio_uri with auto-upload support
28
+ processed_audio_uri = MediaProcessor.process(
29
+ audio_uri,
30
+ errors,
31
+ client: client,
32
+ auto_upload: auto_upload
33
+ )
34
+
35
+ validator = Validators::VoiceDubbingValidator.new
36
+ result = validator.validate(
37
+ audio_uri: processed_audio_uri,
38
+ target_lang: target_lang,
39
+ disable_voice_cloning: disable_voice_cloning,
40
+ drop_background_audio: drop_background_audio,
41
+ num_speakers: num_speakers
42
+ )
43
+
44
+ errors = result[:errors]
45
+ raise ValidationError, errors unless errors.empty?
46
+
47
+ inputs = build_inputs(processed_audio_uri, target_lang, disable_voice_cloning, drop_background_audio, num_speakers)
48
+
49
+ response = client.post(
50
+ "voice_dubbing",
51
+ inputs.merge(model: model)
52
+ )
53
+
54
+ Task.new(id: response["id"], client: client)
55
+ end
56
+
57
+ private
58
+
59
+ attr_reader :client
60
+
61
+ def build_inputs(audio_uri, target_lang, disable_voice_cloning, drop_background_audio, num_speakers)
62
+ inputs = {
63
+ audioUri: audio_uri,
64
+ targetLang: target_lang
65
+ }
66
+
67
+ inputs[:disableVoiceCloning] = disable_voice_cloning unless disable_voice_cloning.nil?
68
+ inputs[:dropBackgroundAudio] = drop_background_audio unless drop_background_audio.nil?
69
+ inputs[:numSpeakers] = num_speakers unless num_speakers.nil?
70
+
71
+ inputs
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "media_processor"
5
+ require_relative "validators/voice_isolation_validator"
6
+
7
+ module RunwayML
8
+ class VoiceIsolation
9
+ VALID_MODELS = [ "eleven_voice_isolation" ].freeze
10
+
11
+ def initialize(client:)
12
+ @client = client
13
+ end
14
+
15
+ def create(model:, audio_uri:, auto_upload: true)
16
+ errors = {}
17
+
18
+ unless VALID_MODELS.include?(model)
19
+ errors[:model] = "must be one of: #{VALID_MODELS.join(', ')}"
20
+ raise ValidationError, errors
21
+ end
22
+
23
+ # Process audio_uri with auto-upload support
24
+ processed_audio_uri = MediaProcessor.process(
25
+ audio_uri,
26
+ errors,
27
+ client: client,
28
+ auto_upload: auto_upload
29
+ )
30
+
31
+ validator = Validators::VoiceIsolationValidator.new
32
+ result = validator.validate(audio_uri: processed_audio_uri)
33
+
34
+ errors = result[:errors]
35
+ raise ValidationError, errors unless errors.empty?
36
+
37
+ inputs = build_inputs(processed_audio_uri)
38
+
39
+ response = client.post(
40
+ "voice_isolation",
41
+ inputs.merge(model: model)
42
+ )
43
+
44
+ Task.new(id: response["id"], client: client)
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :client
50
+
51
+ def build_inputs(audio_uri)
52
+ {
53
+ audioUri: audio_uri
54
+ }
55
+ end
56
+ end
57
+ end