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.
- checksums.yaml +7 -0
- data/.tool-versions +1 -0
- data/LICENSE.txt +21 -0
- data/OPENAPI_VALIDATION.md +100 -0
- data/README.md +789 -0
- data/Rakefile +11 -0
- data/lib/runway_ml/character_performance.rb +106 -0
- data/lib/runway_ml/client.rb +84 -0
- data/lib/runway_ml/contract_validator.rb +151 -0
- data/lib/runway_ml/errors.rb +179 -0
- data/lib/runway_ml/image_processor.rb +128 -0
- data/lib/runway_ml/image_to_video.rb +158 -0
- data/lib/runway_ml/media_processor.rb +343 -0
- data/lib/runway_ml/openapi_spec_loader.rb +120 -0
- data/lib/runway_ml/organization.rb +89 -0
- data/lib/runway_ml/sound_effect.rb +57 -0
- data/lib/runway_ml/spec_coverage_analyzer.rb +134 -0
- data/lib/runway_ml/speech_to_speech.rb +90 -0
- data/lib/runway_ml/task.rb +116 -0
- data/lib/runway_ml/test_client.rb +62 -0
- data/lib/runway_ml/text_to_speech.rb +52 -0
- data/lib/runway_ml/text_to_video.rb +103 -0
- data/lib/runway_ml/uploads.rb +74 -0
- data/lib/runway_ml/validator_builder.rb +95 -0
- data/lib/runway_ml/validators/base_validators.rb +338 -0
- data/lib/runway_ml/validators/character_performance_validator.rb +41 -0
- data/lib/runway_ml/validators/gen3a_turbo_validator.rb +48 -0
- data/lib/runway_ml/validators/gen4_turbo_validator.rb +50 -0
- data/lib/runway_ml/validators/prompt_image_validator.rb +97 -0
- data/lib/runway_ml/validators/sound_effect_validator.rb +27 -0
- data/lib/runway_ml/validators/speech_to_speech_validator.rb +57 -0
- data/lib/runway_ml/validators/text_to_speech_validator.rb +28 -0
- data/lib/runway_ml/validators/text_veo3_stable_validator.rb +35 -0
- data/lib/runway_ml/validators/text_veo3_validator.rb +33 -0
- data/lib/runway_ml/validators/uploads_validator.rb +44 -0
- data/lib/runway_ml/validators/veo3_stable_validator.rb +43 -0
- data/lib/runway_ml/validators/veo3_validator.rb +46 -0
- data/lib/runway_ml/validators/voice_dubbing_validator.rb +128 -0
- data/lib/runway_ml/validators/voice_isolation_validator.rb +35 -0
- data/lib/runway_ml/validators/voice_validator.rb +32 -0
- data/lib/runway_ml/version.rb +5 -0
- data/lib/runway_ml/voice_dubbing.rb +74 -0
- data/lib/runway_ml/voice_isolation.rb +57 -0
- data/lib/runway_ml.rb +81 -0
- data/lib/tasks/openapi.rake +33 -0
- metadata +90 -0
data/Rakefile
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "media_processor"
|
|
5
|
+
require_relative "validators/character_performance_validator"
|
|
6
|
+
|
|
7
|
+
module RunwayML
|
|
8
|
+
class CharacterPerformance
|
|
9
|
+
VALID_MODELS = [ "act_two" ].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(client:)
|
|
12
|
+
@client = client
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def create(model:, character:, reference:, ratio:, seed: nil, body_control: nil, expression_intensity: nil, public_figure_threshold: nil, auto_upload: true)
|
|
16
|
+
errors = {}
|
|
17
|
+
|
|
18
|
+
# Validate model
|
|
19
|
+
unless VALID_MODELS.include?(model)
|
|
20
|
+
errors[:model] = "must be one of: #{VALID_MODELS.join(', ')}"
|
|
21
|
+
raise ValidationError, errors
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Process character and reference with auto-upload support
|
|
25
|
+
# They can be hashes with 'uri' keys containing file paths
|
|
26
|
+
processed_character = if character.is_a?(Hash) && character[:uri]
|
|
27
|
+
processed_uri = MediaProcessor.process(
|
|
28
|
+
character[:uri],
|
|
29
|
+
errors,
|
|
30
|
+
client: client,
|
|
31
|
+
auto_upload: auto_upload
|
|
32
|
+
)
|
|
33
|
+
character.dup.tap { |h| h[:uri] = processed_uri }
|
|
34
|
+
else
|
|
35
|
+
MediaProcessor.process(
|
|
36
|
+
character,
|
|
37
|
+
errors,
|
|
38
|
+
client: client,
|
|
39
|
+
auto_upload: auto_upload
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
processed_reference = if reference.is_a?(Hash) && reference[:uri]
|
|
44
|
+
processed_uri = MediaProcessor.process(
|
|
45
|
+
reference[:uri],
|
|
46
|
+
errors,
|
|
47
|
+
client: client,
|
|
48
|
+
auto_upload: auto_upload
|
|
49
|
+
)
|
|
50
|
+
reference.dup.tap { |h| h[:uri] = processed_uri }
|
|
51
|
+
else
|
|
52
|
+
MediaProcessor.process(
|
|
53
|
+
reference,
|
|
54
|
+
errors,
|
|
55
|
+
client: client,
|
|
56
|
+
auto_upload: auto_upload
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
validator = Validators::CharacterPerformanceValidator.new
|
|
61
|
+
result = validator.validate(
|
|
62
|
+
character: processed_character,
|
|
63
|
+
reference: processed_reference,
|
|
64
|
+
ratio: ratio,
|
|
65
|
+
seed: seed,
|
|
66
|
+
body_control: body_control,
|
|
67
|
+
expression_intensity: expression_intensity,
|
|
68
|
+
public_figure_threshold: public_figure_threshold
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
errors = result[:errors]
|
|
72
|
+
raise ValidationError, errors unless errors.empty?
|
|
73
|
+
|
|
74
|
+
inputs = build_inputs(processed_character, processed_reference, ratio, seed, body_control, expression_intensity, public_figure_threshold)
|
|
75
|
+
|
|
76
|
+
response = client.post(
|
|
77
|
+
"character_performance",
|
|
78
|
+
inputs.merge(model: model)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
Task.new(id: response["id"], client: client)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
attr_reader :client
|
|
87
|
+
|
|
88
|
+
def build_inputs(character, reference, ratio, seed, body_control, expression_intensity, public_figure_threshold)
|
|
89
|
+
inputs = {
|
|
90
|
+
character: character,
|
|
91
|
+
reference: reference,
|
|
92
|
+
ratio: ratio
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
inputs[:seed] = seed unless seed.nil?
|
|
96
|
+
inputs[:bodyControl] = body_control unless body_control.nil?
|
|
97
|
+
inputs[:expressionIntensity] = expression_intensity unless expression_intensity.nil?
|
|
98
|
+
|
|
99
|
+
unless public_figure_threshold.nil?
|
|
100
|
+
inputs[:contentModeration] = { publicFigureThreshold: public_figure_threshold }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
inputs
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module RunwayML
|
|
7
|
+
class Client
|
|
8
|
+
def initialize(api_secret:, base_url: "https://api.dev.runwayml.com/v1/", api_version: "2024-11-06")
|
|
9
|
+
@api_secret = api_secret
|
|
10
|
+
@base_url = base_url
|
|
11
|
+
@api_version = api_version
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def post(path, params)
|
|
15
|
+
request(:post, path, params)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def get(path)
|
|
19
|
+
request(:get, path)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def delete(path)
|
|
23
|
+
request(:delete, path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
attr_reader :api_secret, :base_url, :api_version
|
|
29
|
+
|
|
30
|
+
def request(method, path, params = nil)
|
|
31
|
+
uri = URI("#{base_url}#{path}")
|
|
32
|
+
|
|
33
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
|
|
34
|
+
request = build_request(method, uri, params)
|
|
35
|
+
response = http.request(request)
|
|
36
|
+
body = parse_body(response)
|
|
37
|
+
|
|
38
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
39
|
+
headers = response.to_hash.transform_keys(&:downcase)
|
|
40
|
+
error_message = body.is_a?(Hash) ? body["error"] : nil
|
|
41
|
+
raise APIError.generate(response.code.to_i, body, error_message, headers)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
body
|
|
45
|
+
end
|
|
46
|
+
rescue OpenSSL::SSL::SSLError => e
|
|
47
|
+
raise SSLError.new(message: e.message, cause: e)
|
|
48
|
+
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
|
|
49
|
+
raise APIConnectionError.new(message: e.message, cause: e)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def build_request(method, uri, params)
|
|
53
|
+
request_class = case method
|
|
54
|
+
when :get
|
|
55
|
+
Net::HTTP::Get
|
|
56
|
+
when :post
|
|
57
|
+
Net::HTTP::Post
|
|
58
|
+
when :delete
|
|
59
|
+
Net::HTTP::Delete
|
|
60
|
+
else
|
|
61
|
+
raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
request = request_class.new(uri)
|
|
65
|
+
request["Authorization"] = "Bearer #{api_secret}"
|
|
66
|
+
request["X-Runway-Version"] = api_version
|
|
67
|
+
|
|
68
|
+
if method == :post
|
|
69
|
+
request["Content-Type"] = "application/json"
|
|
70
|
+
request.body = JSON.generate(params || {})
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
request
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parse_body(response)
|
|
77
|
+
return nil if response.body.nil? || response.body.strip.empty?
|
|
78
|
+
|
|
79
|
+
JSON.parse(response.body)
|
|
80
|
+
rescue JSON::ParserError
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "openapi_spec_loader"
|
|
4
|
+
|
|
5
|
+
module RunwayML
|
|
6
|
+
# Helps validate SDK requests and responses against the OpenAPI spec
|
|
7
|
+
class ContractValidator
|
|
8
|
+
def initialize
|
|
9
|
+
@spec = OpenAPISpecLoader.load_spec
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Validates that a request body conforms to the OpenAPI spec
|
|
13
|
+
def validate_request(method, path, request_body)
|
|
14
|
+
schema = OpenAPISpecLoader.get_request_schema(method, path)
|
|
15
|
+
return { valid: true } unless schema
|
|
16
|
+
|
|
17
|
+
# Try to validate using JSON Schema
|
|
18
|
+
errors = validate_against_schema(request_body, schema, "Request")
|
|
19
|
+
errors.empty? ? { valid: true } : { valid: false, errors: errors }
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
{ valid: false, errors: [ e.message ] }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Validates that a response body conforms to the OpenAPI spec
|
|
25
|
+
def validate_response(method, path, response_body, status_code = 200)
|
|
26
|
+
schema = OpenAPISpecLoader.get_response_schema(method, path, status_code.to_s)
|
|
27
|
+
return { valid: true } unless schema
|
|
28
|
+
|
|
29
|
+
errors = validate_against_schema(response_body, schema, "Response")
|
|
30
|
+
errors.empty? ? { valid: true } : { valid: false, errors: errors }
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
{ valid: false, errors: [ e.message ] }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Validates a value against a schema with discriminator support
|
|
36
|
+
def validate_discriminated_oneOf(value, schema, discriminator_field)
|
|
37
|
+
return { valid: true } unless schema.dig("oneOf")
|
|
38
|
+
|
|
39
|
+
discriminator_value = value[discriminator_field]
|
|
40
|
+
return { valid: false, errors: [ "Missing discriminator field: #{discriminator_field}" ] } unless discriminator_value
|
|
41
|
+
|
|
42
|
+
matching_schemas = schema["oneOf"].select do |s|
|
|
43
|
+
s.dig("properties", discriminator_field, "const") == discriminator_value ||
|
|
44
|
+
s["title"] == discriminator_value
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
return { valid: false, errors: [ "No matching schema for #{discriminator_field}: #{discriminator_value}" ] } if matching_schemas.empty?
|
|
48
|
+
|
|
49
|
+
# Validate against the first matching schema
|
|
50
|
+
errors = validate_against_schema(value, matching_schemas.first, "Discriminated Schema")
|
|
51
|
+
errors.empty? ? { valid: true } : { valid: false, errors: errors }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validate_against_schema(data, schema, context = "Schema")
|
|
55
|
+
return [] if schema.nil?
|
|
56
|
+
|
|
57
|
+
errors = []
|
|
58
|
+
|
|
59
|
+
# Handle oneOf schemas (discriminated unions)
|
|
60
|
+
if schema["oneOf"]
|
|
61
|
+
model_value = data[schema["discriminator"]["propertyName"]] if schema["discriminator"]
|
|
62
|
+
matching_schemas = schema["oneOf"].select do |s|
|
|
63
|
+
s.dig("properties", schema["discriminator"]["propertyName"], "const") == model_value ||
|
|
64
|
+
s["title"] == model_value
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
if matching_schemas.any?
|
|
68
|
+
errors.concat(validate_against_schema(data, matching_schemas.first, context))
|
|
69
|
+
return errors
|
|
70
|
+
else
|
|
71
|
+
return [ "#{context}: No matching schema for model: #{model_value}" ]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Basic type checking
|
|
76
|
+
if schema["type"]
|
|
77
|
+
unless matches_type?(data, schema["type"])
|
|
78
|
+
errors << "#{context}: Expected type #{schema['type']}, got #{data.class.name.downcase}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# String constraints
|
|
83
|
+
if schema["type"] == "string" && data.is_a?(String)
|
|
84
|
+
if schema["minLength"] && data.length < schema["minLength"]
|
|
85
|
+
errors << "#{context}: String too short (minimum #{schema['minLength']} characters)"
|
|
86
|
+
end
|
|
87
|
+
if schema["maxLength"] && data.length > schema["maxLength"]
|
|
88
|
+
errors << "#{context}: String too long (maximum #{schema['maxLength']} characters)"
|
|
89
|
+
end
|
|
90
|
+
if schema["pattern"] && !data.match?(Regexp.new(schema["pattern"]))
|
|
91
|
+
errors << "#{context}: String does not match pattern #{schema['pattern']}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Numeric constraints
|
|
96
|
+
if (schema["type"] == "number" || schema["type"] == "integer") && data.is_a?(Numeric)
|
|
97
|
+
if schema["minimum"] && data < schema["minimum"]
|
|
98
|
+
errors << "#{context}: Number below minimum #{schema['minimum']}"
|
|
99
|
+
end
|
|
100
|
+
if schema["maximum"] && data > schema["maximum"]
|
|
101
|
+
errors << "#{context}: Number above maximum #{schema['maximum']}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Enum validation
|
|
106
|
+
if schema["enum"] && !schema["enum"].include?(data)
|
|
107
|
+
errors << "#{context}: #{data.inspect} not in enum #{schema['enum']}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Required fields for objects
|
|
111
|
+
if schema["type"] == "object" && data.is_a?(Hash)
|
|
112
|
+
required_fields = schema["required"] || []
|
|
113
|
+
missing_fields = required_fields.reject { |f| data.key?(f) || data.key?(f.to_sym) }
|
|
114
|
+
if missing_fields.any?
|
|
115
|
+
errors << "#{context}: Missing required fields: #{missing_fields.join(', ')}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Validate properties if defined
|
|
119
|
+
properties = schema["properties"] || {}
|
|
120
|
+
properties.each do |prop_name, prop_schema|
|
|
121
|
+
value = data[prop_name] || data[prop_name.to_sym]
|
|
122
|
+
next unless value
|
|
123
|
+
|
|
124
|
+
prop_errors = validate_against_schema(value, prop_schema, "#{context}.#{prop_name}")
|
|
125
|
+
errors.concat(prop_errors)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
errors
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def matches_type?(data, type)
|
|
133
|
+
case type
|
|
134
|
+
when "string"
|
|
135
|
+
data.is_a?(String)
|
|
136
|
+
when "number"
|
|
137
|
+
data.is_a?(Numeric)
|
|
138
|
+
when "integer"
|
|
139
|
+
data.is_a?(Integer)
|
|
140
|
+
when "boolean"
|
|
141
|
+
data.is_a?(TrueClass) || data.is_a?(FalseClass)
|
|
142
|
+
when "object"
|
|
143
|
+
data.is_a?(Hash)
|
|
144
|
+
when "array"
|
|
145
|
+
data.is_a?(Array)
|
|
146
|
+
else
|
|
147
|
+
true
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RunwayML
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
class APIError < Error
|
|
8
|
+
attr_reader :status, :headers, :error
|
|
9
|
+
|
|
10
|
+
def initialize(status, error, message, headers)
|
|
11
|
+
@status = status
|
|
12
|
+
@headers = headers
|
|
13
|
+
@error = error
|
|
14
|
+
super(make_message(status, error, message))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.generate(status, error_response, message, headers)
|
|
18
|
+
return APIConnectionError.new(message: message) unless status && headers
|
|
19
|
+
|
|
20
|
+
case status
|
|
21
|
+
when 400
|
|
22
|
+
BadRequestError.new(status, error_response, message, headers)
|
|
23
|
+
when 401
|
|
24
|
+
AuthenticationError.new(status, error_response, message, headers)
|
|
25
|
+
when 403
|
|
26
|
+
PermissionDeniedError.new(status, error_response, message, headers)
|
|
27
|
+
when 404
|
|
28
|
+
NotFoundError.new(status, error_response, message, headers)
|
|
29
|
+
when 409
|
|
30
|
+
ConflictError.new(status, error_response, message, headers)
|
|
31
|
+
when 422
|
|
32
|
+
UnprocessableEntityError.new(status, error_response, message, headers)
|
|
33
|
+
when 429
|
|
34
|
+
RateLimitError.new(status, error_response, message, headers)
|
|
35
|
+
when 500..599
|
|
36
|
+
InternalServerError.new(status, error_response, message, headers)
|
|
37
|
+
else
|
|
38
|
+
new(status, error_response, message, headers)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def make_message(status, error, message)
|
|
45
|
+
msg = if error.is_a?(Hash) && error["message"]
|
|
46
|
+
error["message"]
|
|
47
|
+
elsif error
|
|
48
|
+
error.to_s
|
|
49
|
+
else
|
|
50
|
+
message
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
if status && msg
|
|
54
|
+
"#{status} #{msg}"
|
|
55
|
+
elsif status
|
|
56
|
+
"#{status} status code (no body)"
|
|
57
|
+
elsif msg
|
|
58
|
+
msg
|
|
59
|
+
else
|
|
60
|
+
"(no status code or body)"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class APIConnectionError < APIError
|
|
66
|
+
def initialize(message: nil, cause: nil)
|
|
67
|
+
@cause = cause
|
|
68
|
+
super(nil, nil, message || "Connection error.", nil)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
attr_reader :cause
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class APIConnectionTimeoutError < APIConnectionError
|
|
75
|
+
def initialize(message: nil)
|
|
76
|
+
super(message: message || "Request timed out.")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class SSLError < APIConnectionError
|
|
81
|
+
def initialize(message: nil, cause: nil)
|
|
82
|
+
ssl_message = "SSL verification failed: #{message}. " \
|
|
83
|
+
"This may be due to outdated SSL certificates on your system. " \
|
|
84
|
+
"Try updating your system's SSL certificates or OpenSSL installation."
|
|
85
|
+
super(message: ssl_message, cause: cause)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
class BadRequestError < APIError
|
|
90
|
+
def initialize(status, error, message, headers)
|
|
91
|
+
@validation_issues = extract_validation_issues(error) if error.is_a?(Hash)
|
|
92
|
+
super
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
attr_reader :validation_issues
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def extract_validation_issues(error)
|
|
100
|
+
return [] unless error["issues"].is_a?(Array)
|
|
101
|
+
|
|
102
|
+
error["issues"].map do |issue|
|
|
103
|
+
{
|
|
104
|
+
path: issue["path"]&.join(".") || "unknown",
|
|
105
|
+
message: issue["message"] || "Invalid value",
|
|
106
|
+
code: issue["code"]
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def make_message(status, error, message)
|
|
112
|
+
return super unless @validation_issues&.any?
|
|
113
|
+
|
|
114
|
+
base = "400 Bad Request - Validation failed:\n"
|
|
115
|
+
issues_msg = @validation_issues.map do |issue|
|
|
116
|
+
" - #{issue[:path]}: #{issue[:message]}"
|
|
117
|
+
end.join("\n")
|
|
118
|
+
|
|
119
|
+
base + issues_msg
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
class AuthenticationError < APIError
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
class PermissionDeniedError < APIError
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
class NotFoundError < APIError
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
class ConflictError < APIError
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
class UnprocessableEntityError < APIError
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
class RateLimitError < APIError
|
|
139
|
+
def retry_after
|
|
140
|
+
headers&.[]("retry-after")&.to_i || headers&.[]("x-ratelimit-reset")&.to_i
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
class InternalServerError < APIError
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
class ValidationError < Error
|
|
148
|
+
attr_reader :errors
|
|
149
|
+
|
|
150
|
+
def initialize(errors)
|
|
151
|
+
@errors = errors
|
|
152
|
+
super(format_message)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def format_message
|
|
158
|
+
"Validation failed:\n" + errors.map { |field, message| " - #{field}: #{message}" }.join("\n")
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
class TaskFailedError < Error
|
|
163
|
+
attr_reader :task
|
|
164
|
+
|
|
165
|
+
def initialize(task)
|
|
166
|
+
@task = task
|
|
167
|
+
super("Task failed")
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
class TaskTimeoutError < Error
|
|
172
|
+
attr_reader :task
|
|
173
|
+
|
|
174
|
+
def initialize(task)
|
|
175
|
+
@task = task
|
|
176
|
+
super("Task timed out")
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "stringio"
|
|
5
|
+
|
|
6
|
+
module RunwayML
|
|
7
|
+
class ImageProcessor
|
|
8
|
+
def self.process(prompt_image, errors)
|
|
9
|
+
new.process(prompt_image, errors)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def process(prompt_image, errors)
|
|
13
|
+
return prompt_image if prompt_image.nil?
|
|
14
|
+
|
|
15
|
+
if io_like?(prompt_image)
|
|
16
|
+
convert_io_to_data_uri(prompt_image, errors)
|
|
17
|
+
elsif prompt_image.is_a?(String) && looks_like_file_path?(prompt_image)
|
|
18
|
+
load_file_path(prompt_image, errors)
|
|
19
|
+
elsif prompt_image.is_a?(Array)
|
|
20
|
+
process_array_format(prompt_image, errors)
|
|
21
|
+
else
|
|
22
|
+
prompt_image
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def process_array_format(prompt_image, errors)
|
|
29
|
+
return prompt_image unless prompt_image.is_a?(Array)
|
|
30
|
+
|
|
31
|
+
processed_items = prompt_image.map do |item|
|
|
32
|
+
next item unless item.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
uri = item[:uri] || item["uri"]
|
|
35
|
+
next item unless uri
|
|
36
|
+
|
|
37
|
+
converted_uri = if io_like?(uri)
|
|
38
|
+
convert_io_to_data_uri(uri, errors)
|
|
39
|
+
elsif uri.is_a?(String) && looks_like_file_path?(uri)
|
|
40
|
+
load_file_path(uri, errors)
|
|
41
|
+
else
|
|
42
|
+
uri
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
next item unless converted_uri
|
|
46
|
+
|
|
47
|
+
item = item.dup
|
|
48
|
+
if item.key?(:uri)
|
|
49
|
+
item[:uri] = converted_uri
|
|
50
|
+
else
|
|
51
|
+
item["uri"] = converted_uri
|
|
52
|
+
end
|
|
53
|
+
item
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
processed_items
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def load_file_path(path, errors)
|
|
60
|
+
file = File.open(path, "rb")
|
|
61
|
+
result = convert_io_to_data_uri(file, errors)
|
|
62
|
+
file.close
|
|
63
|
+
result
|
|
64
|
+
rescue Errno::ENOENT
|
|
65
|
+
errors[:prompt_image] = "file not found: #{path}"
|
|
66
|
+
nil
|
|
67
|
+
rescue => e
|
|
68
|
+
errors[:prompt_image] = "error reading file: #{e.message}"
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def looks_like_file_path?(string)
|
|
73
|
+
return false unless string.is_a?(String)
|
|
74
|
+
|
|
75
|
+
# If it starts with a known URI scheme, it's not a file path
|
|
76
|
+
return false if string.start_with?("https://", "http://", "runway://", "data:")
|
|
77
|
+
|
|
78
|
+
# Check if it's a file that exists
|
|
79
|
+
File.exist?(string)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def io_like?(object)
|
|
83
|
+
object.is_a?(File) || object.is_a?(StringIO) ||
|
|
84
|
+
(object.respond_to?(:read) && object.respond_to?(:rewind))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def convert_io_to_data_uri(io, errors)
|
|
88
|
+
io.rewind if io.respond_to?(:rewind)
|
|
89
|
+
content = io.read
|
|
90
|
+
io.rewind if io.respond_to?(:rewind)
|
|
91
|
+
|
|
92
|
+
content_type = detect_content_type(io, content)
|
|
93
|
+
unless content_type
|
|
94
|
+
errors[:prompt_image] = "unable to detect image type. Supported formats: JPEG, PNG, WebP"
|
|
95
|
+
return nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
encoded = Base64.strict_encode64(content)
|
|
99
|
+
"data:#{content_type};base64,#{encoded}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def detect_content_type(io, content)
|
|
103
|
+
# Try to detect from file path if it's a File object
|
|
104
|
+
if io.is_a?(File) && io.respond_to?(:path)
|
|
105
|
+
ext = File.extname(io.path).downcase
|
|
106
|
+
case ext
|
|
107
|
+
when ".jpg", ".jpeg"
|
|
108
|
+
return "image/jpeg"
|
|
109
|
+
when ".png"
|
|
110
|
+
return "image/png"
|
|
111
|
+
when ".webp"
|
|
112
|
+
return "image/webp"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Detect from magic bytes
|
|
117
|
+
return nil if content.nil? || content.empty?
|
|
118
|
+
|
|
119
|
+
if content.start_with?("\xFF\xD8\xFF".b)
|
|
120
|
+
"image/jpeg"
|
|
121
|
+
elsif content.start_with?("\x89PNG\r\n\x1A\n".b)
|
|
122
|
+
"image/png"
|
|
123
|
+
elsif content.start_with?("RIFF".b) && content[8..11] == "WEBP".b
|
|
124
|
+
"image/webp"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|