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,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RunwayML
4
+ # Builds validators from OpenAPI schema definitions
5
+ module ValidatorBuilder
6
+ class << self
7
+ def build_enum_validator(enum_values)
8
+ ->(value, errors, field_name) {
9
+ unless enum_values.include?(value)
10
+ errors[field_name] = "must be one of: #{enum_values.join(', ')}, got #{value.inspect}"
11
+ end
12
+ }
13
+ end
14
+
15
+ def build_string_length_validator(min_length: nil, max_length: nil)
16
+ ->(value, errors, field_name) {
17
+ return if value.nil?
18
+
19
+ # Handle UTF-16 length for prompt text
20
+ if field_name.to_s.include?("prompt_text") || field_name.to_s.include?("promptText")
21
+ utf16_length = value.encode("UTF-16LE").bytesize / 2
22
+ length = utf16_length
23
+ else
24
+ length = value.to_s.length
25
+ end
26
+
27
+ if min_length && length < min_length
28
+ errors[field_name] = "must be at least #{min_length} characters, got #{length}"
29
+ end
30
+
31
+ if max_length && length > max_length
32
+ errors[field_name] = "must be at most #{max_length} characters, got #{length}"
33
+ end
34
+ }
35
+ end
36
+
37
+ def build_numeric_range_validator(minimum: nil, maximum: nil)
38
+ ->(value, errors, field_name) {
39
+ return if value.nil?
40
+
41
+ if minimum && value < minimum
42
+ errors[field_name] = "must be at least #{minimum}, got #{value}"
43
+ end
44
+
45
+ if maximum && value > maximum
46
+ errors[field_name] = "must be at most #{maximum}, got #{value}"
47
+ end
48
+ }
49
+ end
50
+
51
+ def extract_constraints_from_schema(schema)
52
+ constraints = {}
53
+
54
+ if schema["type"] == "string"
55
+ constraints[:string_length] = {
56
+ min_length: schema["minLength"],
57
+ max_length: schema["maxLength"]
58
+ }.compact
59
+ end
60
+
61
+ if schema["type"] == "number" || schema["type"] == "integer"
62
+ constraints[:numeric_range] = {
63
+ minimum: schema["minimum"],
64
+ maximum: schema["maximum"]
65
+ }.compact
66
+ end
67
+
68
+ if schema["enum"]
69
+ constraints[:enum] = schema["enum"]
70
+ end
71
+
72
+ constraints
73
+ end
74
+
75
+ def build_from_openapi_property(property_schema, field_name)
76
+ constraints = extract_constraints_from_schema(property_schema)
77
+ validators = []
78
+
79
+ if constraints[:enum]
80
+ validators << build_enum_validator(constraints[:enum])
81
+ end
82
+
83
+ if constraints[:string_length].any?
84
+ validators << build_string_length_validator(**constraints[:string_length])
85
+ end
86
+
87
+ if constraints[:numeric_range].any?
88
+ validators << build_numeric_range_validator(**constraints[:numeric_range])
89
+ end
90
+
91
+ validators
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RunwayML
4
+ module Validators
5
+ class RatioValidator
6
+ def initialize(valid_ratios:)
7
+ @valid_ratios = valid_ratios
8
+ end
9
+
10
+ def validate(ratio, errors)
11
+ unless valid_ratios.include?(ratio)
12
+ errors[:ratio] = "must be one of: #{valid_ratios.join(', ')}"
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :valid_ratios
19
+ end
20
+
21
+ class PromptTextValidator
22
+ def initialize(max_length:, required: true)
23
+ @max_length = max_length
24
+ @required = required
25
+ end
26
+
27
+ def validate(prompt_text, errors)
28
+ if prompt_text.nil? || prompt_text.empty?
29
+ errors[:prompt_text] = "cannot be empty" if required
30
+ return
31
+ end
32
+
33
+ utf16_length = prompt_text.encode("UTF-16LE").bytesize / 2
34
+ if utf16_length > max_length
35
+ errors[:prompt_text] = "must be at most #{max_length} characters (UTF-16 code units), got #{utf16_length}"
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :max_length, :required
42
+ end
43
+
44
+ class DurationValidator
45
+ def initialize(valid_values: nil, range: nil, exact: nil)
46
+ @valid_values = valid_values
47
+ @range = range
48
+ @exact = exact
49
+ end
50
+
51
+ def validate(duration, errors)
52
+ return unless duration
53
+
54
+ if exact
55
+ unless duration == exact
56
+ errors[:duration] = "must be exactly #{exact}"
57
+ end
58
+ elsif valid_values
59
+ unless valid_values.include?(duration)
60
+ errors[:duration] = "must be one of: #{valid_values.join(', ')}"
61
+ end
62
+ elsif range
63
+ unless range.include?(duration)
64
+ errors[:duration] = "must be between #{range.min} and #{range.max} seconds"
65
+ end
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ attr_reader :valid_values, :range, :exact
72
+ end
73
+
74
+ class SeedValidator
75
+ def initialize(range:)
76
+ @range = range
77
+ end
78
+
79
+ def validate(seed, errors)
80
+ return unless seed
81
+
82
+ unless range.include?(seed)
83
+ errors[:seed] = "must be between #{range.min} and #{range.max}"
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ attr_reader :range
90
+ end
91
+
92
+ class PublicFigureThresholdValidator
93
+ def initialize(valid_thresholds:)
94
+ @valid_thresholds = valid_thresholds
95
+ end
96
+
97
+ def validate(public_figure_threshold, errors)
98
+ return unless public_figure_threshold
99
+
100
+ unless valid_thresholds.include?(public_figure_threshold)
101
+ errors[:public_figure_threshold] = "must be one of: #{valid_thresholds.join(', ')}"
102
+ end
103
+ end
104
+
105
+ private
106
+
107
+ attr_reader :valid_thresholds
108
+ end
109
+
110
+ class AudioValidator
111
+ def validate(audio, errors)
112
+ return if audio.nil?
113
+
114
+ unless [ true, false ].include?(audio)
115
+ errors[:audio] = "must be true or false"
116
+ end
117
+ end
118
+ end
119
+
120
+ class ImageUriValidator
121
+ VALID_CONTENT_TYPES = [ "image/jpeg", "image/jpg", "image/png", "image/webp" ].freeze
122
+
123
+ def validate(uri, errors, field: :prompt_image)
124
+ return unless uri.is_a?(String)
125
+
126
+ if uri.length < 13
127
+ errors[field] = "URI must be at least 13 characters"
128
+ return
129
+ end
130
+
131
+ if uri.start_with?("https://")
132
+ validate_https_url(uri, errors, field)
133
+ elsif uri.start_with?("runway://")
134
+ validate_runway_uri(uri, errors, field)
135
+ elsif uri.start_with?("data:image/")
136
+ validate_data_uri(uri, errors, field)
137
+ else
138
+ errors[field] = "must be a valid HTTPS URL, Runway URI (runway://), or data URI (data:image/)"
139
+ end
140
+ end
141
+
142
+ private
143
+
144
+ def validate_https_url(uri, errors, field)
145
+ if uri.length > 2048
146
+ errors[field] = "HTTPS URL must be at most 2048 characters"
147
+ end
148
+ end
149
+
150
+ def validate_runway_uri(uri, errors, field)
151
+ if uri.length > 5000
152
+ errors[field] = "Runway URI must be at most 5000 characters"
153
+ end
154
+ end
155
+
156
+ def validate_data_uri(uri, errors, field)
157
+ if uri.length > 5242880
158
+ errors[field] = "Data URI must be at most 5242880 characters"
159
+ return
160
+ end
161
+
162
+ content_type_match = uri.match(/^data:(image\/[^;,]+)/)
163
+ return unless content_type_match
164
+
165
+ content_type = content_type_match[1]
166
+ return if VALID_CONTENT_TYPES.include?(content_type)
167
+
168
+ if content_type == "image/gif"
169
+ errors[field] = "GIF images are not supported. Use JPEG, PNG, or WebP"
170
+ else
171
+ errors[field] = "unsupported image type '#{content_type}'. Must be JPEG (image/jpeg or image/jpg), PNG (image/png), or WebP (image/webp)"
172
+ end
173
+ end
174
+ end
175
+
176
+ class VideoUriValidator
177
+ VALID_CONTENT_TYPES = [ "video/mp4", "video/quicktime", "video/x-matroska", "video/webm", "video/3gpp", "video/ogg", "video/x-msvideo", "video/x-flv", "video/mpeg" ].freeze
178
+
179
+ def validate(uri, errors, field: :video, max_data_uri_size: 16777216)
180
+ return unless uri.is_a?(String)
181
+
182
+ if uri.length < 13
183
+ errors[field] = "URI must be at least 13 characters"
184
+ return
185
+ end
186
+
187
+ if uri.start_with?("https://")
188
+ validate_https_url(uri, errors, field)
189
+ elsif uri.start_with?("runway://")
190
+ validate_runway_uri(uri, errors, field)
191
+ elsif uri.start_with?("data:video/")
192
+ validate_data_uri(uri, errors, field, max_data_uri_size)
193
+ else
194
+ errors[field] = "must be a valid HTTPS URL, Runway URI (runway://), or data URI (data:video/)"
195
+ end
196
+ end
197
+
198
+ private
199
+
200
+ def validate_https_url(uri, errors, field)
201
+ if uri.length > 2048
202
+ errors[field] = "HTTPS URL must be at most 2048 characters"
203
+ end
204
+ end
205
+
206
+ def validate_runway_uri(uri, errors, field)
207
+ if uri.length > 5000
208
+ errors[field] = "Runway URI must be at most 5000 characters"
209
+ end
210
+ end
211
+
212
+ def validate_data_uri(uri, errors, field, max_size)
213
+ if uri.length > max_size
214
+ errors[field] = "Data URI must be at most #{max_size} characters"
215
+ return
216
+ end
217
+
218
+ content_type_match = uri.match(/^data:(video\/[^;,]+)/)
219
+ return unless content_type_match
220
+
221
+ content_type = content_type_match[1]
222
+ errors[field] = "unsupported video type '#{content_type}'" unless VALID_CONTENT_TYPES.include?(content_type)
223
+ end
224
+ end
225
+
226
+ class BodyControlValidator
227
+ def validate(body_control, errors)
228
+ return if body_control.nil?
229
+
230
+ unless [ true, false ].include?(body_control)
231
+ errors[:body_control] = "must be true or false"
232
+ end
233
+ end
234
+ end
235
+
236
+ class ExpressionIntensityValidator
237
+ def initialize(range: 1..5)
238
+ @range = range
239
+ end
240
+
241
+ def validate(expression_intensity, errors)
242
+ return if expression_intensity.nil?
243
+
244
+ unless range.include?(expression_intensity)
245
+ errors[:expression_intensity] = "must be between #{range.min} and #{range.max}"
246
+ end
247
+ end
248
+
249
+ private
250
+
251
+ attr_reader :range
252
+ end
253
+
254
+ class CharacterValidator
255
+ def initialize
256
+ @video_uri_validator = VideoUriValidator.new
257
+ end
258
+
259
+ def validate(character, errors)
260
+ return if character.nil?
261
+
262
+ unless character.is_a?(Hash)
263
+ errors[:character] = "must be a Hash with 'type' and 'uri'"
264
+ return
265
+ end
266
+
267
+ type = character[:type]
268
+ uri = character[:uri]
269
+
270
+ unless [ "image", "video" ].include?(type)
271
+ errors[:character] = "type must be 'image' or 'video'"
272
+ return
273
+ end
274
+
275
+ if type == "image"
276
+ # Reuse ImageUriValidator for image validation
277
+ ImageUriValidator.new.validate(uri, errors, field: :character)
278
+ elsif type == "video"
279
+ @video_uri_validator.validate(uri, errors, field: :character)
280
+ end
281
+ end
282
+
283
+ private
284
+
285
+ attr_reader :video_uri_validator
286
+ end
287
+
288
+ class ReferenceVideoValidator
289
+ def initialize
290
+ @video_uri_validator = VideoUriValidator.new
291
+ end
292
+
293
+ def validate(reference, errors)
294
+ return if reference.nil?
295
+
296
+ unless reference.is_a?(Hash)
297
+ errors[:reference] = "must be a Hash with 'type' and 'uri'"
298
+ return
299
+ end
300
+
301
+ type = reference[:type]
302
+ uri = reference[:uri]
303
+
304
+ unless type == "video"
305
+ errors[:reference] = "type must be 'video'"
306
+ return
307
+ end
308
+
309
+ @video_uri_validator.validate(uri, errors, field: :reference)
310
+ end
311
+
312
+ private
313
+
314
+ attr_reader :video_uri_validator
315
+ end
316
+
317
+ class UUIDValidator
318
+ # RFC 4122 UUID v4 pattern from OpenAPI spec
319
+ UUID_PATTERN = /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$/
320
+
321
+ def validate(uuid, errors, field: :id)
322
+ if uuid.nil?
323
+ errors[field] = "cannot be nil"
324
+ return
325
+ end
326
+
327
+ unless uuid.is_a?(String)
328
+ errors[field] = "must be a string"
329
+ return
330
+ end
331
+
332
+ unless uuid.match?(UUID_PATTERN)
333
+ errors[field] = "must be a valid UUID (RFC 4122 v4)"
334
+ end
335
+ end
336
+ end
337
+ end
338
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_validators"
4
+
5
+ module RunwayML
6
+ module Validators
7
+ class CharacterPerformanceValidator
8
+ def initialize
9
+ @ratio_validator = RatioValidator.new(
10
+ valid_ratios: [ "1280:720", "720:1280", "960:960", "1104:832", "832:1104", "1584:672" ]
11
+ )
12
+ @seed_validator = SeedValidator.new(range: 0..4294967295)
13
+ @body_control_validator = BodyControlValidator.new
14
+ @expression_intensity_validator = ExpressionIntensityValidator.new(range: 1..5)
15
+ @character_validator = CharacterValidator.new
16
+ @reference_video_validator = ReferenceVideoValidator.new
17
+ @public_figure_threshold_validator = PublicFigureThresholdValidator.new(
18
+ valid_thresholds: [ "auto", "low" ]
19
+ )
20
+ end
21
+
22
+ def validate(character:, reference:, ratio:, seed: nil, body_control: nil, expression_intensity: nil, public_figure_threshold: nil)
23
+ errors = {}
24
+
25
+ character_validator.validate(character, errors)
26
+ reference_video_validator.validate(reference, errors)
27
+ ratio_validator.validate(ratio, errors)
28
+ seed_validator.validate(seed, errors)
29
+ body_control_validator.validate(body_control, errors)
30
+ expression_intensity_validator.validate(expression_intensity, errors)
31
+ public_figure_threshold_validator.validate(public_figure_threshold, errors)
32
+
33
+ { errors: errors }
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :ratio_validator, :seed_validator, :body_control_validator, :expression_intensity_validator, :character_validator, :reference_video_validator, :public_figure_threshold_validator
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,48 @@
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 Gen3aTurboValidator
9
+ def initialize(image_processor:)
10
+ @image_processor = image_processor
11
+ @ratio_validator = RatioValidator.new(valid_ratios: [ "768:1280", "1280:768" ])
12
+ @prompt_text_validator = PromptTextValidator.new(max_length: 1000, required: true)
13
+ @duration_validator = DurationValidator.new(valid_values: [ 5, 10 ])
14
+ @seed_validator = SeedValidator.new(range: 0..4294967295)
15
+ @public_figure_threshold_validator = PublicFigureThresholdValidator.new(
16
+ valid_thresholds: [ "auto", "low" ]
17
+ )
18
+ @prompt_image_validator = PromptImageValidator.new(
19
+ image_processor: image_processor,
20
+ array_size: 1..2,
21
+ valid_positions: [ "first", "last" ]
22
+ )
23
+ end
24
+
25
+ def validate(prompt_image:, prompt_text:, ratio:, duration:, seed:, public_figure_threshold:)
26
+ errors = {}
27
+
28
+ ratio_validator.validate(ratio, errors)
29
+ prompt_text_validator.validate(prompt_text, errors)
30
+ duration_validator.validate(duration, errors)
31
+ seed_validator.validate(seed, errors)
32
+ public_figure_threshold_validator.validate(public_figure_threshold, errors)
33
+
34
+ # Process and validate prompt_image
35
+ processed_prompt_image = image_processor.process(prompt_image, errors)
36
+ prompt_image_validator.validate(processed_prompt_image, errors)
37
+
38
+ { errors: errors, processed_prompt_image: processed_prompt_image }
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :image_processor, :ratio_validator, :prompt_text_validator,
44
+ :duration_validator, :seed_validator, :public_figure_threshold_validator,
45
+ :prompt_image_validator
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,50 @@
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 Gen4TurboValidator
9
+ def initialize(image_processor:)
10
+ @image_processor = image_processor
11
+ @ratio_validator = RatioValidator.new(
12
+ valid_ratios: [ "1280:720", "720:1280", "1104:832", "832:1104", "960:960", "1584:672" ]
13
+ )
14
+ @prompt_text_validator = PromptTextValidator.new(max_length: 1000, required: true)
15
+ @duration_validator = DurationValidator.new(range: 2..10)
16
+ @seed_validator = SeedValidator.new(range: 0..4294967295)
17
+ @public_figure_threshold_validator = PublicFigureThresholdValidator.new(
18
+ valid_thresholds: [ "auto", "low" ]
19
+ )
20
+ @prompt_image_validator = PromptImageValidator.new(
21
+ image_processor: image_processor,
22
+ array_size: 1,
23
+ valid_positions: [ "first" ]
24
+ )
25
+ end
26
+
27
+ def validate(prompt_image:, prompt_text:, ratio:, duration:, seed:, public_figure_threshold:)
28
+ errors = {}
29
+
30
+ ratio_validator.validate(ratio, errors)
31
+ prompt_text_validator.validate(prompt_text, errors)
32
+ duration_validator.validate(duration, errors)
33
+ seed_validator.validate(seed, errors)
34
+ public_figure_threshold_validator.validate(public_figure_threshold, errors)
35
+
36
+ # Process and validate prompt_image
37
+ processed_prompt_image = image_processor.process(prompt_image, errors)
38
+ prompt_image_validator.validate(processed_prompt_image, errors)
39
+
40
+ { errors: errors, processed_prompt_image: processed_prompt_image }
41
+ end
42
+
43
+ private
44
+
45
+ attr_reader :image_processor, :ratio_validator, :prompt_text_validator,
46
+ :duration_validator, :seed_validator, :public_figure_threshold_validator,
47
+ :prompt_image_validator
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_validators"
4
+
5
+ module RunwayML
6
+ module Validators
7
+ class PromptImageValidator
8
+ def initialize(image_processor:, array_size:, valid_positions:, only_first_frame: false)
9
+ @image_processor = image_processor
10
+ @array_size = array_size
11
+ @valid_positions = valid_positions
12
+ @only_first_frame = only_first_frame
13
+ @image_uri_validator = ImageUriValidator.new
14
+ end
15
+
16
+ def validate(prompt_image, errors)
17
+ if prompt_image.nil?
18
+ errors[:prompt_image] = "is required"
19
+ return
20
+ end
21
+
22
+ if prompt_image.is_a?(String)
23
+ image_uri_validator.validate(prompt_image, errors)
24
+ elsif prompt_image.is_a?(Array)
25
+ validate_array_format(prompt_image, errors)
26
+ else
27
+ errors[:prompt_image] = "must be a string or an array"
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :image_processor, :array_size, :valid_positions, :only_first_frame, :image_uri_validator
34
+
35
+ def validate_array_format(prompt_image, errors)
36
+ if array_size.is_a?(Range)
37
+ unless array_size.include?(prompt_image.length)
38
+ errors[:prompt_image] = "array must contain #{array_size.min} to #{array_size.max} items"
39
+ return
40
+ end
41
+ elsif prompt_image.length != array_size
42
+ errors[:prompt_image] = "array must contain exactly #{array_size} item#{'s' if array_size > 1}"
43
+ return
44
+ end
45
+
46
+ validate_array_items(prompt_image, errors)
47
+ validate_array_rules(prompt_image, errors)
48
+ end
49
+
50
+ def validate_array_items(prompt_image, errors)
51
+ prompt_image.each_with_index do |item, index|
52
+ unless item.is_a?(Hash)
53
+ errors[:prompt_image] = "array items must be objects with 'uri' and 'position' fields"
54
+ return
55
+ end
56
+
57
+ unless item.key?(:uri) || item.key?("uri")
58
+ errors[:prompt_image] = "array item #{index + 1} must have 'uri' field"
59
+ end
60
+
61
+ unless item.key?(:position) || item.key?("position")
62
+ errors[:prompt_image] = "array item #{index + 1} must have 'position' field"
63
+ end
64
+
65
+ position = item[:position] || item["position"]
66
+ if position && !valid_positions.include?(position)
67
+ errors[:prompt_image] = "position must be one of: #{valid_positions.join(', ')}"
68
+ end
69
+
70
+ uri = item[:uri] || item["uri"]
71
+ image_uri_validator.validate(uri, errors) if uri
72
+ end
73
+ end
74
+
75
+ def validate_array_rules(prompt_image, errors)
76
+ return if errors[:prompt_image] # Skip if there are already errors
77
+
78
+ # Rule: cannot generate with only a last frame (veo3.1 specific)
79
+ if only_first_frame && prompt_image.length == 1
80
+ item = prompt_image.first
81
+ position = item[:position] || item["position"]
82
+ if position == "last"
83
+ errors[:prompt_image] = "cannot generate with only a last frame. Must include a first frame."
84
+ end
85
+ end
86
+
87
+ # Rule: with 2 items, ensure one is "first" and one is "last"
88
+ if prompt_image.length == 2 && valid_positions.sort == [ "first", "last" ]
89
+ positions = prompt_image.map { |item| item[:position] || item["position"] }.compact
90
+ unless positions.sort == [ "first", "last" ]
91
+ errors[:prompt_image] = "array with 2 items must have one 'first' and one 'last' position"
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_validators"
4
+
5
+ module RunwayML
6
+ module Validators
7
+ class SoundEffectValidator
8
+ def initialize
9
+ @prompt_text_validator = PromptTextValidator.new(max_length: 3000, required: true)
10
+ @duration_validator = DurationValidator.new(range: 0.5..30)
11
+ end
12
+
13
+ def validate(prompt_text:, duration:, loop:)
14
+ errors = {}
15
+
16
+ prompt_text_validator.validate(prompt_text, errors)
17
+ duration_validator.validate(duration, errors) unless duration.nil?
18
+
19
+ { errors: errors }
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :prompt_text_validator, :duration_validator
25
+ end
26
+ end
27
+ end