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,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