flux3_ai_prompt_tools 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.
@@ -0,0 +1,349 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+
6
+ require_relative "../flux3_ai_prompt_tools"
7
+ require_relative "errors"
8
+ require_relative "layouts"
9
+ require_relative "limits"
10
+ require_relative "reader"
11
+
12
+ module Flux3AiPromptTools
13
+ # Command-line entry point used by bin/flux3-prompt.
14
+ class CLI
15
+ def self.start(argv)
16
+ new(argv).start
17
+ end
18
+
19
+ def initialize(argv)
20
+ @argv = argv.map(&:dup)
21
+ end
22
+
23
+ def start
24
+ command = @argv.shift
25
+ case command
26
+ when nil
27
+ $stdout.write(self.class.help_text)
28
+ 2
29
+ when "help", "-h", "--help"
30
+ $stdout.write(self.class.help_text)
31
+ 0
32
+ when "version", "-v", "--version"
33
+ $stdout.puts(VERSION)
34
+ 0
35
+ when "schema"
36
+ schema_command
37
+ when "presets"
38
+ presets_command
39
+ when "assemble"
40
+ assemble_command
41
+ when "validate"
42
+ validate_command
43
+ when "batch"
44
+ batch_command
45
+ else
46
+ raise UsageError, "unknown command #{command.inspect}. See `flux3-prompt help`."
47
+ end
48
+ end
49
+
50
+ def self.help_text
51
+ <<~HELP
52
+ flux3-prompt #{VERSION}
53
+
54
+ Validate structured prompt inputs and assemble text-to-image prompt
55
+ manifests on this machine. This command does not generate images and
56
+ does not contact a network service.
57
+
58
+ Usage:
59
+ flux3-prompt assemble --subject TEXT [options]
60
+ flux3-prompt validate FILE [options]
61
+ flux3-prompt batch FILE [-o PATH] [options]
62
+ flux3-prompt schema
63
+ flux3-prompt presets
64
+ flux3-prompt version
65
+ flux3-prompt help
66
+
67
+ Assemble options:
68
+ --subject TEXT Required picture subject
69
+ --scene, --medium, --style, --lighting, --composition, --camera,
70
+ --palette, --mood TEXT Optional art direction
71
+ --detail TEXT Repeatable detail, weight 1
72
+ --negative TEXT Repeatable negative phrase
73
+ --aspect-ratio RATIO Stored metadata, for example 4:5
74
+ --seed N Stored integer metadata
75
+ --steps N Stored integer metadata from 1 through 150
76
+ --guidance N Stored numeric metadata from 0 through 30
77
+ --note TEXT Copied to the manifest, omitted from prompts
78
+ --preset NAME Local writing template
79
+ --id ID Optional record id
80
+ --layout NAME natural, tagged, or labeled (default natural)
81
+ --manifest Print the JSON record instead of prompt text
82
+
83
+ Batch and validate options:
84
+ --input-format FORMAT auto, json, or jsonl (default auto)
85
+ --format FORMAT json or jsonl output for batch
86
+ --layout NAME Default layout for records that do not set one
87
+ --keep-going Write failed records with ok false instead of stopping
88
+ -o, --output PATH Batch output file. Stdout is used when omitted
89
+ --max-prompt-chars N Default #{Limits::DEFAULT_MAX_PROMPT_CHARS}
90
+ --max-negative-chars N Default #{Limits::DEFAULT_MAX_NEGATIVE_CHARS}
91
+ --max-records N Default #{Limits::DEFAULT_MAX_RECORDS}
92
+ --max-input-bytes N Default #{Limits::DEFAULT_MAX_INPUT_BYTES}
93
+
94
+ Weighted details belong in JSON, as {"text":"wide rim","weight":1.2}.
95
+ FILE may be - when the document is piped to stdin.
96
+
97
+ Exit status is 0 for success, 1 for invalid input or records, and 2
98
+ for an invalid command line.
99
+
100
+ Homepage: https://flux3-ai.com/
101
+ HELP
102
+ end
103
+
104
+ private
105
+
106
+ def schema_command
107
+ raise UsageError, "schema does not take arguments" unless @argv.empty?
108
+
109
+ $stdout.write(JSON.pretty_generate(Schema.document) + "\n")
110
+ 0
111
+ end
112
+
113
+ def presets_command
114
+ raise UsageError, "presets does not take arguments" unless @argv.empty?
115
+
116
+ $stdout.write(JSON.pretty_generate(Presets.list) + "\n")
117
+ 0
118
+ end
119
+
120
+ def assemble_command
121
+ record = {}
122
+ options = { layout: Layouts::DEFAULT, manifest: false }
123
+ parser = OptionParser.new do |opts|
124
+ opts.banner = "Usage: flux3-prompt assemble --subject TEXT [options]"
125
+ opts.on("--id ID") { |value| record["id"] = value }
126
+ opts.on("--subject TEXT") { |value| record["subject"] = value }
127
+ opts.on("--scene TEXT") { |value| record["scene"] = value }
128
+ opts.on("--medium TEXT") { |value| record["medium"] = value }
129
+ opts.on("--style TEXT") { |value| record["style"] = value }
130
+ opts.on("--lighting TEXT") { |value| record["lighting"] = value }
131
+ opts.on("--composition TEXT") { |value| record["composition"] = value }
132
+ opts.on("--camera TEXT") { |value| record["camera"] = value }
133
+ opts.on("--palette TEXT") { |value| record["palette"] = value }
134
+ opts.on("--mood TEXT") { |value| record["mood"] = value }
135
+ opts.on("--detail TEXT") { |value| (record["details"] ||= []) << value }
136
+ opts.on("--negative TEXT") { |value| (record["negatives"] ||= []) << value }
137
+ opts.on("--aspect-ratio RATIO") { |value| record["aspect_ratio"] = value }
138
+ opts.on("--seed N", Integer) { |value| record["seed"] = value }
139
+ opts.on("--steps N", Integer) { |value| record["steps"] = value }
140
+ opts.on("--guidance N", Float) { |value| record["guidance"] = value }
141
+ opts.on("--note TEXT") { |value| record["note"] = value }
142
+ opts.on("--preset NAME") { |value| record["preset"] = value }
143
+ opts.on("--layout NAME") { |value| options[:layout] = value }
144
+ opts.on("--manifest", "Print the JSON record instead of the prompt text") { options[:manifest] = true }
145
+ add_text_budget_flags(opts, options)
146
+ end
147
+ parse!(parser)
148
+ raise UsageError, "assemble does not take positional arguments" unless @argv.empty?
149
+
150
+ check_layout!(options[:layout])
151
+ result = Flux3AiPromptTools.assemble(record, layout: options[:layout], limits: limits_from(options))
152
+ if options[:manifest]
153
+ $stdout.write(JSON.pretty_generate(result.to_h) + "\n")
154
+ else
155
+ text = result.prompt
156
+ text += "\n" unless text.end_with?("\n")
157
+ $stdout.write(text)
158
+ end
159
+ 0
160
+ end
161
+
162
+ def validate_command
163
+ options = default_batch_options
164
+ parser = OptionParser.new do |opts|
165
+ opts.banner = "Usage: flux3-prompt validate FILE [options]"
166
+ add_common_batch_flags(opts, options)
167
+ end
168
+ parse!(parser)
169
+ raise UsageError, "validate requires a FILE or -" unless @argv.length == 1
170
+
171
+ check_choices!(options)
172
+ records = read_records(@argv.fetch(0), options)
173
+ Flux3AiPromptTools.build_manifest(records, layout: options[:layout], limits: limits_from(options))
174
+ $stdout.puts("valid: #{records.length} record(s)")
175
+ 0
176
+ end
177
+
178
+ def batch_command
179
+ options = default_batch_options
180
+ parser = OptionParser.new do |opts|
181
+ opts.banner = "Usage: flux3-prompt batch FILE [-o PATH] [options]"
182
+ opts.on("-o", "--output PATH", "Write the manifest to PATH") { |value| options[:output] = value }
183
+ opts.on("--format FORMAT", "json or jsonl") { |value| options[:format] = value }
184
+ opts.on("--keep-going", "Write records that failed validation") { options[:keep_going] = true }
185
+ add_common_batch_flags(opts, options)
186
+ end
187
+ parse!(parser)
188
+ raise UsageError, "batch requires a FILE or -" unless @argv.length == 1
189
+
190
+ check_choices!(options)
191
+ format = resolve_format(options)
192
+ records = read_records(@argv.fetch(0), options)
193
+ manifest = Flux3AiPromptTools.build_manifest(
194
+ records,
195
+ layout: options[:layout],
196
+ limits: limits_from(options),
197
+ keep_going: options[:keep_going]
198
+ )
199
+ body = format == "json" ? manifest.to_json_document : manifest.to_jsonl
200
+ write_output(options[:output], body)
201
+ report_batch(manifest, options[:output])
202
+ manifest.ok? ? 0 : 1
203
+ end
204
+
205
+ def default_batch_options
206
+ {
207
+ layout: Layouts::DEFAULT,
208
+ input_format: "auto",
209
+ output: nil,
210
+ format: nil,
211
+ keep_going: false
212
+ }
213
+ end
214
+
215
+ def add_common_batch_flags(parser, options)
216
+ parser.on("--layout NAME", "natural, tagged, or labeled") { |value| options[:layout] = value }
217
+ parser.on("--input-format FORMAT", "auto, json, or jsonl") { |value| options[:input_format] = value }
218
+ add_text_budget_flags(parser, options)
219
+ add_input_budget_flags(parser, options)
220
+ end
221
+
222
+ def add_text_budget_flags(parser, options)
223
+ parser.on("--max-prompt-chars N", Integer, "Assembled prompt character limit") do |value|
224
+ raise UsageError, "max-prompt-chars must be a positive integer" unless value.positive?
225
+
226
+ options[:max_prompt_chars] = value
227
+ end
228
+ parser.on("--max-negative-chars N", Integer, "Assembled negative prompt character limit") do |value|
229
+ raise UsageError, "max-negative-chars must be a positive integer" unless value.positive?
230
+
231
+ options[:max_negative_chars] = value
232
+ end
233
+ end
234
+
235
+ def add_input_budget_flags(parser, options)
236
+ parser.on("--max-records N", Integer, "Maximum records in one batch") do |value|
237
+ raise UsageError, "max-records must be a positive integer" unless value.positive?
238
+
239
+ options[:max_records] = value
240
+ end
241
+ parser.on("--max-input-bytes N", Integer, "Maximum input size in bytes") do |value|
242
+ raise UsageError, "max-input-bytes must be a positive integer" unless value.positive?
243
+
244
+ options[:max_input_bytes] = value
245
+ end
246
+ end
247
+
248
+ def parse!(parser)
249
+ parser.parse!(@argv)
250
+ rescue OptionParser::ParseError => e
251
+ raise UsageError, e.message
252
+ end
253
+
254
+ def check_choices!(options)
255
+ check_layout!(options[:layout])
256
+ return if %w[auto json jsonl].include?(options[:input_format])
257
+
258
+ raise UsageError, "input format must be auto, json, or jsonl"
259
+ end
260
+
261
+ def check_layout!(layout)
262
+ return if Layouts::NAMES.include?(layout)
263
+
264
+ raise UsageError, "layout must be one of: #{Layouts::NAMES.join(', ')}"
265
+ end
266
+
267
+ def resolve_format(options)
268
+ format = options[:format]
269
+ if format
270
+ return format if %w[json jsonl].include?(format)
271
+
272
+ raise UsageError, "format must be json or jsonl"
273
+ end
274
+
275
+ path = options[:output]
276
+ return "jsonl" if path.nil?
277
+ return "jsonl" if path.match?(/\.jsonl\z/i)
278
+ return "json" if path.match?(/\.json\z/i)
279
+
280
+ "json"
281
+ end
282
+
283
+ def read_records(path, options)
284
+ limits = limits_from(options)
285
+ if path == "-"
286
+ Reader.load(read_stdin(limits), format: options[:input_format], limits: limits)
287
+ else
288
+ Reader.load_file(path, format: options[:input_format], limits: limits)
289
+ end
290
+ end
291
+
292
+ def read_stdin(limits)
293
+ data = $stdin.binmode.read(limits.max_input_bytes + 1)
294
+ data = "" if data.nil?
295
+ if data.bytesize > limits.max_input_bytes
296
+ raise InputError, "input is larger than #{limits.max_input_bytes} bytes"
297
+ end
298
+
299
+ data.force_encoding(Encoding::UTF_8)
300
+ raise InputError, "input is not valid UTF-8" unless data.valid_encoding?
301
+
302
+ data
303
+ end
304
+
305
+ def limits_from(options)
306
+ Limits.new(
307
+ max_prompt_chars: options.fetch(:max_prompt_chars, Limits::DEFAULT_MAX_PROMPT_CHARS),
308
+ max_negative_chars: options.fetch(:max_negative_chars, Limits::DEFAULT_MAX_NEGATIVE_CHARS),
309
+ max_records: options.fetch(:max_records, Limits::DEFAULT_MAX_RECORDS),
310
+ max_input_bytes: options.fetch(:max_input_bytes, Limits::DEFAULT_MAX_INPUT_BYTES)
311
+ )
312
+ end
313
+
314
+ def write_output(path, body)
315
+ if path.nil?
316
+ $stdout.write(body)
317
+ return
318
+ end
319
+ raise UsageError, "output path is a directory: #{path}" if File.directory?(path)
320
+
321
+ directory = File.dirname(path)
322
+ raise UsageError, "output directory does not exist: #{directory}" unless File.directory?(directory)
323
+
324
+ temporary = File.join(directory, ".#{File.basename(path)}.#{Process.pid}.tmp")
325
+ File.write(temporary, body, encoding: Encoding::UTF_8)
326
+ File.rename(temporary, path)
327
+ rescue StandardError
328
+ File.delete(temporary) if defined?(temporary) && temporary && File.exist?(temporary)
329
+ raise
330
+ end
331
+
332
+ def report_batch(manifest, path)
333
+ return if path.nil? && manifest.ok?
334
+
335
+ failed = manifest.records.count { |record| !record.ok }
336
+ if path
337
+ suffix = failed.zero? ? "" : " (#{failed} failed)"
338
+ warn "wrote #{manifest.records.length} record(s) to #{path}#{suffix}"
339
+ end
340
+ return if failed.zero?
341
+
342
+ manifest.records.each do |record|
343
+ record.errors.each do |issue|
344
+ warn "error: #{issue.path} #{issue.code}: #{issue.message}"
345
+ end
346
+ end
347
+ end
348
+ end
349
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flux3AiPromptTools
4
+ # Base class for errors raised by this gem.
5
+ class Error < StandardError; end
6
+
7
+ # The command line itself is invalid. CLI exit status 2.
8
+ class UsageError < Error; end
9
+
10
+ # A file or document could not be read as JSON or JSONL. CLI exit status 1.
11
+ class InputError < Error
12
+ attr_reader :line
13
+
14
+ def initialize(message, line: nil)
15
+ @line = line
16
+ super(message)
17
+ end
18
+ end
19
+
20
+ # One validation problem on a record or on assembled text.
21
+ class Issue
22
+ attr_reader :path, :code, :message
23
+
24
+ def initialize(path, code, message)
25
+ @path = path
26
+ @code = code
27
+ @message = message
28
+ freeze
29
+ end
30
+
31
+ def to_h
32
+ { "path" => path, "code" => code, "message" => message }
33
+ end
34
+ end
35
+
36
+ # One or more field, budget, or batch-consistency problems.
37
+ class ValidationError < Error
38
+ attr_reader :issues
39
+
40
+ def initialize(issues)
41
+ unless issues.is_a?(Array) && issues.all? { |issue| issue.is_a?(Issue) }
42
+ raise ArgumentError, "issues must be an array of Issue"
43
+ end
44
+
45
+ @issues = issues.freeze
46
+ super(issues.map { |issue| "#{issue.path} #{issue.code}: #{issue.message}" }.join("\n"))
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flux3AiPromptTools
4
+ # Shared limits for the validator, the JSON description, and the CLI help text.
5
+ module Fields
6
+ NAMES = %w[
7
+ id subject scene medium style lighting composition camera palette mood
8
+ details negatives aspect_ratio seed steps guidance note layout preset
9
+ ].freeze
10
+
11
+ STRING_LIMITS = {
12
+ "subject" => 400,
13
+ "scene" => 400,
14
+ "medium" => 120,
15
+ "style" => 200,
16
+ "lighting" => 300,
17
+ "composition" => 300,
18
+ "camera" => 200,
19
+ "palette" => 200,
20
+ "mood" => 200,
21
+ "note" => 500
22
+ }.freeze
23
+
24
+ DETAIL_TEXT_LIMIT = 160
25
+ MAX_DETAILS = 32
26
+ MAX_NEGATIVES = 32
27
+ MIN_WEIGHT_HUNDREDTHS = 10
28
+ MAX_WEIGHT_HUNDREDTHS = 200
29
+ MIN_GUIDANCE_HUNDREDTHS = 0
30
+ MAX_GUIDANCE_HUNDREDTHS = 3_000
31
+ MIN_STEPS = 1
32
+ MAX_STEPS = 150
33
+ MAX_ASPECT_SIDE = 64
34
+ ID_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,63}\z/
35
+ ASPECT_PATTERN = /\A[1-9]\d?:[1-9]\d?\z/
36
+ end
37
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flux3AiPromptTools
4
+ module Layouts
5
+ NAMES = %w[natural tagged labeled].freeze
6
+ DEFAULT = "natural"
7
+ end
8
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flux3AiPromptTools
4
+ # Budgets for assembled text and for a batch document.
5
+ class Limits
6
+ attr_reader :max_prompt_chars, :max_negative_chars, :max_records, :max_input_bytes
7
+
8
+ DEFAULT_MAX_PROMPT_CHARS = 2_000
9
+ DEFAULT_MAX_NEGATIVE_CHARS = 1_000
10
+ DEFAULT_MAX_RECORDS = 5_000
11
+ DEFAULT_MAX_INPUT_BYTES = 5_000_000
12
+
13
+ # Largest integer that every JSON consumer can round-trip as a number.
14
+ MAX_SEED = (2**53) - 1
15
+
16
+ def initialize(max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
17
+ max_negative_chars: DEFAULT_MAX_NEGATIVE_CHARS,
18
+ max_records: DEFAULT_MAX_RECORDS,
19
+ max_input_bytes: DEFAULT_MAX_INPUT_BYTES)
20
+ @max_prompt_chars = self.class.positive_integer!(max_prompt_chars, "max_prompt_chars")
21
+ @max_negative_chars = self.class.positive_integer!(max_negative_chars, "max_negative_chars")
22
+ @max_records = self.class.positive_integer!(max_records, "max_records")
23
+ @max_input_bytes = self.class.positive_integer!(max_input_bytes, "max_input_bytes")
24
+ freeze
25
+ end
26
+
27
+ def self.positive_integer!(value, name)
28
+ unless value.is_a?(Integer) && value.positive?
29
+ raise ArgumentError, "#{name} must be a positive integer"
30
+ end
31
+
32
+ value
33
+ end
34
+
35
+ DEFAULT = new
36
+ end
37
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "assembler"
6
+ require_relative "errors"
7
+ require_relative "layouts"
8
+ require_relative "limits"
9
+ require_relative "specification"
10
+ require_relative "validator"
11
+ require_relative "version"
12
+
13
+ module Flux3AiPromptTools
14
+ # A batch of assembled records in input order.
15
+ class Manifest
16
+ attr_reader :records, :layout
17
+
18
+ def initialize(records, layout:)
19
+ @records = records.dup.freeze
20
+ @layout = layout
21
+ freeze
22
+ end
23
+
24
+ def ok?
25
+ records.all?(&:ok)
26
+ end
27
+
28
+ def to_h
29
+ {
30
+ "generator" => "flux3_ai_prompt_tools/#{VERSION}",
31
+ "layout" => layout,
32
+ "ok" => ok?,
33
+ "records" => records.map(&:to_h)
34
+ }
35
+ end
36
+
37
+ def to_json_document
38
+ JSON.pretty_generate(to_h) + "\n"
39
+ end
40
+
41
+ def to_jsonl
42
+ records.map { |record| JSON.generate(record.to_h) }.join("\n") + (records.empty? ? "" : "\n")
43
+ end
44
+
45
+ def self.build(inputs, layout: Layouts::DEFAULT, limits: Limits::DEFAULT, keep_going: false)
46
+ unless inputs.is_a?(Array)
47
+ raise ArgumentError, "inputs must be an array of records"
48
+ end
49
+ unless layout.is_a?(String) && Layouts::NAMES.include?(layout)
50
+ raise ArgumentError, "layout must be one of: #{Layouts::NAMES.join(', ')}"
51
+ end
52
+ unless limits.is_a?(Limits)
53
+ raise ArgumentError, "limits must be a Flux3AiPromptTools::Limits"
54
+ end
55
+ raise InputError, "batch has no records" if inputs.empty?
56
+ if inputs.length > limits.max_records
57
+ raise InputError, "batch has #{inputs.length} records; limit is #{limits.max_records}"
58
+ end
59
+
60
+ validator = Validator.new(limits)
61
+ rows = inputs.each_with_index.map do |input, index|
62
+ pointer = "/records/#{index}"
63
+ clean, issues, explicit_id = validator.prepare(input, pointer: pointer)
64
+ { index: index, clean: clean, issues: issues, explicit_id: explicit_id, assigned_id: nil }
65
+ end
66
+
67
+ assign_ids!(rows)
68
+ all_issues = rows.flat_map { |row| row[:issues] }
69
+ results = []
70
+ fingerprints = {}
71
+
72
+ rows.each do |row|
73
+ if row[:issues].any?
74
+ results << failure_result(row, layout)
75
+ next
76
+ end
77
+
78
+ spec = Specification.from_clean(row[:clean], id: row[:explicit_id] || row[:assigned_id])
79
+ result = Assembler.assemble(spec, layout: layout, limits: limits, raise_on_error: false)
80
+ unless result.ok
81
+ prefixed = result.errors.map { |issue| prefix_issue(issue, row[:index]) }
82
+ all_issues.concat(prefixed)
83
+ results << replace_errors(result, prefixed)
84
+ next
85
+ end
86
+
87
+ fingerprint = fingerprint_for(result)
88
+ if fingerprints.key?(fingerprint)
89
+ issue = Issue.new(
90
+ "/records/#{row[:index]}",
91
+ "duplicate_job",
92
+ "this record repeats the prompt, negative prompt, layout, and parameters of /records/#{fingerprints[fingerprint]}"
93
+ )
94
+ all_issues << issue
95
+ results << replace_errors(result, [issue])
96
+ else
97
+ fingerprints[fingerprint] = row[:index]
98
+ results << result
99
+ end
100
+ end
101
+
102
+ raise ValidationError, all_issues if !keep_going && all_issues.any?
103
+
104
+ new(results, layout: layout)
105
+ end
106
+
107
+ def self.assign_ids!(rows)
108
+ used = {}
109
+ rows.each do |row|
110
+ id = row[:explicit_id]
111
+ next if id.nil?
112
+
113
+ if used.key?(id)
114
+ row[:issues] << Issue.new(
115
+ "/records/#{row[:index]}/id",
116
+ "duplicate_id",
117
+ "id #{id.inspect} is already used by /records/#{used[id]}"
118
+ )
119
+ else
120
+ used[id] = row[:index]
121
+ end
122
+ end
123
+
124
+ rows.each do |row|
125
+ next unless row[:explicit_id].nil?
126
+
127
+ row[:assigned_id] = next_id(used, row[:index])
128
+ end
129
+ end
130
+ private_class_method :assign_ids!
131
+
132
+ def self.next_id(used, index)
133
+ number = 1
134
+ loop do
135
+ candidate = format("item-%04d", number)
136
+ unless used.key?(candidate)
137
+ used[candidate] = index
138
+ return candidate
139
+ end
140
+ number += 1
141
+ raise InputError, "could not assign a unique id" if number > 100_000
142
+ end
143
+ end
144
+ private_class_method :next_id
145
+
146
+ def self.failure_result(row, layout)
147
+ Result.new(
148
+ id: row[:explicit_id] || row[:assigned_id],
149
+ ok: false,
150
+ prompt: nil,
151
+ negative_prompt: nil,
152
+ layout: layout,
153
+ parameters: {
154
+ "aspect_ratio" => nil,
155
+ "seed" => nil,
156
+ "steps" => nil,
157
+ "guidance" => nil
158
+ },
159
+ counts: {
160
+ "prompt_chars" => nil,
161
+ "negative_chars" => nil,
162
+ "details" => nil,
163
+ "negatives" => nil
164
+ },
165
+ note: nil,
166
+ errors: row[:issues]
167
+ )
168
+ end
169
+ private_class_method :failure_result
170
+
171
+ def self.prefix_issue(issue, index)
172
+ Issue.new("/records/#{index}#{issue.path}", issue.code, issue.message)
173
+ end
174
+ private_class_method :prefix_issue
175
+
176
+ def self.replace_errors(result, errors)
177
+ Result.new(
178
+ id: result.id,
179
+ ok: false,
180
+ prompt: nil,
181
+ negative_prompt: nil,
182
+ layout: result.layout,
183
+ parameters: result.parameters,
184
+ counts: result.counts,
185
+ note: result.note,
186
+ errors: errors
187
+ )
188
+ end
189
+ private_class_method :replace_errors
190
+
191
+ def self.fingerprint_for(result)
192
+ [
193
+ result.prompt,
194
+ result.negative_prompt,
195
+ result.layout,
196
+ result.parameters["aspect_ratio"],
197
+ result.parameters["seed"],
198
+ result.parameters["steps"],
199
+ result.parameters["guidance"]
200
+ ].map { |value| value.nil? ? "" : value.to_s }.join("\u001f")
201
+ end
202
+ private_class_method :fingerprint_for
203
+ end
204
+ end