generate_image 1.1.2.2 → 2.0.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 +4 -4
- data/.env.example +6 -0
- data/.github/workflows/ci.yml +23 -0
- data/.gitignore +14 -0
- data/.rspec +3 -0
- data/.travis.yml +6 -0
- data/CHANGELOG.md +44 -0
- data/CLAUDE.md +25 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +55 -0
- data/LICENSE.txt +21 -21
- data/README.md +165 -85
- data/Rakefile +6 -0
- data/bin/console +14 -14
- data/bin/setup +8 -8
- data/generate_image-1.1.2.2.gem +0 -0
- data/generate_image.gemspec +37 -0
- data/lib/generate_image/client.rb +201 -0
- data/lib/generate_image/configuration.rb +27 -0
- data/lib/generate_image/errors.rb +31 -0
- data/lib/generate_image/http.rb +215 -0
- data/lib/generate_image/models.rb +30 -0
- data/lib/generate_image/response.rb +54 -0
- data/lib/generate_image/version.rb +5 -3
- data/lib/generate_image.rb +21 -46
- data/scripts/gem-docker.cmd +5 -0
- data/scripts/gem-docker.ps1 +83 -0
- metadata +53 -50
- data/spec/generate_image_spec.rb +0 -9
- data/spec/spec_helper.rb +0 -14
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/generate_image/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "generate_image"
|
|
7
|
+
spec.version = GenerateImage::VERSION
|
|
8
|
+
spec.authors = ["AMQOR Merouane"]
|
|
9
|
+
spec.email = ["marouaneamqor@gmail.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "OpenAI Images API client (GPT Image, DALL·E) for Ruby"
|
|
12
|
+
spec.description = <<~DESC
|
|
13
|
+
Lightweight Ruby client for OpenAI image generation and edits (/v1/images/generations, /v1/images/edits).
|
|
14
|
+
Defaults to GPT Image models; stdlib-only (Net::HTTP + JSON). Ruby 3.1+.
|
|
15
|
+
DESC
|
|
16
|
+
spec.homepage = "https://github.com/merouaneamqor/generate_image"
|
|
17
|
+
spec.license = "MIT"
|
|
18
|
+
spec.required_ruby_version = ">= 3.1.0"
|
|
19
|
+
|
|
20
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
21
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
22
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
23
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
24
|
+
|
|
25
|
+
spec.files = Dir.chdir(__dir__) do
|
|
26
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
|
|
27
|
+
rescue StandardError
|
|
28
|
+
Dir["lib/**/*", "LICENSE.txt", "README.md", "CHANGELOG.md"]
|
|
29
|
+
end
|
|
30
|
+
spec.bindir = "exe"
|
|
31
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
32
|
+
spec.require_paths = ["lib"]
|
|
33
|
+
|
|
34
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
35
|
+
spec.add_development_dependency "rspec", "~> 3.12"
|
|
36
|
+
spec.add_development_dependency "webmock", "~> 3.19"
|
|
37
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
|
|
5
|
+
module GenerateImage
|
|
6
|
+
class Client
|
|
7
|
+
DEPRECATE_GENERATE_IMAGE_MSG =
|
|
8
|
+
"[DEPRECATION] GenerateImage::Client#generate_image is deprecated; use #generate instead."
|
|
9
|
+
|
|
10
|
+
def initialize(api_key = nil)
|
|
11
|
+
@api_key_override = api_key
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def generate(prompt, **opts)
|
|
15
|
+
raise ValidationError, "prompt must be a non-empty String" unless prompt.is_a?(String) && !prompt.strip.empty?
|
|
16
|
+
|
|
17
|
+
model = (opts[:model] || configuration.default_model).to_s
|
|
18
|
+
validate_generation_model!(model)
|
|
19
|
+
|
|
20
|
+
body = build_generation_body(prompt, model, opts)
|
|
21
|
+
parsed = http.post_json("/v1/images/generations", body)
|
|
22
|
+
Response.new(parsed, model: model)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def edit(image:, prompt:, **opts)
|
|
26
|
+
raise ValidationError, "prompt must be a non-empty String" unless prompt.is_a?(String) && !prompt.strip.empty?
|
|
27
|
+
raise ValidationError, "image is required" if image.nil?
|
|
28
|
+
|
|
29
|
+
model = (opts[:model] || configuration.default_model).to_s
|
|
30
|
+
validate_edit_model!(model)
|
|
31
|
+
|
|
32
|
+
fields = build_edit_fields(model, prompt, opts)
|
|
33
|
+
files = { "image" => image }
|
|
34
|
+
files["mask"] = opts[:mask] if opts[:mask]
|
|
35
|
+
|
|
36
|
+
parsed = http.post_multipart("/v1/images/edits", fields, files)
|
|
37
|
+
Response.new(parsed, model: model)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def generate_image(text, options = {})
|
|
41
|
+
warn "#{DEPRECATE_GENERATE_IMAGE_MSG}\n"
|
|
42
|
+
generate(text, **legacy_options_to_new(options)).to_h
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def configuration
|
|
48
|
+
base = GenerateImage.configuration
|
|
49
|
+
if @api_key_override && !@api_key_override.to_s.strip.empty?
|
|
50
|
+
KeyOverride.new(base, @api_key_override)
|
|
51
|
+
else
|
|
52
|
+
base
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def http
|
|
57
|
+
@http ||= HTTP.new(configuration: configuration)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def build_generation_body(prompt, model, opts)
|
|
61
|
+
cfg = configuration
|
|
62
|
+
n = (opts.key?(:n) ? opts[:n] : 1).to_i
|
|
63
|
+
size = (opts[:size] || cfg.default_size).to_s
|
|
64
|
+
|
|
65
|
+
validate_generation_n!(model, n)
|
|
66
|
+
validate_generation_size!(model, size)
|
|
67
|
+
|
|
68
|
+
body = {
|
|
69
|
+
prompt: prompt,
|
|
70
|
+
model: model,
|
|
71
|
+
n: n,
|
|
72
|
+
size: size
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if Models.gpt_image?(model)
|
|
76
|
+
body[:quality] = (opts[:quality] || cfg.default_quality).to_s
|
|
77
|
+
of = opts[:output_format] || cfg.default_output_format
|
|
78
|
+
body[:output_format] = of.to_s if of && !of.to_s.empty?
|
|
79
|
+
body[:background] = opts[:background].to_s if opts[:background]
|
|
80
|
+
body[:response_format] = opts[:response_format].to_s if opts[:response_format]
|
|
81
|
+
body[:style] = opts[:style].to_s if opts[:style]
|
|
82
|
+
elsif Models.dall_e_3?(model)
|
|
83
|
+
body[:quality] = (opts[:quality] || "standard").to_s
|
|
84
|
+
body[:response_format] = (opts[:response_format] || "url").to_s
|
|
85
|
+
body[:style] = opts[:style].to_s if opts[:style]
|
|
86
|
+
else
|
|
87
|
+
body[:response_format] = opts[:response_format].to_s if opts[:response_format]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
body[:user] = opts[:user].to_s if opts[:user]
|
|
91
|
+
body.compact
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def build_edit_fields(model, prompt, opts)
|
|
95
|
+
cfg = configuration
|
|
96
|
+
n = (opts.key?(:n) ? opts[:n] : 1).to_i
|
|
97
|
+
size = (opts[:size] || cfg.default_size).to_s
|
|
98
|
+
|
|
99
|
+
validate_generation_n!(model, n)
|
|
100
|
+
validate_generation_size!(model, size)
|
|
101
|
+
|
|
102
|
+
fields = {
|
|
103
|
+
"model" => model,
|
|
104
|
+
"prompt" => prompt,
|
|
105
|
+
"n" => n.to_s,
|
|
106
|
+
"size" => size
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if Models.gpt_image?(model)
|
|
110
|
+
fields["quality"] = (opts[:quality] || cfg.default_quality).to_s
|
|
111
|
+
of = opts[:output_format] || cfg.default_output_format
|
|
112
|
+
fields["output_format"] = of.to_s if of && !of.to_s.empty?
|
|
113
|
+
fields["background"] = opts[:background].to_s if opts[:background]
|
|
114
|
+
fields["input_fidelity"] = opts[:input_fidelity].to_s if opts[:input_fidelity]
|
|
115
|
+
fields["response_format"] = opts[:response_format].to_s if opts[:response_format]
|
|
116
|
+
elsif Models.dall_e_3?(model)
|
|
117
|
+
fields["quality"] = (opts[:quality] || "standard").to_s
|
|
118
|
+
fields["response_format"] = (opts[:response_format] || "url").to_s
|
|
119
|
+
elsif opts[:response_format]
|
|
120
|
+
fields["response_format"] = opts[:response_format].to_s
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
fields["user"] = opts[:user].to_s if opts[:user]
|
|
124
|
+
fields.compact
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def validate_generation_model!(model)
|
|
128
|
+
return if Models::ALL.include?(model)
|
|
129
|
+
|
|
130
|
+
raise ValidationError, "Unknown model #{model.inspect}. Expected one of: #{Models::ALL.join(", ")}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def validate_edit_model!(model)
|
|
134
|
+
# Edits are supported for gpt-image and dall-e-2 historically; dall-e-3 may differ — allow gpt + dall-e-2 + dall-e-3 per API
|
|
135
|
+
return if Models.gpt_image?(model) || model == Models::DALLE2 || model == Models::DALLE3
|
|
136
|
+
|
|
137
|
+
raise ValidationError,
|
|
138
|
+
"Unsupported edit model #{model.inspect}. Use a GPT Image model, #{Models::DALLE2}, or #{Models::DALLE3}."
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def validate_generation_n!(model, n)
|
|
142
|
+
raise ValidationError, "n must be between 1 and 10" unless n >= 1 && n <= 10
|
|
143
|
+
|
|
144
|
+
return unless Models.dall_e_3?(model)
|
|
145
|
+
|
|
146
|
+
raise ValidationError, "n must be 1 for #{model}" if n != 1
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def validate_generation_size!(model, size)
|
|
150
|
+
valid =
|
|
151
|
+
if Models.gpt_image?(model)
|
|
152
|
+
Models::GPT_IMAGE_SIZES.include?(size)
|
|
153
|
+
elsif Models.dall_e_3?(model)
|
|
154
|
+
Models::DALLE3_SIZES.include?(size)
|
|
155
|
+
else
|
|
156
|
+
Models::DALLE2_SIZES.include?(size)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
return if valid
|
|
160
|
+
|
|
161
|
+
allowed =
|
|
162
|
+
if Models.gpt_image?(model)
|
|
163
|
+
Models::GPT_IMAGE_SIZES
|
|
164
|
+
elsif Models.dall_e_3?(model)
|
|
165
|
+
Models::DALLE3_SIZES
|
|
166
|
+
else
|
|
167
|
+
Models::DALLE2_SIZES
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
raise ValidationError, "Invalid size #{size.inspect} for #{model}. Allowed: #{allowed.join(", ")}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def legacy_options_to_new(options)
|
|
174
|
+
h = options.transform_keys { |k| k.to_sym }
|
|
175
|
+
if h.key?(:num_images)
|
|
176
|
+
h[:n] = h.delete(:num_images)
|
|
177
|
+
end
|
|
178
|
+
if h.key?(:response_format)
|
|
179
|
+
case h[:response_format].to_s
|
|
180
|
+
when "base64", "b64"
|
|
181
|
+
h[:response_format] = "b64_json"
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
h
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
class KeyOverride < SimpleDelegator
|
|
188
|
+
def initialize(base, api_key)
|
|
189
|
+
super(base)
|
|
190
|
+
@override_key = api_key
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def resolved_api_key
|
|
194
|
+
s = @override_key.to_s
|
|
195
|
+
return s unless s.strip.empty?
|
|
196
|
+
|
|
197
|
+
__getobj__.resolved_api_key
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GenerateImage
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :api_key, :default_model, :base_url, :default_size, :default_quality,
|
|
6
|
+
:default_output_format, :open_timeout, :read_timeout, :max_retries
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@api_key = nil
|
|
10
|
+
@default_model = "gpt-image-1"
|
|
11
|
+
@base_url = "https://api.openai.com"
|
|
12
|
+
@default_size = "1024x1024"
|
|
13
|
+
@default_quality = "auto"
|
|
14
|
+
@default_output_format = "png"
|
|
15
|
+
@open_timeout = 30
|
|
16
|
+
@read_timeout = 120
|
|
17
|
+
@max_retries = 1
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def resolved_api_key
|
|
21
|
+
[api_key, ENV["OPENAI_API_KEY"], ENV["DALL_E_API_KEY"]]
|
|
22
|
+
.compact
|
|
23
|
+
.map(&:to_s)
|
|
24
|
+
.find { |s| !s.strip.empty? }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GenerateImage
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class AuthenticationError < Error; end
|
|
7
|
+
|
|
8
|
+
class RateLimitError < Error
|
|
9
|
+
attr_reader :retry_after
|
|
10
|
+
|
|
11
|
+
def initialize(message = nil, retry_after: 1)
|
|
12
|
+
super(message)
|
|
13
|
+
@retry_after = retry_after
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class ApiError < Error
|
|
18
|
+
attr_reader :status_code, :body
|
|
19
|
+
|
|
20
|
+
def initialize(message = nil, status_code: nil, body: nil)
|
|
21
|
+
super(message)
|
|
22
|
+
@status_code = status_code
|
|
23
|
+
@body = body
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class ValidationError < Error; end
|
|
28
|
+
|
|
29
|
+
# @deprecated Use {ApiError} instead. Kept for v1.x rescue compatibility.
|
|
30
|
+
RequestFailed = ApiError
|
|
31
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "stringio"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module GenerateImage
|
|
10
|
+
class HTTP
|
|
11
|
+
def initialize(configuration:)
|
|
12
|
+
@configuration = configuration
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def post_json(path, body_hash)
|
|
16
|
+
uri = build_uri(path)
|
|
17
|
+
body = JSON.generate(body_hash)
|
|
18
|
+
|
|
19
|
+
with_retries do
|
|
20
|
+
request = Net::HTTP::Post.new(uri)
|
|
21
|
+
request["Authorization"] = "Bearer #{api_key}"
|
|
22
|
+
request["Content-Type"] = "application/json"
|
|
23
|
+
request.body = body
|
|
24
|
+
perform(uri, request)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def post_multipart(path, fields, files)
|
|
29
|
+
uri = build_uri(path)
|
|
30
|
+
boundary = "----RubyGenerateImage#{SecureRandom.hex(16)}"
|
|
31
|
+
body = build_multipart_body(boundary, fields, files)
|
|
32
|
+
|
|
33
|
+
with_retries do
|
|
34
|
+
request = Net::HTTP::Post.new(uri)
|
|
35
|
+
request["Authorization"] = "Bearer #{api_key}"
|
|
36
|
+
request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
|
|
37
|
+
request.body = body
|
|
38
|
+
perform(uri, request)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
attr_reader :configuration
|
|
45
|
+
|
|
46
|
+
def api_key
|
|
47
|
+
key = configuration.resolved_api_key
|
|
48
|
+
raise AuthenticationError, "Missing API key. Set OPENAI_API_KEY or configure api_key." if key.nil? || key.strip.empty?
|
|
49
|
+
|
|
50
|
+
key
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def build_uri(path)
|
|
54
|
+
base = configuration.base_url.to_s.chomp("/")
|
|
55
|
+
URI.join("#{base}/", path.delete_prefix("/"))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def perform(uri, request)
|
|
59
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
60
|
+
http.use_ssl = uri.scheme == "https"
|
|
61
|
+
http.open_timeout = configuration.open_timeout
|
|
62
|
+
http.read_timeout = configuration.read_timeout
|
|
63
|
+
|
|
64
|
+
response = http.request(request)
|
|
65
|
+
handle_response(response)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def handle_response(response)
|
|
69
|
+
case response.code.to_i
|
|
70
|
+
when 200, 201
|
|
71
|
+
parse_json_body(response.body)
|
|
72
|
+
when 401
|
|
73
|
+
raise AuthenticationError, error_message_from(response)
|
|
74
|
+
when 429
|
|
75
|
+
raise RateLimitError.new(
|
|
76
|
+
error_message_from(response),
|
|
77
|
+
retry_after: parse_retry_after(response["Retry-After"])
|
|
78
|
+
)
|
|
79
|
+
else
|
|
80
|
+
raise ApiError.new(error_message_from(response), status_code: response.code.to_i, body: response.body)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def parse_json_body(body)
|
|
85
|
+
return {} if body.nil? || body.empty?
|
|
86
|
+
|
|
87
|
+
JSON.parse(body)
|
|
88
|
+
rescue JSON::ParserError
|
|
89
|
+
raise ApiError.new("Invalid JSON response", body: body)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def error_message_from(response)
|
|
93
|
+
parsed = safe_parse_json(response.body)
|
|
94
|
+
err = parsed["error"]
|
|
95
|
+
if err.is_a?(Hash)
|
|
96
|
+
err["message"] || err.inspect
|
|
97
|
+
elsif err.is_a?(String)
|
|
98
|
+
err
|
|
99
|
+
else
|
|
100
|
+
"HTTP #{response.code}: #{response.message}"
|
|
101
|
+
end
|
|
102
|
+
rescue StandardError
|
|
103
|
+
"HTTP #{response.code}: #{response.message}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def safe_parse_json(body)
|
|
107
|
+
return {} if body.nil? || body.empty?
|
|
108
|
+
|
|
109
|
+
JSON.parse(body)
|
|
110
|
+
rescue JSON::ParserError, TypeError
|
|
111
|
+
{}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def parse_retry_after(value)
|
|
115
|
+
s = value.to_s.strip
|
|
116
|
+
return 1 if s.empty?
|
|
117
|
+
|
|
118
|
+
if s.match?(/^\d+$/)
|
|
119
|
+
s.to_i.clamp(1, 120)
|
|
120
|
+
else
|
|
121
|
+
1
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def with_retries
|
|
126
|
+
attempts = 0
|
|
127
|
+
max = [configuration.max_retries.to_i, 0].max
|
|
128
|
+
|
|
129
|
+
begin
|
|
130
|
+
yield
|
|
131
|
+
rescue RateLimitError => e
|
|
132
|
+
attempts += 1
|
|
133
|
+
raise e if attempts > max
|
|
134
|
+
|
|
135
|
+
sleep(e.retry_after.to_f.positive? ? e.retry_after : 1)
|
|
136
|
+
retry
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def build_multipart_body(boundary, fields, files)
|
|
141
|
+
io = StringIO.new
|
|
142
|
+
crlf = "\r\n"
|
|
143
|
+
|
|
144
|
+
fields.each do |name, value|
|
|
145
|
+
next if value.nil?
|
|
146
|
+
|
|
147
|
+
io << "--#{boundary}#{crlf}"
|
|
148
|
+
io << "Content-Disposition: form-data; name=\"#{escape_name(name)}\"#{crlf}#{crlf}"
|
|
149
|
+
io << value.to_s
|
|
150
|
+
io << crlf
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
files.each do |name, file_spec|
|
|
154
|
+
filename, content, content_type = normalize_file_spec(name, file_spec)
|
|
155
|
+
io << "--#{boundary}#{crlf}"
|
|
156
|
+
io << "Content-Disposition: form-data; name=\"#{escape_name(name)}\"; filename=\"#{escape_filename(filename)}\"#{crlf}"
|
|
157
|
+
io << "Content-Type: #{content_type}#{crlf}#{crlf}"
|
|
158
|
+
io << content
|
|
159
|
+
io << crlf
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
io << "--#{boundary}--#{crlf}"
|
|
163
|
+
io.string
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def escape_name(name)
|
|
167
|
+
name.to_s.gsub(/["\r\n]/, "")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def escape_filename(name)
|
|
171
|
+
name.to_s.gsub(/["\r\n]/, "")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def normalize_file_spec(name, spec)
|
|
175
|
+
case spec
|
|
176
|
+
when String
|
|
177
|
+
path = spec
|
|
178
|
+
[File.basename(path), File.binread(path), mime_for_path(path)]
|
|
179
|
+
when Pathname
|
|
180
|
+
path = spec.to_s
|
|
181
|
+
[File.basename(path), File.binread(path), mime_for_path(path)]
|
|
182
|
+
when Hash
|
|
183
|
+
filename = spec[:filename] || spec["filename"] || "image.png"
|
|
184
|
+
io = spec[:io] || spec["io"]
|
|
185
|
+
content_type = spec[:content_type] || spec["content_type"] || "application/octet-stream"
|
|
186
|
+
content =
|
|
187
|
+
if io.respond_to?(:read)
|
|
188
|
+
io.rewind if io.respond_to?(:rewind)
|
|
189
|
+
io.read
|
|
190
|
+
else
|
|
191
|
+
spec[:data] || spec["data"] || ""
|
|
192
|
+
end
|
|
193
|
+
[filename, content, content_type]
|
|
194
|
+
else
|
|
195
|
+
if spec.respond_to?(:read)
|
|
196
|
+
filename = spec.respond_to?(:path) && spec.path ? File.basename(spec.path) : "#{name}.png"
|
|
197
|
+
spec.rewind if spec.respond_to?(:rewind)
|
|
198
|
+
[filename, spec.read, "application/octet-stream"]
|
|
199
|
+
else
|
|
200
|
+
raise ValidationError, "Unsupported file payload for #{name}"
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def mime_for_path(path)
|
|
206
|
+
case File.extname(path).downcase
|
|
207
|
+
when ".png" then "image/png"
|
|
208
|
+
when ".jpg", ".jpeg" then "image/jpeg"
|
|
209
|
+
when ".webp" then "image/webp"
|
|
210
|
+
when ".gif" then "image/gif"
|
|
211
|
+
else "application/octet-stream"
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GenerateImage
|
|
4
|
+
module Models
|
|
5
|
+
GPT_IMAGE = %w[gpt-image-1 gpt-image-1.5 gpt-image-1-mini].freeze
|
|
6
|
+
DALLE3 = "dall-e-3"
|
|
7
|
+
DALLE2 = "dall-e-2"
|
|
8
|
+
|
|
9
|
+
ALL = (GPT_IMAGE + [DALLE3, DALLE2]).freeze
|
|
10
|
+
|
|
11
|
+
GPT_IMAGE_SIZES = %w[auto 1024x1024 1536x1024 1024x1536].freeze
|
|
12
|
+
DALLE3_SIZES = %w[1024x1024 1792x1024 1024x1792].freeze
|
|
13
|
+
DALLE2_SIZES = %w[256x256 512x512 1024x1024].freeze
|
|
14
|
+
|
|
15
|
+
GPT_IMAGE_QUALITIES = %w[auto high medium low].freeze
|
|
16
|
+
DALLE3_QUALITIES = %w[standard hd].freeze
|
|
17
|
+
|
|
18
|
+
OUTPUT_FORMATS = %w[png jpeg webp].freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def gpt_image?(model)
|
|
23
|
+
GPT_IMAGE.include?(model.to_s)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def dall_e_3?(model)
|
|
27
|
+
model.to_s == DALLE3
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GenerateImage
|
|
4
|
+
class Response
|
|
5
|
+
attr_reader :raw, :model
|
|
6
|
+
|
|
7
|
+
def initialize(parsed_json, model: nil)
|
|
8
|
+
@raw = parsed_json
|
|
9
|
+
@model = model
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def data_items
|
|
13
|
+
Array(raw["data"])
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def images
|
|
17
|
+
data_items.map do |item|
|
|
18
|
+
if item["url"]
|
|
19
|
+
{ url: item["url"] }
|
|
20
|
+
elsif item["b64_json"]
|
|
21
|
+
{ b64_json: item["b64_json"] }
|
|
22
|
+
else
|
|
23
|
+
{}
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def url
|
|
29
|
+
data_items.find { |d| d["url"] }&.fetch("url", nil)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def b64
|
|
33
|
+
data_items.find { |d| d["b64_json"] }&.fetch("b64_json", nil)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def usage
|
|
37
|
+
raw["usage"]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def created
|
|
41
|
+
raw["created"]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def to_h
|
|
45
|
+
if url
|
|
46
|
+
{ image_url: url }
|
|
47
|
+
elsif b64
|
|
48
|
+
{ image_base64: b64 }
|
|
49
|
+
else
|
|
50
|
+
{}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/generate_image.rb
CHANGED
|
@@ -1,46 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
request.body = {
|
|
23
|
-
prompt: text,
|
|
24
|
-
n: options[:num_images] || 1,
|
|
25
|
-
size: options[:size] || '512x512',
|
|
26
|
-
response_format: options[:response_format] || 'url',
|
|
27
|
-
}.to_json
|
|
28
|
-
|
|
29
|
-
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
|
|
30
|
-
http.request(request)
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
if response.code == '200'
|
|
34
|
-
if options[:response_format] == 'base64'
|
|
35
|
-
image_base64 = JSON.parse(response.body)['data'][0]['base64']
|
|
36
|
-
return { image_base64: image_base64 }
|
|
37
|
-
else
|
|
38
|
-
image_url = JSON.parse(response.body)['data'][0]['url']
|
|
39
|
-
return { image_url: image_url }
|
|
40
|
-
end
|
|
41
|
-
else
|
|
42
|
-
raise RequestFailed, "Failed to generate image. Response code: #{response.code}. Response body: #{response.body}"
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "generate_image/version"
|
|
4
|
+
require_relative "generate_image/errors"
|
|
5
|
+
require_relative "generate_image/configuration"
|
|
6
|
+
require_relative "generate_image/models"
|
|
7
|
+
require_relative "generate_image/response"
|
|
8
|
+
require_relative "generate_image/http"
|
|
9
|
+
require_relative "generate_image/client"
|
|
10
|
+
|
|
11
|
+
module GenerateImage
|
|
12
|
+
class << self
|
|
13
|
+
def configuration
|
|
14
|
+
@configuration ||= Configuration.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def configure
|
|
18
|
+
yield configuration
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|