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
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ # Load custom OpenAPI validation tasks
9
+ Dir.glob("lib/tasks/*.rake").each { |r| load r }
10
+
11
+ task default: %i[spec]
@@ -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