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
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "image_processor"
|
|
5
|
+
require_relative "media_processor"
|
|
6
|
+
require_relative "validators/gen4_turbo_validator"
|
|
7
|
+
require_relative "validators/veo3_validator"
|
|
8
|
+
require_relative "validators/veo3_stable_validator"
|
|
9
|
+
require_relative "validators/gen3a_turbo_validator"
|
|
10
|
+
|
|
11
|
+
module RunwayML
|
|
12
|
+
class ImageToVideo
|
|
13
|
+
VALID_MODELS = [ "gen4_turbo", "veo3.1", "gen3a_turbo", "veo3.1_fast", "veo3" ].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(client:)
|
|
16
|
+
@client = client
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create(model:, prompt_image:, prompt_text:, ratio:, duration:, seed: nil, public_figure_threshold: nil, audio: nil, auto_upload: true)
|
|
20
|
+
errors = {}
|
|
21
|
+
|
|
22
|
+
# Validate model
|
|
23
|
+
unless VALID_MODELS.include?(model)
|
|
24
|
+
errors[:model] = "must be one of: #{VALID_MODELS.join(', ')}"
|
|
25
|
+
raise ValidationError, errors
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Process prompt_image with auto-upload support
|
|
29
|
+
processed_prompt_image = MediaProcessor.process(
|
|
30
|
+
prompt_image,
|
|
31
|
+
errors,
|
|
32
|
+
client: client,
|
|
33
|
+
auto_upload: auto_upload
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Process audio with auto-upload support if provided
|
|
37
|
+
processed_audio = if audio && audio != false
|
|
38
|
+
MediaProcessor.process(
|
|
39
|
+
audio,
|
|
40
|
+
errors,
|
|
41
|
+
client: client,
|
|
42
|
+
auto_upload: auto_upload
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Delegate to model-specific validator
|
|
47
|
+
validator = get_validator(model)
|
|
48
|
+
result = validator.validate(
|
|
49
|
+
**build_validator_params(model, processed_prompt_image, prompt_text, ratio, duration, seed, public_figure_threshold, processed_audio || audio)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
errors = result[:errors]
|
|
53
|
+
processed_prompt_image = result[:processed_prompt_image]
|
|
54
|
+
|
|
55
|
+
raise ValidationError, errors unless errors.empty?
|
|
56
|
+
|
|
57
|
+
inputs = build_inputs(model, processed_prompt_image, prompt_text, ratio, duration, seed, public_figure_threshold, processed_audio || audio)
|
|
58
|
+
|
|
59
|
+
response = client.post(
|
|
60
|
+
"image_to_video",
|
|
61
|
+
inputs.merge(model: model)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
Task.new(id: response["id"], client: client)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
attr_reader :client
|
|
70
|
+
|
|
71
|
+
def get_validator(model)
|
|
72
|
+
case model
|
|
73
|
+
when "gen4_turbo"
|
|
74
|
+
Validators::Gen4TurboValidator.new(image_processor: ImageProcessor.new)
|
|
75
|
+
when "veo3.1", "veo3.1_fast"
|
|
76
|
+
Validators::Veo3Validator.new(image_processor: ImageProcessor.new)
|
|
77
|
+
when "veo3"
|
|
78
|
+
Validators::Veo3StableValidator.new(image_processor: ImageProcessor.new)
|
|
79
|
+
when "gen3a_turbo"
|
|
80
|
+
Validators::Gen3aTurboValidator.new(image_processor: ImageProcessor.new)
|
|
81
|
+
else
|
|
82
|
+
raise ArgumentError, "No validator configured for model: #{model}"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def build_validator_params(model, prompt_image, prompt_text, ratio, duration, seed, public_figure_threshold, audio)
|
|
87
|
+
case model
|
|
88
|
+
when "gen4_turbo"
|
|
89
|
+
{
|
|
90
|
+
prompt_image: prompt_image,
|
|
91
|
+
prompt_text: prompt_text,
|
|
92
|
+
ratio: ratio,
|
|
93
|
+
duration: duration,
|
|
94
|
+
seed: seed,
|
|
95
|
+
public_figure_threshold: public_figure_threshold
|
|
96
|
+
}
|
|
97
|
+
when "veo3.1", "veo3.1_fast"
|
|
98
|
+
{
|
|
99
|
+
prompt_image: prompt_image,
|
|
100
|
+
prompt_text: prompt_text,
|
|
101
|
+
ratio: ratio,
|
|
102
|
+
duration: duration,
|
|
103
|
+
audio: audio
|
|
104
|
+
}
|
|
105
|
+
when "veo3"
|
|
106
|
+
{
|
|
107
|
+
prompt_image: prompt_image,
|
|
108
|
+
prompt_text: prompt_text,
|
|
109
|
+
ratio: ratio,
|
|
110
|
+
duration: duration
|
|
111
|
+
}
|
|
112
|
+
when "gen3a_turbo"
|
|
113
|
+
{
|
|
114
|
+
prompt_image: prompt_image,
|
|
115
|
+
prompt_text: prompt_text,
|
|
116
|
+
ratio: ratio,
|
|
117
|
+
duration: duration,
|
|
118
|
+
seed: seed,
|
|
119
|
+
public_figure_threshold: public_figure_threshold
|
|
120
|
+
}
|
|
121
|
+
else
|
|
122
|
+
{}
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build_inputs(model, processed_prompt_image, prompt_text, ratio, duration, seed, public_figure_threshold, audio)
|
|
127
|
+
inputs = {
|
|
128
|
+
promptImage: processed_prompt_image,
|
|
129
|
+
promptText: prompt_text,
|
|
130
|
+
ratio: ratio,
|
|
131
|
+
duration: duration
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
case model
|
|
135
|
+
when "gen4_turbo"
|
|
136
|
+
inputs[:seed] = seed if seed
|
|
137
|
+
if public_figure_threshold
|
|
138
|
+
inputs[:contentModeration] = {
|
|
139
|
+
publicFigureThreshold: public_figure_threshold
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
when "veo3.1", "veo3.1_fast"
|
|
143
|
+
inputs[:audio] = audio unless audio.nil?
|
|
144
|
+
when "veo3"
|
|
145
|
+
# No additional parameters for veo3
|
|
146
|
+
when "gen3a_turbo"
|
|
147
|
+
inputs[:seed] = seed if seed
|
|
148
|
+
if public_figure_threshold
|
|
149
|
+
inputs[:contentModeration] = {
|
|
150
|
+
publicFigureThreshold: public_figure_threshold
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
inputs
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "stringio"
|
|
5
|
+
|
|
6
|
+
module RunwayML
|
|
7
|
+
class MediaProcessor
|
|
8
|
+
# Processes media files, optionally uploading them to Runway
|
|
9
|
+
# If auto_upload is true (default), local files/StringIO are uploaded and converted to runway URIs
|
|
10
|
+
# If auto_upload is false, converts them to data URIs instead
|
|
11
|
+
def self.process(media, errors, client: nil, auto_upload: true)
|
|
12
|
+
new(client: client).process(media, errors, auto_upload: auto_upload)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(client: nil)
|
|
16
|
+
@client = client
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def process(media, errors, auto_upload: true)
|
|
20
|
+
return media if media.nil?
|
|
21
|
+
|
|
22
|
+
if auto_upload && @client
|
|
23
|
+
process_with_upload(media, errors)
|
|
24
|
+
else
|
|
25
|
+
process_without_upload(media, errors)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :client
|
|
32
|
+
|
|
33
|
+
# ======================
|
|
34
|
+
# WITH UPLOAD PROCESSING
|
|
35
|
+
# ======================
|
|
36
|
+
|
|
37
|
+
def process_with_upload(media, errors)
|
|
38
|
+
case media
|
|
39
|
+
when Array
|
|
40
|
+
process_array_with_upload(media, errors)
|
|
41
|
+
when Hash
|
|
42
|
+
process_hash_with_upload(media, errors)
|
|
43
|
+
when String, File, StringIO
|
|
44
|
+
convert_to_runway_uri(media, errors)
|
|
45
|
+
else
|
|
46
|
+
media
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def process_array_with_upload(items, errors)
|
|
51
|
+
items.map do |item|
|
|
52
|
+
case item
|
|
53
|
+
when Hash
|
|
54
|
+
process_hash_with_upload(item, errors)
|
|
55
|
+
when String, File, StringIO
|
|
56
|
+
convert_to_runway_uri(item, errors) || item
|
|
57
|
+
else
|
|
58
|
+
item
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def process_hash_with_upload(item, errors)
|
|
64
|
+
return item unless item.is_a?(Hash)
|
|
65
|
+
|
|
66
|
+
item = item.dup
|
|
67
|
+
uri = item[:uri] || item["uri"]
|
|
68
|
+
|
|
69
|
+
return item unless uri.is_a?(String) || uri.respond_to?(:read) || (uri.is_a?(String) && looks_like_file_path?(uri))
|
|
70
|
+
|
|
71
|
+
converted_uri = convert_to_runway_uri(uri, errors)
|
|
72
|
+
return item unless converted_uri
|
|
73
|
+
|
|
74
|
+
if item.key?(:uri)
|
|
75
|
+
item[:uri] = converted_uri
|
|
76
|
+
else
|
|
77
|
+
item["uri"] = converted_uri
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
item
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def convert_to_runway_uri(media, errors)
|
|
84
|
+
return media if media.nil?
|
|
85
|
+
|
|
86
|
+
# If it's already a URI, return as-is
|
|
87
|
+
if media.is_a?(String) && (media.start_with?("https://", "http://", "runway://", "data:"))
|
|
88
|
+
return media
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Check if it's a file path
|
|
92
|
+
if media.is_a?(String) && looks_like_file_path?(media)
|
|
93
|
+
upload_file(media, errors)
|
|
94
|
+
# Check if it's a file-like object
|
|
95
|
+
elsif media.respond_to?(:read)
|
|
96
|
+
upload_io(media, errors)
|
|
97
|
+
else
|
|
98
|
+
media
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def upload_file(path, errors)
|
|
103
|
+
return nil if client.nil?
|
|
104
|
+
|
|
105
|
+
begin
|
|
106
|
+
uploads = Uploads.new(client: client)
|
|
107
|
+
uploads.create_ephemeral(path)
|
|
108
|
+
rescue ValidationError => e
|
|
109
|
+
errors[:media] = "Failed to upload file: #{e.message}"
|
|
110
|
+
nil
|
|
111
|
+
rescue => e
|
|
112
|
+
errors[:media] = "Error uploading file: #{e.message}"
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def upload_io(io, errors)
|
|
118
|
+
return nil if client.nil?
|
|
119
|
+
|
|
120
|
+
begin
|
|
121
|
+
# Determine a good filename
|
|
122
|
+
filename = if io.respond_to?(:path)
|
|
123
|
+
File.basename(io.path)
|
|
124
|
+
else
|
|
125
|
+
# For StringIO without a path, infer the extension from content type
|
|
126
|
+
content_type = infer_content_type_for_io(io)
|
|
127
|
+
ext = infer_extension_for_content_type(content_type)
|
|
128
|
+
"upload#{ext}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
uploads = Uploads.new(client: client)
|
|
132
|
+
uploads.create_ephemeral(io, filename: filename)
|
|
133
|
+
rescue ValidationError => e
|
|
134
|
+
errors[:media] = "Failed to upload file: #{e.message}"
|
|
135
|
+
nil
|
|
136
|
+
rescue => e
|
|
137
|
+
errors[:media] = "Error uploading file: #{e.message}"
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def infer_content_type_for_io(io)
|
|
143
|
+
io.rewind if io.respond_to?(:rewind)
|
|
144
|
+
content = io.read
|
|
145
|
+
io.rewind if io.respond_to?(:rewind)
|
|
146
|
+
|
|
147
|
+
detect_from_magic_bytes(content) || "application/octet-stream"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def infer_extension_for_content_type(content_type)
|
|
151
|
+
{
|
|
152
|
+
"image/jpeg" => ".jpg",
|
|
153
|
+
"image/png" => ".png",
|
|
154
|
+
"image/webp" => ".webp",
|
|
155
|
+
"audio/mpeg" => ".mp3",
|
|
156
|
+
"audio/wav" => ".wav",
|
|
157
|
+
"audio/flac" => ".flac",
|
|
158
|
+
"audio/mp4" => ".m4a",
|
|
159
|
+
"audio/aac" => ".aac",
|
|
160
|
+
"audio/ogg" => ".ogg",
|
|
161
|
+
"audio/webp" => ".weba",
|
|
162
|
+
"video/mp4" => ".mp4",
|
|
163
|
+
"video/quicktime" => ".mov",
|
|
164
|
+
"video/x-matroska" => ".mkv",
|
|
165
|
+
"video/webm" => ".webm",
|
|
166
|
+
"video/3gpp" => ".3gp",
|
|
167
|
+
"video/ogg" => ".ogv",
|
|
168
|
+
"video/x-msvideo" => ".avi",
|
|
169
|
+
"video/x-flv" => ".flv",
|
|
170
|
+
"video/mpeg" => ".mpg"
|
|
171
|
+
}[content_type] || ".mp3" # Default to mp3 for unknown audio
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# ========================
|
|
175
|
+
# WITHOUT UPLOAD PROCESSING
|
|
176
|
+
# ========================
|
|
177
|
+
|
|
178
|
+
def process_without_upload(media, errors)
|
|
179
|
+
case media
|
|
180
|
+
when Array
|
|
181
|
+
process_array_without_upload(media, errors)
|
|
182
|
+
when Hash
|
|
183
|
+
process_hash_without_upload(media, errors)
|
|
184
|
+
when String, File, StringIO
|
|
185
|
+
convert_to_data_uri(media, errors)
|
|
186
|
+
else
|
|
187
|
+
media
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def process_array_without_upload(items, errors)
|
|
192
|
+
items.map do |item|
|
|
193
|
+
case item
|
|
194
|
+
when Hash
|
|
195
|
+
process_hash_without_upload(item, errors)
|
|
196
|
+
when String, File, StringIO
|
|
197
|
+
convert_to_data_uri(item, errors) || item
|
|
198
|
+
else
|
|
199
|
+
item
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def process_hash_without_upload(item, errors)
|
|
205
|
+
return item unless item.is_a?(Hash)
|
|
206
|
+
|
|
207
|
+
item = item.dup
|
|
208
|
+
uri = item[:uri] || item["uri"]
|
|
209
|
+
|
|
210
|
+
return item unless uri.is_a?(String) || uri.respond_to?(:read) || (uri.is_a?(String) && looks_like_file_path?(uri))
|
|
211
|
+
|
|
212
|
+
converted_uri = convert_to_data_uri(uri, errors)
|
|
213
|
+
return item unless converted_uri
|
|
214
|
+
|
|
215
|
+
if item.key?(:uri)
|
|
216
|
+
item[:uri] = converted_uri
|
|
217
|
+
else
|
|
218
|
+
item["uri"] = converted_uri
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
item
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def convert_to_data_uri(media, errors)
|
|
225
|
+
# If it's already a URI, return as-is
|
|
226
|
+
if media.is_a?(String) && (media.start_with?("https://", "http://", "runway://", "data:"))
|
|
227
|
+
return media
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Check if it's a file path
|
|
231
|
+
if media.is_a?(String) && looks_like_file_path?(media)
|
|
232
|
+
load_file_to_data_uri(media, errors)
|
|
233
|
+
# Check if it's a file-like object
|
|
234
|
+
elsif media.respond_to?(:read)
|
|
235
|
+
convert_io_to_data_uri(media, errors)
|
|
236
|
+
else
|
|
237
|
+
media
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def load_file_to_data_uri(path, errors)
|
|
242
|
+
file = File.open(path, "rb")
|
|
243
|
+
result = convert_io_to_data_uri(file, errors)
|
|
244
|
+
file.close
|
|
245
|
+
result
|
|
246
|
+
rescue Errno::ENOENT
|
|
247
|
+
errors[:media] = "file not found: #{path}"
|
|
248
|
+
nil
|
|
249
|
+
rescue => e
|
|
250
|
+
errors[:media] = "error reading file: #{e.message}"
|
|
251
|
+
nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def convert_io_to_data_uri(io, errors)
|
|
255
|
+
io.rewind if io.respond_to?(:rewind)
|
|
256
|
+
content = io.read
|
|
257
|
+
io.rewind if io.respond_to?(:rewind)
|
|
258
|
+
|
|
259
|
+
content_type = detect_content_type(io, content)
|
|
260
|
+
unless content_type
|
|
261
|
+
errors[:media] = "unable to detect media type"
|
|
262
|
+
return nil
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
encoded = Base64.strict_encode64(content)
|
|
266
|
+
"data:#{content_type};base64,#{encoded}"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# =======================
|
|
270
|
+
# CONTENT TYPE DETECTION
|
|
271
|
+
# =======================
|
|
272
|
+
|
|
273
|
+
def detect_content_type(io, content)
|
|
274
|
+
# Try to detect from file path if it's a File object
|
|
275
|
+
if io.is_a?(File) && io.respond_to?(:path)
|
|
276
|
+
ext = File.extname(io.path).downcase
|
|
277
|
+
mime_type = mime_type_for_extension(ext)
|
|
278
|
+
return mime_type if mime_type
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Detect from magic bytes
|
|
282
|
+
detect_from_magic_bytes(content)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def detect_from_magic_bytes(content)
|
|
286
|
+
return nil if content.nil? || content.empty?
|
|
287
|
+
|
|
288
|
+
if content.start_with?("\xFF\xD8\xFF".b)
|
|
289
|
+
"image/jpeg"
|
|
290
|
+
elsif content.start_with?("\x89PNG\r\n\x1A\n".b)
|
|
291
|
+
"image/png"
|
|
292
|
+
elsif content.start_with?("RIFF".b) && content[8..11] == "WEBP".b
|
|
293
|
+
"image/webp"
|
|
294
|
+
elsif content.start_with?("\xFF\xFB".b) || content.start_with?("\xFF\xFA".b)
|
|
295
|
+
"audio/mpeg"
|
|
296
|
+
elsif content.start_with?("RIFF".b) && content[8..11] == "WAVE".b
|
|
297
|
+
"audio/wav"
|
|
298
|
+
elsif content.start_with?("ftyp".b)
|
|
299
|
+
"video/mp4"
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def mime_type_for_extension(ext)
|
|
304
|
+
{
|
|
305
|
+
".jpg" => "image/jpeg",
|
|
306
|
+
".jpeg" => "image/jpeg",
|
|
307
|
+
".png" => "image/png",
|
|
308
|
+
".webp" => "image/webp",
|
|
309
|
+
".mp3" => "audio/mpeg",
|
|
310
|
+
".wav" => "audio/wav",
|
|
311
|
+
".flac" => "audio/flac",
|
|
312
|
+
".m4a" => "audio/mp4",
|
|
313
|
+
".aac" => "audio/aac",
|
|
314
|
+
".ogg" => "audio/ogg",
|
|
315
|
+
".weba" => "audio/webp",
|
|
316
|
+
".mp4" => "video/mp4",
|
|
317
|
+
".mov" => "video/quicktime",
|
|
318
|
+
".mkv" => "video/x-matroska",
|
|
319
|
+
".webm" => "video/webm",
|
|
320
|
+
".3gp" => "video/3gpp",
|
|
321
|
+
".ogv" => "video/ogg",
|
|
322
|
+
".avi" => "video/x-msvideo",
|
|
323
|
+
".flv" => "video/x-flv",
|
|
324
|
+
".mpg" => "video/mpeg",
|
|
325
|
+
".mpeg" => "video/mpeg"
|
|
326
|
+
}[ext]
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# ============
|
|
330
|
+
# HELPERS
|
|
331
|
+
# ============
|
|
332
|
+
|
|
333
|
+
def looks_like_file_path?(string)
|
|
334
|
+
return false unless string.is_a?(String)
|
|
335
|
+
|
|
336
|
+
# If it starts with a known URI scheme, it's not a file path
|
|
337
|
+
return false if string.start_with?("https://", "http://", "runway://", "data:")
|
|
338
|
+
|
|
339
|
+
# Check if it's a file that exists
|
|
340
|
+
File.exist?(string)
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "tempfile"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
|
|
9
|
+
module RunwayML
|
|
10
|
+
# Loads and caches the OpenAPI spec from the RunwayML GitHub repository
|
|
11
|
+
class OpenAPISpecLoader
|
|
12
|
+
SPEC_URL = "https://raw.githubusercontent.com/runwayml/openapi/main/openapi.json"
|
|
13
|
+
SPEC_HASH = "9cf1febc614000f72d4332f8bee51b6873b6bce49c9bdc02daae300e6882f9dd"
|
|
14
|
+
CACHE_DIR = File.join(Dir.home, ".cache", "runway-ml-openapi")
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
attr_accessor :spec
|
|
18
|
+
|
|
19
|
+
def load_spec(force_reload: false)
|
|
20
|
+
return @spec if @spec && !force_reload
|
|
21
|
+
|
|
22
|
+
@spec = fetch_or_cache_spec(force_reload)
|
|
23
|
+
@spec
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def get_endpoint_schema(method, path)
|
|
27
|
+
load_spec
|
|
28
|
+
path_item = @spec.dig("paths", path)
|
|
29
|
+
return nil unless path_item
|
|
30
|
+
|
|
31
|
+
operation = path_item[method.downcase]
|
|
32
|
+
return nil unless operation
|
|
33
|
+
|
|
34
|
+
operation
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def get_request_schema(method, path)
|
|
38
|
+
operation = get_endpoint_schema(method, path)
|
|
39
|
+
return nil unless operation
|
|
40
|
+
|
|
41
|
+
request_body = operation.dig("requestBody", "content", "application/json", "schema")
|
|
42
|
+
request_body
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def get_response_schema(method, path, status_code = "200")
|
|
46
|
+
operation = get_endpoint_schema(method, path)
|
|
47
|
+
return nil unless operation
|
|
48
|
+
|
|
49
|
+
response_schema = operation.dig("responses", status_code, "content", "application/json", "schema")
|
|
50
|
+
response_schema
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def get_path_parameters(method, path)
|
|
54
|
+
operation = get_endpoint_schema(method, path)
|
|
55
|
+
return [] unless operation
|
|
56
|
+
|
|
57
|
+
parameters = operation["parameters"] || []
|
|
58
|
+
parameters.select { |p| p["in"] == "path" }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def get_query_parameters(method, path)
|
|
62
|
+
operation = get_endpoint_schema(method, path)
|
|
63
|
+
return [] unless operation
|
|
64
|
+
|
|
65
|
+
parameters = operation["parameters"] || []
|
|
66
|
+
parameters.select { |p| p["in"] == "query" }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def fetch_or_cache_spec(force_reload)
|
|
72
|
+
if force_reload
|
|
73
|
+
fetch_spec
|
|
74
|
+
else
|
|
75
|
+
cached = load_from_cache
|
|
76
|
+
cached || fetch_and_cache_spec
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def load_from_cache
|
|
81
|
+
cache_file = cache_path
|
|
82
|
+
return nil unless File.exist?(cache_file)
|
|
83
|
+
|
|
84
|
+
# Check if cache is less than 24 hours old
|
|
85
|
+
if File.mtime(cache_file) > Time.now - (24 * 3600)
|
|
86
|
+
JSON.parse(File.read(cache_file))
|
|
87
|
+
else
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
rescue StandardError
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def fetch_and_cache_spec
|
|
95
|
+
spec = fetch_spec
|
|
96
|
+
save_to_cache(spec)
|
|
97
|
+
spec
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def fetch_spec
|
|
101
|
+
uri = URI(SPEC_URL)
|
|
102
|
+
response = Net::HTTP.get(uri)
|
|
103
|
+
JSON.parse(response)
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
raise "Failed to fetch OpenAPI spec: #{e.message}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def save_to_cache(spec)
|
|
109
|
+
FileUtils.mkdir_p(CACHE_DIR)
|
|
110
|
+
File.write(cache_path, JSON.pretty_generate(spec))
|
|
111
|
+
rescue StandardError
|
|
112
|
+
# Silently fail cache writes
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def cache_path
|
|
116
|
+
File.join(CACHE_DIR, "openapi.json")
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module RunwayML
|
|
6
|
+
class Organization
|
|
7
|
+
def initialize(client:)
|
|
8
|
+
@client = client
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def retrieve
|
|
12
|
+
response = client.get("organization")
|
|
13
|
+
OrganizationInfo.new(response)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def usage(start_date: nil, before_date: nil)
|
|
17
|
+
params = {}
|
|
18
|
+
params[:startDate] = start_date if start_date
|
|
19
|
+
params[:beforeDate] = before_date if before_date
|
|
20
|
+
|
|
21
|
+
response = if params.empty?
|
|
22
|
+
client.post("organization/usage", {})
|
|
23
|
+
else
|
|
24
|
+
client.post("organization/usage", params)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
UsageInfo.new(response)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :client
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class OrganizationInfo
|
|
36
|
+
attr_reader :tier, :credit_balance, :usage
|
|
37
|
+
|
|
38
|
+
def initialize(data)
|
|
39
|
+
@data = data
|
|
40
|
+
@tier = data["tier"] || {}
|
|
41
|
+
@credit_balance = data["creditBalance"] || 0
|
|
42
|
+
@usage = data["usage"] || {}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def max_monthly_credit_spend
|
|
46
|
+
tier["maxMonthlyCreditSpend"]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def tier_models
|
|
50
|
+
tier["models"] || {}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def usage_models
|
|
54
|
+
usage["models"] || {}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def to_h
|
|
58
|
+
@data
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def inspect
|
|
62
|
+
"#<RunwayML::OrganizationInfo tier=#{tier.inspect} credit_balance=#{credit_balance}>"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class UsageInfo
|
|
67
|
+
attr_reader :data
|
|
68
|
+
|
|
69
|
+
def initialize(data)
|
|
70
|
+
@data = data
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def models
|
|
74
|
+
data["models"] || {}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def by_model
|
|
78
|
+
models
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def to_h
|
|
82
|
+
data
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def inspect
|
|
86
|
+
"#<RunwayML::UsageInfo models=#{models.keys.join(', ')}>"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|