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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 458cf2f3b6fa96e5d615158ec3ebd732850b9e0ef1637ea9f94948072167a1ee
4
+ data.tar.gz: 2d5279558f4f02efe4f75d0812d5c7bcda6e39c7724ea6fe63d03d07a0408d85
5
+ SHA512:
6
+ metadata.gz: 86572ec322850a4ebfe673cafaccdbeec4a4c3bb4eaa7bf9fe7f1b4d0689c2101f690f47dbebe3babfb024a0a31eec73169a7880be47515b47b69bc9173a1548
7
+ data.tar.gz: 438c501aea1204264bce8ff7348e0ff5718c9ca9993469cb0b85adfabc25e17d06e9ffc1230ada6d027b12e422bc2e24c3b0800484520ce53a38b2390ddfcc92
data/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release of the offline prompt assembler.
6
+
7
+ - Validate structured text-to-image prompt records.
8
+ - Assemble `natural`, `tagged`, and `labeled` prompt text.
9
+ - Write JSON and JSONL batch manifests.
10
+ - Provide the `flux3-prompt` executable and a Ruby library API.
11
+ - Ship local writing presets, a JSON Schema description, examples, and tests.
12
+
13
+ This version does not generate images and does not make network calls.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andy Wang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # flux3_ai_prompt_tools
2
+
3
+ `flux3_ai_prompt_tools` is an offline Ruby library and command-line tool by Andy Wang. It turns structured prompt specifications into text-to-image prompt strings and batch manifests. The project homepage is [https://flux3-ai.com/](https://flux3-ai.com/).
4
+
5
+ The gem checks the fields you provide, rejects malformed records, and writes a deterministic prompt plus a separate negative prompt. It can read one JSON object, a JSON array, or JSONL, and it can write a JSON document or JSONL. Nothing in this process contacts a model host. The package does not generate images, videos, or audio, and it does not contain an API client.
6
+
7
+ This is an independent utility. It is not a Black Forest Labs product, it is not an official FLUX client, and it is not the `flux_3_org` gem.
8
+
9
+ ## What it does
10
+
11
+ A record has a required `subject` and optional art-direction fields: `scene`, `medium`, `style`, `lighting`, `composition`, `camera`, `palette`, `mood`, `details`, and `negatives`. Optional manifest metadata is `aspect_ratio`, `seed`, `steps`, `guidance`, `id`, and `note`. A `preset` fills a few of the art-direction fields with a local writing template. Your own values replace the template. Set a field to `null` to clear a template value.
12
+
13
+ Three layouts are built in.
14
+
15
+ `natural` writes short sentences such as `Lighting: soft window light.` The assembler does not change your capitalization. If a sentence already ends with `.`, `!`, or `?`, it leaves that ending in place.
16
+
17
+ `tagged` joins the same fields with commas. A weighted detail is rendered as `(phrase:1.2)`. Commas that you typed inside a field are not escaped, so the labeled layout is the safer choice when a field itself contains commas.
18
+
19
+ `labeled` writes one `Label: value` line per filled field. Empty fields are skipped.
20
+
21
+ Weights are accepted only on details, from 0.1 through 2.0, with at most two decimal places. A weight of 1 is unmarked. The negative list is not pasted into the positive prompt. It is returned in `negative_prompt`. The `note` field is copied into the manifest and is never inserted into either prompt. `seed`, `steps`, `guidance`, and `aspect_ratio` are stored under `parameters` for your own bookkeeping. This gem does not send them to a renderer and does not claim that any model will honor them.
22
+
23
+ Batch mode assigns ids such as `item-0001` when you omit `id`. It rejects duplicate ids. It also rejects a later record that repeats the same prompt, negative prompt, layout, and parameters. Use a different seed, or change the wording, when you want more than one job from similar text. `--keep-going` still writes a manifest when some records are bad. Those records have `"ok": false`, a null prompt, and an `errors` array. Without that flag the command writes nothing and exits with an error. Record order is the input order.
24
+
25
+ Validation covers types, blank strings, control characters, Unicode line separators, UTF-8, unknown fields, preset names, and aspect ratios whose sides are integers from 1 through 64. Seeds must be integers in the safe JSON range, 0 through 9007199254740991. Steps must be integers from 1 through 150. Guidance must be a number from 0 through 30 with at most two decimal places. Assembled text must fit the character budget. The defaults are 2000 characters for the prompt and 1000 for the negative prompt. Counts are Unicode characters, not model tokens. Input larger than 5,000,000 bytes, or a batch larger than 5000 records, is refused. Unknown JSON keys are errors, so a typo is not silently dropped and is not inserted into the prompt.
26
+
27
+ The shipped presets are `product_still`, `portrait`, `environment`, and `graphic_poster`. They are short English writing templates stored in the gem. They are not model defaults and they are not account settings. `flux3-prompt schema` prints a JSON Schema description of one record. The validator in this gem is the authority if a document passes a looser third-party schema check. The schema is printed locally. The tool does not download it.
28
+
29
+ ## Install
30
+
31
+ The library needs Ruby 3.1 or newer. Runtime code uses the standard library only. There are no runtime gem dependencies.
32
+
33
+ From a local gem file:
34
+
35
+ ```bash
36
+ gem install --user-install flux3_ai_prompt_tools-0.1.0.gem
37
+ flux3-prompt version
38
+ ```
39
+
40
+ After the owner publishes the package to RubyGems, the same gem can be installed with `gem install flux3_ai_prompt_tools`. Installing this repository does not publish the gem and does not create an account.
41
+
42
+ From a checkout of the source, without installing:
43
+
44
+ ```bash
45
+ ruby -Ilib bin/flux3-prompt version
46
+ rake test
47
+ gem build flux3_ai_prompt_tools.gemspec
48
+ ```
49
+
50
+ ## Library
51
+
52
+ ```ruby
53
+ require "flux3_ai_prompt_tools"
54
+
55
+ result = Flux3AiPromptTools.assemble(
56
+ {
57
+ "subject" => "a matte cream ceramic pour-over kettle",
58
+ "scene" => "on a walnut table",
59
+ "preset" => "product_still",
60
+ "negatives" => ["text", "watermark"],
61
+ "aspect_ratio" => "4:5",
62
+ "seed" => 1204
63
+ },
64
+ layout: "natural"
65
+ )
66
+
67
+ result.prompt
68
+ result.negative_prompt
69
+ result.to_h
70
+
71
+ issues = Flux3AiPromptTools.validate({ "style" => "quiet" })
72
+ manifest = Flux3AiPromptTools.build_manifest(
73
+ [
74
+ { "subject" => "a red bowl" },
75
+ { "subject" => "a blue cup", "layout" => "tagged" }
76
+ ]
77
+ )
78
+ File.write("prompts.jsonl", manifest.to_jsonl)
79
+ ```
80
+
81
+ `assemble` raises `Flux3AiPromptTools::ValidationError` when the record is invalid or the assembled text exceeds its budget. `validate` returns an array of issues and does not raise. An empty array means the record is usable. `build_manifest` raises unless you pass `keep_going: true`. `load_file` reads a JSON or JSONL file into an array of hashes, which you can pass to `build_manifest`. Symbol keys are accepted from Ruby. A JSON null clears a preset field instead of failing the type check.
82
+
83
+ ## Command line
84
+
85
+ Print one prompt:
86
+
87
+ ```bash
88
+ flux3-prompt assemble \
89
+ --preset product_still \
90
+ --subject "a matte cream ceramic pour-over kettle" \
91
+ --scene "on a walnut table" \
92
+ --negative text \
93
+ --negative watermark \
94
+ --aspect-ratio 4:5 \
95
+ --seed 1204
96
+ ```
97
+
98
+ Add `--manifest` to print the JSON record instead of the prompt text. Add `--layout tagged` or `--layout labeled` to change the wording. The plain command prints only the positive prompt, so a shell pipeline can capture it. Use `--manifest` when you also need the negative prompt, the parameters, and the character counts.
99
+
100
+ Check a file, including the examples in this repository:
101
+
102
+ ```bash
103
+ flux3-prompt validate examples/kettle.json
104
+ flux3-prompt batch examples/batch.jsonl --format jsonl -o prompts.jsonl
105
+ flux3-prompt schema
106
+ flux3-prompt presets
107
+ ```
108
+
109
+ `batch` accepts a JSON object, a JSON array, or JSONL. Use `-` to read stdin. `--input-format` may be `auto`, `json`, or `jsonl`. A file extension of `.json` or `.jsonl` selects the matching reader when the format is `auto`. A UTF-8 byte order mark at the start of the file is ignored. Blank lines in JSONL are ignored. A broken JSONL line stops the whole file, because a partial parse would drop the rest of the batch without telling you where the document became unreadable.
110
+
111
+ `--format` chooses the output. A path ending in `.jsonl` or `.json` selects that format when `--format` is omitted. Output written to a file replaces that file through a temporary file in the same directory. Stdout defaults to JSONL, one record per line, and each line includes the generator name and version. The JSON document form adds a top-level `ok` flag and the default layout around the same records.
112
+
113
+ Exit status is `0` for success, `1` when the input or a record is invalid, and `2` when the command line itself is invalid. In `--keep-going` mode a batch with any failed record is still written, and the exit status is `1`.
114
+
115
+ ## Tests
116
+
117
+ The suite is Minitest. It does not need network access:
118
+
119
+ ```bash
120
+ rake test
121
+ ```
122
+
123
+ The packaged gem includes the library, the executable, the tests, the examples, this README, the changelog, and the MIT license.
124
+
125
+ ## Limitations
126
+
127
+ The assembler is a formatter. It does not tokenize a prompt the way any particular model does, and a prompt that passes the character budget can still be too long, too vague, or rejected by a downstream service. Passing `aspect_ratio`, `seed`, `steps`, or `guidance` only stores those values in the manifest. It does not resize a canvas, set a random seed, or choose a sampler.
128
+
129
+ Tagged output does not escape commas or parentheses inside a field. Very large weights and more than two decimal places are rejected rather than rounded silently. The same prompt with the same parameters is treated as a duplicate job so an accidental double paste is visible. That check is exact string equality after assembly, not a similarity score.
130
+
131
+ The tool loads a whole batch into memory and writes the manifest in one pass. It is aimed at authoring batches of thousands of records, not at multi-gigabyte streams. Presets are English templates. The natural and labeled layouts do not translate text. There is no image upload, no preview, and no retry against a generator, because this package never opens a network connection and does not read credentials, tokens, or environment secrets. Publishing the gem, choosing a public source repository, and signing in to RubyGems are left to the owner.
132
+
133
+ ## License
134
+
135
+ MIT. Copyright (c) 2026 Andy Wang.
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake/testtask"
4
+
5
+ Rake::TestTask.new(:test) do |task|
6
+ task.libs << "lib" << "test"
7
+ task.test_files = FileList["test/**/*_test.rb"]
8
+ task.warning = true
9
+ end
10
+
11
+ task default: :test
data/bin/flux3-prompt ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "flux3_ai_prompt_tools"
5
+ require "flux3_ai_prompt_tools/cli"
6
+
7
+ begin
8
+ exit Flux3AiPromptTools::CLI.start(ARGV)
9
+ rescue Flux3AiPromptTools::UsageError => e
10
+ warn "error: #{e.message}"
11
+ exit 2
12
+ rescue Flux3AiPromptTools::ValidationError => e
13
+ e.issues.each do |issue|
14
+ warn "error: #{issue.path} #{issue.code}: #{issue.message}"
15
+ end
16
+ exit 1
17
+ rescue Flux3AiPromptTools::InputError => e
18
+ warn "error: #{e.message}"
19
+ exit 1
20
+ rescue Flux3AiPromptTools::Error => e
21
+ warn "error: #{e.message}"
22
+ exit 1
23
+ end
@@ -0,0 +1,2 @@
1
+ {"id":"kettle-01","preset":"product_still","subject":"a matte cream ceramic pour-over kettle","scene":"on a walnut table in a quiet kitchen","palette":"warm walnut and matte cream","negatives":["text","watermark"],"aspect_ratio":"4:5","seed":1204}
2
+ {"id":"cup-02","preset":"portrait","subject":"a person holding a small blue cup","lighting":"north window light","negatives":["distorted hands"],"aspect_ratio":"3:4","seed":1205,"layout":"tagged"}
@@ -0,0 +1,16 @@
1
+ {
2
+ "id": "kettle-01",
3
+ "preset": "product_still",
4
+ "subject": "a matte cream ceramic pour-over kettle",
5
+ "scene": "on a walnut table in a quiet kitchen",
6
+ "style": "editorial product photography",
7
+ "palette": "warm walnut, matte cream, and muted sage",
8
+ "details": [
9
+ "steam rising from the spout",
10
+ {"text": "visible wood grain", "weight": 1.2}
11
+ ],
12
+ "negatives": ["text", "watermark", "extra handle"],
13
+ "aspect_ratio": "4:5",
14
+ "seed": 1204,
15
+ "note": "Local reminder only. This file is not sent anywhere."
16
+ }
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/flux3_ai_prompt_tools/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "flux3_ai_prompt_tools"
7
+ spec.version = Flux3AiPromptTools::VERSION
8
+ spec.authors = ["Andy Wang"]
9
+ spec.summary = "Validate structured inputs and assemble offline text-to-image prompt manifests."
10
+ spec.description = <<~TEXT
11
+ Offline Ruby library and command-line tool that validates structured prompt
12
+ specifications and assembles text-to-image prompts into JSON and JSONL
13
+ manifests. It does not generate images, call a model API, or send data
14
+ over the network. It is an independent utility by Andy Wang and is not a
15
+ Black Forest Labs product, an official FLUX client, or the flux_3_org gem.
16
+ TEXT
17
+ spec.homepage = "https://flux3-ai.com/"
18
+ spec.license = "MIT"
19
+ spec.required_ruby_version = ">= 3.1.0"
20
+
21
+ spec.files = Dir.chdir(__dir__) do
22
+ Dir[
23
+ "lib/**/*.rb",
24
+ "bin/flux3-prompt",
25
+ "test/**/*.rb",
26
+ "examples/**/*",
27
+ "README.md",
28
+ "LICENSE",
29
+ "CHANGELOG.md",
30
+ "Rakefile",
31
+ "flux3_ai_prompt_tools.gemspec"
32
+ ].select { |path| File.file?(path) }
33
+ end
34
+ spec.bindir = "bin"
35
+ spec.executables = ["flux3-prompt"]
36
+ spec.require_paths = ["lib"]
37
+
38
+ spec.metadata = {
39
+ "homepage_uri" => "https://flux3-ai.com/",
40
+ "allowed_push_host" => "https://rubygems.org"
41
+ }
42
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "errors"
6
+ require_relative "layouts"
7
+ require_relative "numbers"
8
+ require_relative "version"
9
+
10
+ module Flux3AiPromptTools
11
+ # One assembled record, successful or withheld after a budget or batch error.
12
+ class Result
13
+ attr_reader :id, :ok, :prompt, :negative_prompt, :layout, :parameters, :counts, :note, :errors
14
+
15
+ def initialize(id:, ok:, prompt:, negative_prompt:, layout:, parameters:, counts:, note:, errors:)
16
+ @id = id
17
+ @ok = ok
18
+ @prompt = prompt
19
+ @negative_prompt = negative_prompt
20
+ @layout = layout
21
+ @parameters = parameters.dup.freeze
22
+ @counts = counts.dup.freeze
23
+ @note = note
24
+ @errors = errors.dup.freeze
25
+ freeze
26
+ end
27
+
28
+ def to_h
29
+ {
30
+ "generator" => "flux3_ai_prompt_tools/#{VERSION}",
31
+ "id" => id,
32
+ "ok" => ok,
33
+ "prompt" => prompt,
34
+ "negative_prompt" => negative_prompt,
35
+ "layout" => layout,
36
+ "parameters" => parameters,
37
+ "counts" => counts,
38
+ "note" => note,
39
+ "errors" => errors.map(&:to_h)
40
+ }
41
+ end
42
+ end
43
+
44
+ # Builds deterministic prompt text. It does not call a model.
45
+ class Assembler
46
+ def self.assemble(spec, layout:, limits:, raise_on_error: true)
47
+ unless spec.is_a?(Specification)
48
+ raise ArgumentError, "spec must be a Flux3AiPromptTools::Specification"
49
+ end
50
+ unless limits.is_a?(Limits)
51
+ raise ArgumentError, "limits must be a Flux3AiPromptTools::Limits"
52
+ end
53
+
54
+ chosen = spec.layout || layout
55
+ unless chosen.is_a?(String) && Layouts::NAMES.include?(chosen)
56
+ raise ArgumentError, "layout must be one of: #{Layouts::NAMES.join(', ')}"
57
+ end
58
+
59
+ prompt = render(spec, chosen)
60
+ negative = spec.negatives.join(", ")
61
+ issues = []
62
+ if prompt.length > limits.max_prompt_chars
63
+ issues << Issue.new(
64
+ "/prompt",
65
+ "too_long",
66
+ "assembled prompt is #{prompt.length} characters; limit is #{limits.max_prompt_chars}"
67
+ )
68
+ end
69
+ if negative.length > limits.max_negative_chars
70
+ issues << Issue.new(
71
+ "/negative_prompt",
72
+ "too_long",
73
+ "assembled negative prompt is #{negative.length} characters; limit is #{limits.max_negative_chars}"
74
+ )
75
+ end
76
+
77
+ result = Result.new(
78
+ id: spec.id,
79
+ ok: issues.empty?,
80
+ prompt: issues.empty? ? prompt : nil,
81
+ negative_prompt: issues.empty? ? negative : nil,
82
+ layout: chosen,
83
+ parameters: {
84
+ "aspect_ratio" => spec.aspect_ratio,
85
+ "seed" => spec.seed,
86
+ "steps" => spec.steps,
87
+ "guidance" => spec.guidance
88
+ },
89
+ counts: {
90
+ "prompt_chars" => prompt.length,
91
+ "negative_chars" => negative.length,
92
+ "details" => spec.details.length,
93
+ "negatives" => spec.negatives.length
94
+ },
95
+ note: spec.note,
96
+ errors: issues
97
+ )
98
+ raise ValidationError, result.errors if raise_on_error && !result.ok
99
+
100
+ result
101
+ end
102
+
103
+ def self.render(spec, layout)
104
+ case layout
105
+ when "natural" then render_natural(spec)
106
+ when "tagged" then render_tagged(spec)
107
+ when "labeled" then render_labeled(spec)
108
+ else
109
+ raise ArgumentError, "unknown layout #{layout.inspect}"
110
+ end
111
+ end
112
+ private_class_method :render
113
+
114
+ def self.render_natural(spec)
115
+ parts = []
116
+ opening = spec.scene ? "#{spec.subject}, #{spec.scene}" : spec.subject
117
+ parts << sentence(opening)
118
+
119
+ look = []
120
+ look << spec.medium if spec.medium
121
+ look << "#{spec.style} style" if spec.style
122
+ parts << sentence(look.join(", ")) if look.any?
123
+
124
+ [
125
+ ["Lighting", spec.lighting],
126
+ ["Composition", spec.composition],
127
+ ["Camera", spec.camera],
128
+ ["Palette", spec.palette],
129
+ ["Mood", spec.mood]
130
+ ].each do |label, value|
131
+ parts << sentence("#{label}: #{value}") if value
132
+ end
133
+
134
+ if spec.details.any?
135
+ rendered = spec.details.map { |detail| render_detail_words(detail) }
136
+ parts << sentence("Details: #{rendered.join('; ')}")
137
+ end
138
+ parts.join(" ")
139
+ end
140
+ private_class_method :render_natural
141
+
142
+ def self.render_tagged(spec)
143
+ tokens = []
144
+ tokens << spec.subject
145
+ tokens << spec.scene if spec.scene
146
+ tokens << spec.medium if spec.medium
147
+ tokens << spec.style if spec.style
148
+ tokens << spec.lighting if spec.lighting
149
+ tokens << spec.composition if spec.composition
150
+ tokens << spec.camera if spec.camera
151
+ tokens << spec.palette if spec.palette
152
+ tokens << spec.mood if spec.mood
153
+ spec.details.each do |detail|
154
+ tokens << (detail.neutral? ? detail.text : "(#{detail.text}:#{Numbers.format_hundredths(detail.hundredths)})")
155
+ end
156
+ tokens.join(", ")
157
+ end
158
+ private_class_method :render_tagged
159
+
160
+ def self.render_labeled(spec)
161
+ rows = [["Subject", spec.subject]]
162
+ rows << ["Scene", spec.scene]
163
+ rows << ["Medium", spec.medium]
164
+ rows << ["Style", spec.style]
165
+ rows << ["Lighting", spec.lighting]
166
+ rows << ["Composition", spec.composition]
167
+ rows << ["Camera", spec.camera]
168
+ rows << ["Palette", spec.palette]
169
+ rows << ["Mood", spec.mood]
170
+ if spec.details.any?
171
+ rows << ["Details", spec.details.map { |detail| render_detail_words(detail) }.join("; ")]
172
+ end
173
+ rows.select { |_label, value| value }.map { |label, value| "#{label}: #{value}" }.join("\n")
174
+ end
175
+ private_class_method :render_labeled
176
+
177
+ def self.render_detail_words(detail)
178
+ return detail.text if detail.neutral?
179
+
180
+ "#{detail.text} (emphasis #{Numbers.format_hundredths(detail.hundredths)})"
181
+ end
182
+ private_class_method :render_detail_words
183
+
184
+ def self.sentence(text)
185
+ stripped = text.strip
186
+ return stripped if stripped.match?(/[.!?]["']?\z/)
187
+
188
+ "#{stripped}."
189
+ end
190
+ private_class_method :sentence
191
+ end
192
+ end