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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE +21 -0
- data/README.md +135 -0
- data/Rakefile +11 -0
- data/bin/flux3-prompt +23 -0
- data/examples/batch.jsonl +2 -0
- data/examples/kettle.json +16 -0
- data/flux3_ai_prompt_tools.gemspec +42 -0
- data/lib/flux3_ai_prompt_tools/assembler.rb +192 -0
- data/lib/flux3_ai_prompt_tools/cli.rb +349 -0
- data/lib/flux3_ai_prompt_tools/errors.rb +49 -0
- data/lib/flux3_ai_prompt_tools/fields.rb +37 -0
- data/lib/flux3_ai_prompt_tools/layouts.rb +8 -0
- data/lib/flux3_ai_prompt_tools/limits.rb +37 -0
- data/lib/flux3_ai_prompt_tools/manifest.rb +204 -0
- data/lib/flux3_ai_prompt_tools/numbers.rb +37 -0
- data/lib/flux3_ai_prompt_tools/presets.rb +53 -0
- data/lib/flux3_ai_prompt_tools/reader.rb +111 -0
- data/lib/flux3_ai_prompt_tools/schema.rb +108 -0
- data/lib/flux3_ai_prompt_tools/specification.rb +89 -0
- data/lib/flux3_ai_prompt_tools/validator.rb +368 -0
- data/lib/flux3_ai_prompt_tools/version.rb +5 -0
- data/lib/flux3_ai_prompt_tools.rb +51 -0
- data/test/assembler_test.rb +115 -0
- data/test/cli_test.rb +168 -0
- data/test/helper.rb +30 -0
- data/test/manifest_test.rb +112 -0
- data/test/packaging_test.rb +84 -0
- data/test/reader_test.rb +71 -0
- data/test/validator_test.rb +135 -0
- metadata +80 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Flux3AiPromptTools
|
|
6
|
+
# Exact hundredth parsing so weights and guidance do not drift through binary floats.
|
|
7
|
+
module Numbers
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def hundredths(value)
|
|
11
|
+
return nil unless value.is_a?(Integer) || value.is_a?(Float)
|
|
12
|
+
return nil unless value.finite?
|
|
13
|
+
|
|
14
|
+
text = value.to_s
|
|
15
|
+
return nil unless text.match?(/\A-?\d+(?:\.\d+)?\z/)
|
|
16
|
+
|
|
17
|
+
scaled = BigDecimal(text) * 100
|
|
18
|
+
return nil unless scaled.frac.zero?
|
|
19
|
+
|
|
20
|
+
scaled.to_i
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def from_hundredths(hundredths)
|
|
24
|
+
whole, frac = hundredths.divmod(100)
|
|
25
|
+
return whole if frac.zero?
|
|
26
|
+
|
|
27
|
+
format("%d.%02d", whole, frac).sub(/0\z/, "").to_f
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def format_hundredths(hundredths)
|
|
31
|
+
whole, frac = hundredths.divmod(100)
|
|
32
|
+
return whole.to_s if frac.zero?
|
|
33
|
+
|
|
34
|
+
format("%d.%02d", whole, frac).sub(/0\z/, "")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flux3AiPromptTools
|
|
4
|
+
# Local writing templates. They fill empty art-direction fields and are not model settings.
|
|
5
|
+
module Presets
|
|
6
|
+
DATA = {
|
|
7
|
+
"product_still" => {
|
|
8
|
+
"medium" => "photograph",
|
|
9
|
+
"lighting" => "soft diffused light with a gentle shadow under the subject",
|
|
10
|
+
"composition" => "centered subject with clean negative space",
|
|
11
|
+
"camera" => "standard product lens, sharp focus on the subject",
|
|
12
|
+
"mood" => "clear and uncluttered"
|
|
13
|
+
}.freeze,
|
|
14
|
+
"portrait" => {
|
|
15
|
+
"medium" => "photograph",
|
|
16
|
+
"lighting" => "soft directional light on the face",
|
|
17
|
+
"composition" => "head and shoulders, eyes in the upper third",
|
|
18
|
+
"camera" => "short telephoto lens, shallow depth of field",
|
|
19
|
+
"mood" => "calm and direct"
|
|
20
|
+
}.freeze,
|
|
21
|
+
"environment" => {
|
|
22
|
+
"medium" => "photograph",
|
|
23
|
+
"lighting" => "natural light consistent with the time of day",
|
|
24
|
+
"composition" => "wide view that shows foreground, subject, and background",
|
|
25
|
+
"camera" => "wide-angle lens, deep focus",
|
|
26
|
+
"mood" => "specific and grounded"
|
|
27
|
+
}.freeze,
|
|
28
|
+
"graphic_poster" => {
|
|
29
|
+
"medium" => "graphic poster",
|
|
30
|
+
"composition" => "bold hierarchy with room for a short headline",
|
|
31
|
+
"palette" => "limited palette with one accent color",
|
|
32
|
+
"mood" => "immediate and legible"
|
|
33
|
+
}.freeze
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
NAMES = DATA.keys.freeze
|
|
37
|
+
|
|
38
|
+
def self.list
|
|
39
|
+
DATA.map do |name, fields|
|
|
40
|
+
{ "name" => name, "fields" => fields.dup }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.copy(name)
|
|
45
|
+
source = DATA[name]
|
|
46
|
+
return nil if source.nil?
|
|
47
|
+
|
|
48
|
+
source.each_with_object({}) do |(key, value), copy|
|
|
49
|
+
copy[key] = value.dup
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "limits"
|
|
7
|
+
|
|
8
|
+
module Flux3AiPromptTools
|
|
9
|
+
# Loads a JSON object, a JSON array, or JSONL into an array of hashes.
|
|
10
|
+
class Reader
|
|
11
|
+
def self.load_file(path, format: "auto", limits: Limits::DEFAULT)
|
|
12
|
+
raise InputError, "file does not exist: #{path}" unless File.file?(path)
|
|
13
|
+
|
|
14
|
+
size = File.size(path)
|
|
15
|
+
if size > limits.max_input_bytes
|
|
16
|
+
raise InputError, "file is #{size} bytes; limit is #{limits.max_input_bytes}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
text = File.binread(path)
|
|
20
|
+
text.force_encoding(Encoding::UTF_8)
|
|
21
|
+
unless text.valid_encoding?
|
|
22
|
+
raise InputError, "file is not valid UTF-8: #{path}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
load(text, format: format, filename: path, limits: limits)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.load(text, format:, filename: nil, limits: Limits::DEFAULT)
|
|
29
|
+
raise InputError, "input is empty" if text.nil? || text.strip.empty?
|
|
30
|
+
if text.bytesize > limits.max_input_bytes
|
|
31
|
+
raise InputError, "input is #{text.bytesize} bytes; limit is #{limits.max_input_bytes}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
document = text.dup
|
|
35
|
+
document.force_encoding(Encoding::UTF_8)
|
|
36
|
+
unless document.valid_encoding?
|
|
37
|
+
raise InputError, "input is not valid UTF-8"
|
|
38
|
+
end
|
|
39
|
+
document = document.sub(/\A\uFEFF/, "")
|
|
40
|
+
|
|
41
|
+
chosen = format.to_s
|
|
42
|
+
chosen = detect(document, filename) if chosen == "auto"
|
|
43
|
+
case chosen
|
|
44
|
+
when "json" then parse_json(document, filename)
|
|
45
|
+
when "jsonl" then parse_jsonl(document, filename)
|
|
46
|
+
else
|
|
47
|
+
raise UsageError, "input format must be auto, json, or jsonl"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.detect(text, filename)
|
|
52
|
+
if filename&.match?(/\.jsonl\z/i)
|
|
53
|
+
"jsonl"
|
|
54
|
+
elsif filename&.match?(/\.json\z/i)
|
|
55
|
+
"json"
|
|
56
|
+
else
|
|
57
|
+
begin
|
|
58
|
+
JSON.parse(text)
|
|
59
|
+
"json"
|
|
60
|
+
rescue JSON::ParserError
|
|
61
|
+
"jsonl"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
private_class_method :detect
|
|
66
|
+
|
|
67
|
+
def self.parse_json(text, filename)
|
|
68
|
+
value = JSON.parse(text)
|
|
69
|
+
rescue JSON::ParserError => e
|
|
70
|
+
where = filename ? " in #{filename}" : ""
|
|
71
|
+
raise InputError, "invalid JSON#{where}: #{e.message}"
|
|
72
|
+
else
|
|
73
|
+
records = case value
|
|
74
|
+
when Hash then [value]
|
|
75
|
+
when Array then value
|
|
76
|
+
else
|
|
77
|
+
raise InputError, "JSON input must be an object or an array of objects"
|
|
78
|
+
end
|
|
79
|
+
raise InputError, "batch has no records" if records.empty?
|
|
80
|
+
|
|
81
|
+
records.each_with_index do |item, index|
|
|
82
|
+
raise InputError, "JSON item #{index} must be an object" unless item.is_a?(Hash)
|
|
83
|
+
end
|
|
84
|
+
records
|
|
85
|
+
end
|
|
86
|
+
private_class_method :parse_json
|
|
87
|
+
|
|
88
|
+
def self.parse_jsonl(text, filename)
|
|
89
|
+
records = []
|
|
90
|
+
text.each_line.with_index(1) do |line, number|
|
|
91
|
+
next if line.strip.empty?
|
|
92
|
+
|
|
93
|
+
begin
|
|
94
|
+
value = JSON.parse(line)
|
|
95
|
+
rescue JSON::ParserError => e
|
|
96
|
+
location = filename ? "#{filename}:#{number}" : "line #{number}"
|
|
97
|
+
raise InputError.new("invalid JSONL at #{location}: #{e.message}", line: number)
|
|
98
|
+
end
|
|
99
|
+
unless value.is_a?(Hash)
|
|
100
|
+
location = filename ? "#{filename}:#{number}" : "line #{number}"
|
|
101
|
+
raise InputError.new("JSONL #{location} must be an object", line: number)
|
|
102
|
+
end
|
|
103
|
+
records << value
|
|
104
|
+
end
|
|
105
|
+
raise InputError, "input is empty" if records.empty?
|
|
106
|
+
|
|
107
|
+
records
|
|
108
|
+
end
|
|
109
|
+
private_class_method :parse_jsonl
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "fields"
|
|
4
|
+
require_relative "layouts"
|
|
5
|
+
require_relative "limits"
|
|
6
|
+
require_relative "presets"
|
|
7
|
+
|
|
8
|
+
module Flux3AiPromptTools
|
|
9
|
+
# A JSON Schema document for one prompt record. It is data, not a hosted endpoint.
|
|
10
|
+
module Schema
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def document
|
|
14
|
+
{
|
|
15
|
+
"$schema" => "https://json-schema.org/draft/2020-12/schema",
|
|
16
|
+
"title" => "Text-to-image prompt record",
|
|
17
|
+
"description" => "Input record for flux3_ai_prompt_tools. The assembler reads this document locally and does not submit it to a generator.",
|
|
18
|
+
"type" => "object",
|
|
19
|
+
"additionalProperties" => false,
|
|
20
|
+
"required" => ["subject"],
|
|
21
|
+
"properties" => properties
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def properties
|
|
26
|
+
strings = Fields::STRING_LIMITS.each_with_object({}) do |(name, limit), props|
|
|
27
|
+
props[name] = {
|
|
28
|
+
"type" => "string",
|
|
29
|
+
"minLength" => 1,
|
|
30
|
+
"maxLength" => limit,
|
|
31
|
+
"description" => string_description(name)
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
strings.merge(
|
|
35
|
+
"id" => {
|
|
36
|
+
"type" => "string",
|
|
37
|
+
"pattern" => "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
|
|
38
|
+
"description" => "Optional stable id. Batch mode assigns item-0001 style ids when this is omitted."
|
|
39
|
+
},
|
|
40
|
+
"details" => {
|
|
41
|
+
"type" => "array",
|
|
42
|
+
"maxItems" => Fields::MAX_DETAILS,
|
|
43
|
+
"items" => {
|
|
44
|
+
"anyOf" => [
|
|
45
|
+
{ "type" => "string", "minLength" => 1, "maxLength" => Fields::DETAIL_TEXT_LIMIT },
|
|
46
|
+
{
|
|
47
|
+
"type" => "object",
|
|
48
|
+
"additionalProperties" => false,
|
|
49
|
+
"required" => ["text"],
|
|
50
|
+
"properties" => {
|
|
51
|
+
"text" => { "type" => "string", "minLength" => 1, "maxLength" => Fields::DETAIL_TEXT_LIMIT },
|
|
52
|
+
"weight" => { "type" => "number", "minimum" => 0.1, "maximum" => 2.0 }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"negatives" => {
|
|
59
|
+
"type" => "array",
|
|
60
|
+
"maxItems" => Fields::MAX_NEGATIVES,
|
|
61
|
+
"items" => { "type" => "string", "minLength" => 1, "maxLength" => Fields::DETAIL_TEXT_LIMIT },
|
|
62
|
+
"description" => "Phrases to keep in negative_prompt. They are not copied into the positive prompt."
|
|
63
|
+
},
|
|
64
|
+
"aspect_ratio" => {
|
|
65
|
+
"type" => "string",
|
|
66
|
+
"pattern" => "^[1-9][0-9]?:[1-9][0-9]?$",
|
|
67
|
+
"description" => "Stored manifest metadata such as 4:5. Each side must be from 1 through #{Fields::MAX_ASPECT_SIDE}."
|
|
68
|
+
},
|
|
69
|
+
"seed" => {
|
|
70
|
+
"type" => "integer",
|
|
71
|
+
"minimum" => 0,
|
|
72
|
+
"maximum" => Limits::MAX_SEED,
|
|
73
|
+
"description" => "Stored manifest metadata. It is not sent to a renderer by this gem."
|
|
74
|
+
},
|
|
75
|
+
"steps" => {
|
|
76
|
+
"type" => "integer",
|
|
77
|
+
"minimum" => Fields::MIN_STEPS,
|
|
78
|
+
"maximum" => Fields::MAX_STEPS,
|
|
79
|
+
"description" => "Stored manifest metadata. It is not sent to a renderer by this gem."
|
|
80
|
+
},
|
|
81
|
+
"guidance" => {
|
|
82
|
+
"type" => "number",
|
|
83
|
+
"minimum" => 0,
|
|
84
|
+
"maximum" => 30,
|
|
85
|
+
"description" => "Stored manifest metadata. It is not sent to a renderer by this gem."
|
|
86
|
+
},
|
|
87
|
+
"layout" => {
|
|
88
|
+
"type" => "string",
|
|
89
|
+
"enum" => Layouts::NAMES,
|
|
90
|
+
"description" => "Optional per-record layout override."
|
|
91
|
+
},
|
|
92
|
+
"preset" => {
|
|
93
|
+
"type" => "string",
|
|
94
|
+
"enum" => Presets::NAMES,
|
|
95
|
+
"description" => "Local writing template. Explicit fields replace template fields."
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def string_description(name)
|
|
101
|
+
case name
|
|
102
|
+
when "subject" then "Required subject of the picture."
|
|
103
|
+
when "note" then "Reminder copied into the manifest and omitted from both prompts."
|
|
104
|
+
else "Optional art-direction text. Leading and trailing whitespace is removed."
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "validator"
|
|
5
|
+
|
|
6
|
+
module Flux3AiPromptTools
|
|
7
|
+
# One weighted detail phrase.
|
|
8
|
+
class Detail
|
|
9
|
+
attr_reader :text, :hundredths
|
|
10
|
+
|
|
11
|
+
def initialize(text:, hundredths:)
|
|
12
|
+
@text = text
|
|
13
|
+
@hundredths = hundredths
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def weight
|
|
18
|
+
Numbers.from_hundredths(hundredths)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def neutral?
|
|
22
|
+
hundredths == 100
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A record that has already passed validation.
|
|
27
|
+
class Specification
|
|
28
|
+
attr_reader :id, :subject, :scene, :medium, :style, :lighting, :composition,
|
|
29
|
+
:camera, :palette, :mood, :details, :negatives, :aspect_ratio,
|
|
30
|
+
:seed, :steps, :guidance, :note, :layout
|
|
31
|
+
|
|
32
|
+
def initialize(id:, subject:, scene:, medium:, style:, lighting:, composition:,
|
|
33
|
+
camera:, palette:, mood:, details:, negatives:, aspect_ratio:,
|
|
34
|
+
seed:, steps:, guidance:, note:, layout:)
|
|
35
|
+
@id = id
|
|
36
|
+
@subject = subject
|
|
37
|
+
@scene = scene
|
|
38
|
+
@medium = medium
|
|
39
|
+
@style = style
|
|
40
|
+
@lighting = lighting
|
|
41
|
+
@composition = composition
|
|
42
|
+
@camera = camera
|
|
43
|
+
@palette = palette
|
|
44
|
+
@mood = mood
|
|
45
|
+
@details = details.dup.freeze
|
|
46
|
+
@negatives = negatives.dup.freeze
|
|
47
|
+
@aspect_ratio = aspect_ratio
|
|
48
|
+
@seed = seed
|
|
49
|
+
@steps = steps
|
|
50
|
+
@guidance = guidance
|
|
51
|
+
@note = note
|
|
52
|
+
@layout = layout
|
|
53
|
+
freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.parse(input, limits: Limits::DEFAULT)
|
|
57
|
+
clean, issues, _explicit_id = Validator.new(limits).prepare(input, pointer: "")
|
|
58
|
+
raise ValidationError, issues unless issues.empty?
|
|
59
|
+
|
|
60
|
+
from_clean(clean, id: clean["id"])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.from_clean(clean, id:)
|
|
64
|
+
details = clean.fetch("details", []).map do |item|
|
|
65
|
+
Detail.new(text: item["text"], hundredths: item["hundredths"])
|
|
66
|
+
end
|
|
67
|
+
new(
|
|
68
|
+
id: id,
|
|
69
|
+
subject: clean["subject"],
|
|
70
|
+
scene: clean["scene"],
|
|
71
|
+
medium: clean["medium"],
|
|
72
|
+
style: clean["style"],
|
|
73
|
+
lighting: clean["lighting"],
|
|
74
|
+
composition: clean["composition"],
|
|
75
|
+
camera: clean["camera"],
|
|
76
|
+
palette: clean["palette"],
|
|
77
|
+
mood: clean["mood"],
|
|
78
|
+
details: details,
|
|
79
|
+
negatives: Array(clean["negatives"]),
|
|
80
|
+
aspect_ratio: clean["aspect_ratio"],
|
|
81
|
+
seed: clean["seed"],
|
|
82
|
+
steps: clean["steps"],
|
|
83
|
+
guidance: clean["guidance"],
|
|
84
|
+
note: clean["note"],
|
|
85
|
+
layout: clean["layout"]
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|