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 "errors"
4
+ require_relative "validators/sound_effect_validator"
5
+
6
+ module RunwayML
7
+ class SoundEffect
8
+ VALID_MODELS = [ "eleven_text_to_sound_v2" ].freeze
9
+
10
+ def initialize(client:)
11
+ @client = client
12
+ end
13
+
14
+ def create(model:, prompt_text:, duration: nil, loop: false)
15
+ errors = {}
16
+
17
+ unless VALID_MODELS.include?(model)
18
+ errors[:model] = "must be one of: #{VALID_MODELS.join(', ')}"
19
+ raise ValidationError, errors
20
+ end
21
+
22
+ validator = Validators::SoundEffectValidator.new
23
+ result = validator.validate(
24
+ prompt_text: prompt_text,
25
+ duration: duration,
26
+ loop: loop
27
+ )
28
+
29
+ errors = result[:errors]
30
+ raise ValidationError, errors unless errors.empty?
31
+
32
+ inputs = build_inputs(prompt_text, duration, loop)
33
+
34
+ response = client.post(
35
+ "sound_effect",
36
+ inputs.merge(model: model)
37
+ )
38
+
39
+ Task.new(id: response["id"], client: client)
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :client
45
+
46
+ def build_inputs(prompt_text, duration, loop_flag)
47
+ inputs = {
48
+ promptText: prompt_text,
49
+ loop: loop_flag
50
+ }
51
+
52
+ inputs[:duration] = duration unless duration.nil?
53
+
54
+ inputs
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RunwayML
4
+ # Analyzes coverage of SDK methods against OpenAPI spec endpoints
5
+ class SpecCoverageAnalyzer
6
+ CRITICAL_ENDPOINTS = {
7
+ "POST /v1/text_to_video" => { class: "TextToVideo", method: "create" },
8
+ "POST /v1/image_to_video" => { class: "ImageToVideo", method: "create" },
9
+ "POST /v1/text_to_speech" => { class: "TextToSpeech", method: "create" },
10
+ "POST /v1/speech_to_speech" => { class: "SpeechToSpeech", method: "create" },
11
+ "POST /v1/sound_effect" => { class: "SoundEffect", method: "create" },
12
+ "POST /v1/voice_isolation" => { class: "VoiceIsolation", method: "create" },
13
+ "POST /v1/voice_dubbing" => { class: "VoiceDubbing", method: "create" },
14
+ "POST /v1/character_performance" => { class: "CharacterPerformance", method: "create" },
15
+ "POST /v1/video_to_video" => { class: "VideoToVideo", method: "create" },
16
+ "POST /v1/text_to_image" => { class: "TextToImage", method: "create" },
17
+ "GET /v1/tasks/{id}" => { class: "Task", method: "retrieve" },
18
+ "DELETE /v1/tasks/{id}" => { class: "Task", method: "delete" },
19
+ "GET /v1/organization" => { class: "Organization", method: "retrieve" },
20
+ "POST /v1/uploads" => { class: "Uploads", method: "create" }
21
+ }
22
+
23
+ class << self
24
+ def analyze
25
+ spec = OpenAPISpecLoader.load_spec
26
+ return nil unless spec
27
+
28
+ coverage = {}
29
+
30
+ CRITICAL_ENDPOINTS.each do |endpoint, info|
31
+ method, path = endpoint.split(" ")
32
+ schema = OpenAPISpecLoader.get_endpoint_schema(method, path)
33
+
34
+ coverage[endpoint] = {
35
+ sdk_class: info[:class],
36
+ sdk_method: info[:method],
37
+ spec_exists: !schema.nil?,
38
+ has_request_schema: has_request_schema_for_endpoint?(method, path),
39
+ has_response_schema: has_response_schema_for_endpoint?(method, path),
40
+ models_supported: extract_supported_models(schema, path),
41
+ path_object: schema
42
+ }
43
+ end
44
+
45
+ coverage
46
+ end
47
+
48
+ def print_coverage_report
49
+ coverage = analyze
50
+ return puts "Could not load OpenAPI spec" unless coverage
51
+
52
+ puts "\n" + "=" * 100
53
+ puts "SDK SPEC COVERAGE REPORT".center(100)
54
+ puts "=" * 100 + "\n"
55
+
56
+ total_endpoints = coverage.size
57
+ fully_compliant = coverage.count { |_, v| v[:spec_exists] && v[:has_request_schema] && v[:has_response_schema] }
58
+
59
+ puts "Summary:"
60
+ puts " Total Critical Endpoints: #{total_endpoints}"
61
+ puts " Fully Defined in Spec: #{fully_compliant}/#{total_endpoints}"
62
+ puts " Compliance Rate: #{(fully_compliant.to_f / total_endpoints * 100).round(1)}%\n\n"
63
+
64
+ puts "Endpoint Details:"
65
+ puts "-" * 100
66
+
67
+ coverage.each do |endpoint, data|
68
+ status = data[:spec_exists] ? "✓" : "✗"
69
+ models = data[:models_supported].any? ? " [Models: #{data[:models_supported].join(', ')}]" : ""
70
+
71
+ puts "#{status} #{endpoint.ljust(35)} -> #{data[:sdk_class]}.#{data[:sdk_method]}#{models}"
72
+
73
+ unless data[:spec_exists]
74
+ puts " ⚠️ Endpoint not found in OpenAPI spec"
75
+ else
76
+ unless data[:has_request_schema]
77
+ puts " ⚠️ Missing request schema (expected for GET/DELETE)"
78
+ end
79
+ unless data[:has_response_schema]
80
+ puts " ⚠️ Missing response schema"
81
+ end
82
+ end
83
+ end
84
+
85
+ puts "\n" + "=" * 100 + "\n"
86
+ end
87
+
88
+ private
89
+
90
+ def has_request_schema_for_endpoint?(method, path)
91
+ # GET and DELETE methods don't have request bodies, so skip schema check
92
+ return true if method.upcase == "GET" || method.upcase == "DELETE"
93
+
94
+ OpenAPISpecLoader.get_request_schema(method, path) != nil
95
+ end
96
+
97
+ private
98
+
99
+ def has_request_schema_for_endpoint?(method, path)
100
+ # GET and DELETE methods don't have request bodies, so skip schema check
101
+ return true if method.upcase == "GET" || method.upcase == "DELETE"
102
+
103
+ OpenAPISpecLoader.get_request_schema(method, path) != nil
104
+ end
105
+
106
+ def has_response_schema_for_endpoint?(method, path)
107
+ # 204 No Content responses are valid without a schema
108
+ operation = OpenAPISpecLoader.get_endpoint_schema(method, path)
109
+ return true unless operation
110
+
111
+ responses = operation["responses"] || {}
112
+
113
+ # Check if 204 No Content is defined (DELETE endpoints)
114
+ return true if responses.key?("204")
115
+
116
+ # Otherwise check for response schema
117
+ OpenAPISpecLoader.get_response_schema(method, path) != nil
118
+ end
119
+
120
+ def extract_supported_models(schema, path)
121
+ return [] unless schema
122
+
123
+ # For endpoints with oneOf discriminated by model
124
+ request_schema = schema.dig("requestBody", "content", "application/json", "schema")
125
+ return [] unless request_schema&.dig("oneOf")
126
+
127
+ request_schema["oneOf"].map do |variant|
128
+ variant.dig("properties", "model", "const") ||
129
+ variant.dig("title")
130
+ end.compact
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "media_processor"
5
+ require_relative "validators/speech_to_speech_validator"
6
+
7
+ module RunwayML
8
+ class SpeechToSpeech
9
+ VALID_MODELS = [ "eleven_multilingual_sts_v2" ].freeze
10
+ VALID_MEDIA_TYPES = [ "audio", "video" ].freeze
11
+ VALID_VOICE_TYPES = [ "runway-preset" ].freeze
12
+ VALID_PRESET_IDS = [
13
+ "Maya", "Arjun", "Serene", "Bernard", "Billy", "Mark", "Clint", "Mabel", "Chad", "Leslie",
14
+ "Eleanor", "Elias", "Elliot", "Grungle", "Brodie", "Sandra", "Kirk", "Kylie", "Lara", "Lisa",
15
+ "Malachi", "Marlene", "Martin", "Miriam", "Monster", "Paula", "Pip", "Rusty", "Ragnar", "Xylar",
16
+ "Maggie", "Jack", "Katie", "Noah", "James", "Rina", "Ella", "Mariah", "Frank", "Claudia",
17
+ "Niki", "Vincent", "Kendrick", "Myrna", "Tom", "Wanda", "Benjamin", "Kiana", "Rachel"
18
+ ].freeze
19
+
20
+ def initialize(client:)
21
+ @client = client
22
+ end
23
+
24
+ def create(model:, media:, voice:, remove_background_noise: false, auto_upload: true)
25
+ errors = {}
26
+
27
+ unless VALID_MODELS.include?(model)
28
+ errors[:model] = "must be one of: #{VALID_MODELS.join(', ')}"
29
+ raise ValidationError, errors
30
+ end
31
+
32
+ # Process media with auto-upload support
33
+ processed_media = process_media_with_upload(media, errors, auto_upload)
34
+
35
+ validator = Validators::SpeechToSpeechValidator.new
36
+ result = validator.validate(
37
+ media: processed_media,
38
+ voice: voice,
39
+ remove_background_noise: remove_background_noise
40
+ )
41
+
42
+ errors = result[:errors]
43
+ raise ValidationError, errors unless errors.empty?
44
+
45
+ inputs = build_inputs(processed_media, voice, remove_background_noise)
46
+
47
+ response = client.post(
48
+ "speech_to_speech",
49
+ inputs.merge(model: model)
50
+ )
51
+
52
+ Task.new(id: response["id"], client: client)
53
+ end
54
+
55
+ private
56
+
57
+ attr_reader :client
58
+
59
+ def process_media_with_upload(media, errors, auto_upload)
60
+ return media unless media.is_a?(Hash)
61
+
62
+ media = media.dup
63
+ uri = media[:uri] || media["uri"]
64
+
65
+ # Process the URI with auto-upload
66
+ processed_uri = MediaProcessor.process(
67
+ uri,
68
+ errors,
69
+ client: client,
70
+ auto_upload: auto_upload
71
+ )
72
+
73
+ if media.key?(:uri)
74
+ media[:uri] = processed_uri
75
+ else
76
+ media["uri"] = processed_uri
77
+ end
78
+
79
+ media
80
+ end
81
+
82
+ def build_inputs(media, voice, remove_background_noise)
83
+ {
84
+ media: media,
85
+ voice: voice,
86
+ removeBackgroundNoise: remove_background_noise
87
+ }
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "validators/base_validators"
4
+
5
+ module RunwayML
6
+ class Task
7
+ POLL_TIME = 6
8
+ POLL_JITTER = 3
9
+
10
+ attr_reader :id, :status, :created_at, :progress, :failure, :failure_code, :output, :data
11
+
12
+ def initialize(id:, client: nil, data: nil)
13
+ # Only validate UUID for real clients (not TestClient used in specs)
14
+ validate_task_id(id) if client && !client.is_a?(TestClient)
15
+ @id = id
16
+ @client = client
17
+ apply_data(data) if data
18
+ end
19
+
20
+ def retrieve
21
+ ensure_client!
22
+
23
+ response = client.get("tasks/#{id}")
24
+ apply_data(response)
25
+ self
26
+ end
27
+
28
+ def delete
29
+ ensure_client!
30
+
31
+ client.delete("tasks/#{id}")
32
+ true
33
+ rescue NotFoundError
34
+ false
35
+ end
36
+
37
+ def wait_for_output(timeout: 60 * 10)
38
+ ensure_client!
39
+
40
+ start_time = Time.now.to_f
41
+
42
+ loop do
43
+ retrieve
44
+
45
+ case status
46
+ when "SUCCEEDED"
47
+ return self
48
+ when "FAILED", "CANCELLED"
49
+ raise TaskFailedError.new(self)
50
+ end
51
+
52
+ if !timeout.nil? && (Time.now.to_f - start_time) > timeout
53
+ raise TaskTimeoutError.new(self)
54
+ end
55
+
56
+ sleep(POLL_TIME + (rand * POLL_JITTER) - (POLL_JITTER / 2.0))
57
+ end
58
+ end
59
+
60
+ def ==(other)
61
+ other.is_a?(Task) && other.id == id
62
+ end
63
+
64
+ def to_h
65
+ hash = { id: id }
66
+ hash[:status] = status if status
67
+ hash[:created_at] = created_at if created_at
68
+ hash[:progress] = progress if progress
69
+ hash[:failure] = failure if failure
70
+ hash[:failure_code] = failure_code if failure_code
71
+ hash[:output] = output if output
72
+ hash
73
+ end
74
+
75
+ def to_json
76
+ to_h.to_json
77
+ end
78
+
79
+ def to_s
80
+ "#<RunwayML::Task id=#{id}>"
81
+ end
82
+
83
+ def inspect
84
+ to_s
85
+ end
86
+
87
+ private
88
+
89
+ attr_reader :client
90
+
91
+ def ensure_client!
92
+ return if client
93
+
94
+ raise ArgumentError, "Task client is required to retrieve or delete tasks"
95
+ end
96
+
97
+ def validate_task_id(task_id)
98
+ validator = Validators::UUIDValidator.new
99
+ errors = {}
100
+ validator.validate(task_id, errors, field: :id)
101
+ raise ValidationError, errors if errors.any?
102
+ end
103
+
104
+ def apply_data(payload)
105
+ return unless payload.is_a?(Hash)
106
+
107
+ @data = payload
108
+ @status = payload["status"] || payload[:status]
109
+ @created_at = payload["createdAt"] || payload[:created_at]
110
+ @progress = payload["progress"] || payload[:progress]
111
+ @failure = payload["failure"] || payload[:failure]
112
+ @failure_code = payload["failureCode"] || payload[:failure_code]
113
+ @output = payload["output"] || payload[:output]
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,62 @@
1
+ module RunwayML
2
+ class TestClient
3
+ def initialize(api_secret: nil, base_url: nil, api_version: nil)
4
+ reset!
5
+ end
6
+
7
+ def post(path, params)
8
+ record_request(:post, path, params)
9
+ fetch_response(:post, path, params)
10
+ end
11
+
12
+ def get(path)
13
+ record_request(:get, path, nil)
14
+ fetch_response(:get, path)
15
+ end
16
+
17
+ def delete(path)
18
+ record_request(:delete, path, nil)
19
+ fetch_response(:delete, path)
20
+ end
21
+
22
+ # Inject a response for a given method/path/params
23
+ def inject_response(method, path, params: nil, response:)
24
+ key = response_key(method, path, params)
25
+ @responses[key] = response
26
+ end
27
+
28
+ def requests
29
+ @requests.dup
30
+ end
31
+
32
+ def reset!
33
+ @responses = {}
34
+ @requests = []
35
+ end
36
+
37
+ private
38
+
39
+ def fetch_response(method, path, params = nil)
40
+ key = response_key(method, path, params)
41
+ unless @responses.key?(key)
42
+ raise "No injected response for #{method.upcase} #{path} with params: #{params.inspect}"
43
+ end
44
+ response = @responses[key]
45
+ if response.is_a?(Array)
46
+ raise "No injected response remaining for #{method.upcase} #{path} with params: #{params.inspect}" if response.empty?
47
+ response = response.shift
48
+ end
49
+ raise response if response.is_a?(Exception)
50
+
51
+ response
52
+ end
53
+
54
+ def record_request(method, path, params)
55
+ @requests << { method: method, path: path, params: params }
56
+ end
57
+
58
+ def response_key(method, path, params)
59
+ [ method, path, params ]
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "validators/text_to_speech_validator"
5
+
6
+ module RunwayML
7
+ class TextToSpeech
8
+ VALID_MODELS = [ "eleven_multilingual_v2" ].freeze
9
+
10
+ def initialize(client:)
11
+ @client = client
12
+ end
13
+
14
+ def create(model:, prompt_text:, voice:)
15
+ errors = {}
16
+
17
+ unless VALID_MODELS.include?(model)
18
+ errors[:model] = "must be one of: #{VALID_MODELS.join(', ')}"
19
+ raise ValidationError, errors
20
+ end
21
+
22
+ validator = Validators::TextToSpeechValidator.new
23
+ result = validator.validate(
24
+ prompt_text: prompt_text,
25
+ voice: voice
26
+ )
27
+
28
+ errors = result[:errors]
29
+ raise ValidationError, errors unless errors.empty?
30
+
31
+ inputs = build_inputs(prompt_text, voice)
32
+
33
+ response = client.post(
34
+ "text_to_speech",
35
+ inputs.merge(model: model)
36
+ )
37
+
38
+ Task.new(id: response["id"], client: client)
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :client
44
+
45
+ def build_inputs(prompt_text, voice)
46
+ {
47
+ promptText: prompt_text,
48
+ voice: voice
49
+ }
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "media_processor"
5
+ require_relative "validators/text_veo3_validator"
6
+ require_relative "validators/text_veo3_stable_validator"
7
+
8
+ module RunwayML
9
+ class TextToVideo
10
+ VALID_MODELS = [ "veo3.1", "veo3.1_fast", "veo3" ].freeze
11
+
12
+ def initialize(client:)
13
+ @client = client
14
+ end
15
+
16
+ def create(model:, prompt_text:, ratio:, duration:, audio: nil, auto_upload: true)
17
+ errors = {}
18
+
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 audio with auto-upload support if provided
25
+ processed_audio = if audio && audio != false
26
+ MediaProcessor.process(
27
+ audio,
28
+ errors,
29
+ client: client,
30
+ auto_upload: auto_upload
31
+ )
32
+ end
33
+
34
+ validator = get_validator(model)
35
+ result = validator.validate(**build_validator_params(model, prompt_text, ratio, duration, processed_audio || audio))
36
+
37
+ errors = result[:errors]
38
+ raise ValidationError, errors unless errors.empty?
39
+
40
+ inputs = build_inputs(model, prompt_text, ratio, duration, processed_audio || audio)
41
+
42
+ response = client.post(
43
+ "text_to_video",
44
+ inputs.merge(model: model)
45
+ )
46
+
47
+ Task.new(id: response["id"], client: client)
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :client
53
+
54
+ def get_validator(model)
55
+ case model
56
+ when "veo3.1", "veo3.1_fast"
57
+ Validators::TextVeo3Validator.new
58
+ when "veo3"
59
+ Validators::TextVeo3StableValidator.new
60
+ else
61
+ raise ArgumentError, "No validator configured for model: #{model}"
62
+ end
63
+ end
64
+
65
+ def build_validator_params(model, prompt_text, ratio, duration, audio)
66
+ case model
67
+ when "veo3.1", "veo3.1_fast"
68
+ {
69
+ prompt_text: prompt_text,
70
+ ratio: ratio,
71
+ duration: duration,
72
+ audio: audio
73
+ }
74
+ when "veo3"
75
+ {
76
+ prompt_text: prompt_text,
77
+ ratio: ratio,
78
+ duration: duration,
79
+ audio: audio
80
+ }
81
+ else
82
+ {}
83
+ end
84
+ end
85
+
86
+ def build_inputs(model, prompt_text, ratio, duration, audio)
87
+ inputs = {
88
+ promptText: prompt_text,
89
+ ratio: ratio,
90
+ duration: duration
91
+ }
92
+
93
+ case model
94
+ when "veo3.1", "veo3.1_fast"
95
+ inputs[:audio] = audio unless audio.nil?
96
+ when "veo3"
97
+ # audio not supported for veo3
98
+ end
99
+
100
+ inputs
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "validators/uploads_validator"
5
+
6
+ module RunwayML
7
+ class Uploads
8
+ VALID_TYPES = [ "ephemeral" ].freeze
9
+ SUPPORTED_EXTENSIONS = {
10
+ # Image extensions
11
+ "jpg" => "image/jpeg",
12
+ "jpeg" => "image/jpeg",
13
+ "png" => "image/png",
14
+ "webp" => "image/webp",
15
+ # Video extensions
16
+ "mp4" => "video/mp4",
17
+ "mov" => "video/quicktime",
18
+ "mkv" => "video/x-matroska",
19
+ "webm" => "video/webm",
20
+ "3gp" => "video/3gpp",
21
+ "ogv" => "video/ogg",
22
+ "avi" => "video/x-msvideo",
23
+ "flv" => "video/x-flv",
24
+ "mpg" => "video/mpeg",
25
+ "mpeg" => "video/mpeg",
26
+ # Audio extensions
27
+ "mp3" => "audio/mpeg",
28
+ "wav" => "audio/wav",
29
+ "flac" => "audio/flac",
30
+ "m4a" => "audio/mp4",
31
+ "aac" => "audio/aac",
32
+ "ogg" => "audio/ogg",
33
+ "weba" => "audio/webp"
34
+ }.freeze
35
+
36
+ def initialize(client:)
37
+ @client = client
38
+ end
39
+
40
+ def create_ephemeral(file_or_path, filename: nil)
41
+ if file_or_path.is_a?(String)
42
+ # It's a file path
43
+ unless File.exist?(file_or_path)
44
+ raise ValidationError, { file_path: "file does not exist" }
45
+ end
46
+ file_path = file_or_path
47
+ filename ||= File.basename(file_path)
48
+ elsif file_or_path.respond_to?(:read)
49
+ # It's a File object or StringIO
50
+ filename ||= file_or_path.respond_to?(:path) ? File.basename(file_or_path.path) : "upload"
51
+ else
52
+ raise ValidationError, { file: "must be a file path string or File-like object" }
53
+ end
54
+
55
+ errors = {}
56
+
57
+ # Validate filename
58
+ validator = Validators::UploadsValidator.new
59
+ result = validator.validate(filename: filename)
60
+ errors.merge!(result[:errors])
61
+
62
+ raise ValidationError, errors unless errors.empty?
63
+
64
+ # Request upload URL from API
65
+ response = client.post("uploads", { filename: filename, type: "ephemeral" })
66
+
67
+ response["runwayUri"]
68
+ end
69
+
70
+ private
71
+
72
+ attr_reader :client
73
+ end
74
+ end