nanoodle 0.1.0__tar.gz

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.
nanoodle-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nanoodle contributors
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.
@@ -0,0 +1,5 @@
1
+ # sdist contents: package sources + docs; tests stay in the repo
2
+ # (the default template would ship tests/test_*.py without their
3
+ # harness/fixtures β€” a broken subset, so exclude them entirely)
4
+ prune tests
5
+ recursive-include docs *.md
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: nanoodle
3
+ Version: 0.1.0
4
+ Summary: Run nanoodle visual AI workflows from Python β€” zero-dependency executor for saved noodle-graph.json files
5
+ Author: 255BITS
6
+ License: MIT
7
+ Project-URL: Homepage, https://nanoodle.io
8
+ Project-URL: Repository, https://github.com/255BITS/nanoodle-py
9
+ Keywords: nanoodle,nanogpt,workflow,ai,generative,image,video,audio,llm
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Classifier: Topic :: Multimedia :: Graphics
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Dynamic: license-file
26
+
27
+ # nanoodle
28
+
29
+ Run [nanoodle](https://nanoodle.io) AI workflows server-side. Build a workflow visually in the
30
+ nanoodle editor, hit πŸ’Ύ to download `noodle-graph.json`, and re-execute it anywhere Python runs β€”
31
+ against the same [NanoGPT](https://nano-gpt.com) API the app uses.
32
+
33
+ - **Zero runtime dependencies** (Python >= 3.9, stdlib only)
34
+ - Same execution semantics as the app: topological order, concurrent lanes, wired-field overrides
35
+ - Text, image, video (submit + poll), audio (sync + async poll), vision, transcription
36
+ - Cost tracking per node and per run
37
+
38
+ ## Quickstart
39
+
40
+ ```bash
41
+ pip install nanoodle
42
+ export NANOGPT_API_KEY=... # nano-gpt.com API key (or OAuth access token)
43
+ ```
44
+
45
+ ```python
46
+ from nanoodle import Workflow
47
+
48
+ wf = Workflow.load("noodle-graph.json")
49
+ result = wf.run({"Text": "a cozy ramen shop on a rainy night"})
50
+ result["Image"].save("ramen.png") # media outputs are MediaRef (url + bytes()/save())
51
+ print(result.cost_usd, result.remaining_balance)
52
+ ```
53
+
54
+ With the starter graph from the app (text β†’ LLM prompt-writer β†’ image), that's the whole program.
55
+
56
+ ### Discover a workflow's interface
57
+
58
+ ```python
59
+ wf.inputs # [InputSpec(key="Text", node_id="n1", field="text", kind="textarea", ...)]
60
+ wf.outputs # [OutputSpec(key="Image", node_id="n3", type="image", ports=["image"])]
61
+ wf.settings # [SettingSpec(key="n3.size", kind="select", default="1k", ...)]
62
+ ```
63
+
64
+ Input keys resolve flexibly (case-insensitive): the node's custom name, `nodeId.field`
65
+ (`"n2.system"`), or the input's label when unique. A workflow with exactly one required
66
+ input also accepts a bare value: `wf.run("hello")`.
67
+
68
+ ### Media inputs
69
+
70
+ ```python
71
+ from nanoodle import media_from_file
72
+
73
+ wf.run({"Image": media_from_file("photo.jpg")}) # local file
74
+ wf.run({"Image": "https://example.com/photo.jpg"}) # hosted or data: URL
75
+ wf.run({"Image": raw_bytes}) # raw bytes (MIME sniffed)
76
+ ```
77
+
78
+ Media is sent inline as base64 (NanoGPT has no upload endpoint); files over ~4.4 MB
79
+ (~3.5 MB for transcription) are refused locally with a clear error before any paid call.
80
+
81
+ ### Settings, progress, errors
82
+
83
+ ```python
84
+ result = wf.run(
85
+ {"Text": "sunset harbor"},
86
+ settings={"n3.model": "flux-dev", "n3.size": "1k"},
87
+ timeout=600,
88
+ on_progress=lambda evt: print(evt["type"], evt.get("name", "")),
89
+ )
90
+ ```
91
+
92
+ `run()` raises `RunError` when an output (sink) node failed β€” `error.result` still carries
93
+ the partial results, per-node statuses, and cost so far. Failures in lanes no output depends
94
+ on only surface in `result.errors`. Unknown/unsupported node types, missing required inputs,
95
+ bad keys, and a missing API key all fail **before** anything is spent.
96
+
97
+ ### CLI
98
+
99
+ Installed as `nanoodle-py` (and `python -m nanoodle` always works):
100
+
101
+ ```bash
102
+ nanoodle-py inspect graph.json
103
+ nanoodle-py run graph.json --input Text="a cozy ramen shop" --set n3.size=1k --out ./out
104
+ nanoodle-py run graph.json --input n2.system=@style.txt --json
105
+ nanoodle-py run graph.json --env-file .env --input Text="hello" # NANOGPT_API_KEY from a .env file
106
+ ```
107
+
108
+ `--out DIR` saves media outputs to files; `--json` prints a machine-readable result;
109
+ `--env-file PATH` loads `.env`-style `KEY=VALUE` lines (existing environment variables win).
110
+
111
+ ## Supported nodes
112
+
113
+ | runs | node types |
114
+ |---|---|
115
+ | local | text, upload (image/audio/video), choice, join, comment |
116
+ | NanoGPT | llm (incl. vision + audio input), image, draw, edit, inpaint*, vision, tvideo, ivideo, vedit, lipsync, music, remix, tts, transcribe |
117
+ | **not supported** (browser-only media processing) | resize, vframes, combine, soundtrack, trim, extractaudio |
118
+
119
+ Workflows containing unsupported node types load with a warning and fail fast at `run()` with
120
+ `UnsupportedNodeError` β€” before any network call.
121
+
122
+ \* inpaint caveat: the browser app composites the mask onto black at the source's pixel size;
123
+ this library passes your mask through verbatim, so supply a black/white mask matching the
124
+ source dimensions.
125
+
126
+ ## Use it as an agent skill
127
+
128
+ A saved workflow plus a short `SKILL.md` playbook makes a skill any coding agent can run β€”
129
+ Claude Code (`.claude/skills/<name>/SKILL.md`) or anything that reads markdown and runs shell.
130
+ Recipe + copy-pasteable template: [docs/agent-skills.md](docs/agent-skills.md); complete
131
+ example: [examples/agent-skill/poster-generator/](examples/agent-skill/poster-generator/).
132
+
133
+ ## Cost
134
+
135
+ You bring your own NanoGPT API key; NanoGPT bills your balance per generation and reports the
136
+ price on each response. `result.cost_usd` totals it and `result.cost_exact` turns `False` when
137
+ any call omitted a price (the total is then a floor). `result.remaining_balance` is the freshest
138
+ balance the API reported. A price of 0 means known-included (subscription), not unknown.
139
+ No telemetry, no analytics, and your API key is never logged.
140
+
141
+ ## Testing
142
+
143
+ Tests run fully offline against a mock NanoGPT server (`tests/harness/`):
144
+
145
+ ```bash
146
+ python -m unittest discover -s tests -t .
147
+ ```
148
+
149
+ An opt-in live probe (spends a fraction of a cent) exists for hand-verification:
150
+ `python3 scripts/live-spot-check.py` (add `--image` to also run the starter graph's image step).
151
+
152
+ ## Docs
153
+
154
+ Design contract and format/engine/io specs live in [`docs/`](docs/): `DESIGN.md`,
155
+ `SPEC-format.md`, `SPEC-engine.md`, `SPEC-io.md`.
156
+
157
+ ## License
158
+
159
+ MIT β€” see [LICENSE](LICENSE). Not affiliated with NanoGPT. Build workflows at
160
+ [nanoodle.io](https://nanoodle.io).
@@ -0,0 +1,134 @@
1
+ # nanoodle
2
+
3
+ Run [nanoodle](https://nanoodle.io) AI workflows server-side. Build a workflow visually in the
4
+ nanoodle editor, hit πŸ’Ύ to download `noodle-graph.json`, and re-execute it anywhere Python runs β€”
5
+ against the same [NanoGPT](https://nano-gpt.com) API the app uses.
6
+
7
+ - **Zero runtime dependencies** (Python >= 3.9, stdlib only)
8
+ - Same execution semantics as the app: topological order, concurrent lanes, wired-field overrides
9
+ - Text, image, video (submit + poll), audio (sync + async poll), vision, transcription
10
+ - Cost tracking per node and per run
11
+
12
+ ## Quickstart
13
+
14
+ ```bash
15
+ pip install nanoodle
16
+ export NANOGPT_API_KEY=... # nano-gpt.com API key (or OAuth access token)
17
+ ```
18
+
19
+ ```python
20
+ from nanoodle import Workflow
21
+
22
+ wf = Workflow.load("noodle-graph.json")
23
+ result = wf.run({"Text": "a cozy ramen shop on a rainy night"})
24
+ result["Image"].save("ramen.png") # media outputs are MediaRef (url + bytes()/save())
25
+ print(result.cost_usd, result.remaining_balance)
26
+ ```
27
+
28
+ With the starter graph from the app (text β†’ LLM prompt-writer β†’ image), that's the whole program.
29
+
30
+ ### Discover a workflow's interface
31
+
32
+ ```python
33
+ wf.inputs # [InputSpec(key="Text", node_id="n1", field="text", kind="textarea", ...)]
34
+ wf.outputs # [OutputSpec(key="Image", node_id="n3", type="image", ports=["image"])]
35
+ wf.settings # [SettingSpec(key="n3.size", kind="select", default="1k", ...)]
36
+ ```
37
+
38
+ Input keys resolve flexibly (case-insensitive): the node's custom name, `nodeId.field`
39
+ (`"n2.system"`), or the input's label when unique. A workflow with exactly one required
40
+ input also accepts a bare value: `wf.run("hello")`.
41
+
42
+ ### Media inputs
43
+
44
+ ```python
45
+ from nanoodle import media_from_file
46
+
47
+ wf.run({"Image": media_from_file("photo.jpg")}) # local file
48
+ wf.run({"Image": "https://example.com/photo.jpg"}) # hosted or data: URL
49
+ wf.run({"Image": raw_bytes}) # raw bytes (MIME sniffed)
50
+ ```
51
+
52
+ Media is sent inline as base64 (NanoGPT has no upload endpoint); files over ~4.4 MB
53
+ (~3.5 MB for transcription) are refused locally with a clear error before any paid call.
54
+
55
+ ### Settings, progress, errors
56
+
57
+ ```python
58
+ result = wf.run(
59
+ {"Text": "sunset harbor"},
60
+ settings={"n3.model": "flux-dev", "n3.size": "1k"},
61
+ timeout=600,
62
+ on_progress=lambda evt: print(evt["type"], evt.get("name", "")),
63
+ )
64
+ ```
65
+
66
+ `run()` raises `RunError` when an output (sink) node failed β€” `error.result` still carries
67
+ the partial results, per-node statuses, and cost so far. Failures in lanes no output depends
68
+ on only surface in `result.errors`. Unknown/unsupported node types, missing required inputs,
69
+ bad keys, and a missing API key all fail **before** anything is spent.
70
+
71
+ ### CLI
72
+
73
+ Installed as `nanoodle-py` (and `python -m nanoodle` always works):
74
+
75
+ ```bash
76
+ nanoodle-py inspect graph.json
77
+ nanoodle-py run graph.json --input Text="a cozy ramen shop" --set n3.size=1k --out ./out
78
+ nanoodle-py run graph.json --input n2.system=@style.txt --json
79
+ nanoodle-py run graph.json --env-file .env --input Text="hello" # NANOGPT_API_KEY from a .env file
80
+ ```
81
+
82
+ `--out DIR` saves media outputs to files; `--json` prints a machine-readable result;
83
+ `--env-file PATH` loads `.env`-style `KEY=VALUE` lines (existing environment variables win).
84
+
85
+ ## Supported nodes
86
+
87
+ | runs | node types |
88
+ |---|---|
89
+ | local | text, upload (image/audio/video), choice, join, comment |
90
+ | NanoGPT | llm (incl. vision + audio input), image, draw, edit, inpaint*, vision, tvideo, ivideo, vedit, lipsync, music, remix, tts, transcribe |
91
+ | **not supported** (browser-only media processing) | resize, vframes, combine, soundtrack, trim, extractaudio |
92
+
93
+ Workflows containing unsupported node types load with a warning and fail fast at `run()` with
94
+ `UnsupportedNodeError` β€” before any network call.
95
+
96
+ \* inpaint caveat: the browser app composites the mask onto black at the source's pixel size;
97
+ this library passes your mask through verbatim, so supply a black/white mask matching the
98
+ source dimensions.
99
+
100
+ ## Use it as an agent skill
101
+
102
+ A saved workflow plus a short `SKILL.md` playbook makes a skill any coding agent can run β€”
103
+ Claude Code (`.claude/skills/<name>/SKILL.md`) or anything that reads markdown and runs shell.
104
+ Recipe + copy-pasteable template: [docs/agent-skills.md](docs/agent-skills.md); complete
105
+ example: [examples/agent-skill/poster-generator/](examples/agent-skill/poster-generator/).
106
+
107
+ ## Cost
108
+
109
+ You bring your own NanoGPT API key; NanoGPT bills your balance per generation and reports the
110
+ price on each response. `result.cost_usd` totals it and `result.cost_exact` turns `False` when
111
+ any call omitted a price (the total is then a floor). `result.remaining_balance` is the freshest
112
+ balance the API reported. A price of 0 means known-included (subscription), not unknown.
113
+ No telemetry, no analytics, and your API key is never logged.
114
+
115
+ ## Testing
116
+
117
+ Tests run fully offline against a mock NanoGPT server (`tests/harness/`):
118
+
119
+ ```bash
120
+ python -m unittest discover -s tests -t .
121
+ ```
122
+
123
+ An opt-in live probe (spends a fraction of a cent) exists for hand-verification:
124
+ `python3 scripts/live-spot-check.py` (add `--image` to also run the starter graph's image step).
125
+
126
+ ## Docs
127
+
128
+ Design contract and format/engine/io specs live in [`docs/`](docs/): `DESIGN.md`,
129
+ `SPEC-format.md`, `SPEC-engine.md`, `SPEC-io.md`.
130
+
131
+ ## License
132
+
133
+ MIT β€” see [LICENSE](LICENSE). Not affiliated with NanoGPT. Build workflows at
134
+ [nanoodle.io](https://nanoodle.io).
@@ -0,0 +1,70 @@
1
+ # nanoodle executor libraries β€” DESIGN (binding decisions)
2
+
3
+ Two sibling repos, both MIT, both ZERO runtime dependencies:
4
+ - /home/ntc/dev/nanoodle-js β€” npm package `nanoodle` (ESM, Node >= 20, built-in fetch, node:test)
5
+ - /home/ntc/dev/nanoodle-py β€” PyPI package `nanoodle` (Python >= 3.9, stdlib urllib only, unittest)
6
+
7
+ Read SPEC-format.md, SPEC-engine.md, SPEC-io.md in this directory first. They are the contract.
8
+
9
+ ## Public API β€” JS
10
+ ```js
11
+ import { Workflow, NanoodleError, UnsupportedNodeError, RunError } from "nanoodle";
12
+
13
+ const wf = await Workflow.load("noodle-graph.json", { apiKey }); // path, or pass a parsed object / JSON string to Workflow.fromJSON(objOrString, opts)
14
+ wf.inputs // [{ key, nodeId, field, kind, label, optional, def, options? }] key = resolved friendly name
15
+ wf.outputs // [{ key, nodeId, type, ports }]
16
+ wf.settings // [{ key, nodeId, field, kind, def, options? }]
17
+
18
+ const result = await wf.run({ "Text": "a cozy ramen shop" }, { settings: { "n3.model": "..." }, timeoutMs, signal, onProgress });
19
+ result.get("Image") // primary output value of that sink node
20
+ result.outputs // { [key]: value } (plus node-id keys)
21
+ result.costUsd, result.costExact, result.remainingBalance
22
+ result.nodes // per-node { status, out, error, costUsd, ms }
23
+ result.errors // [] of { nodeId, name, message }
24
+ ```
25
+ - run() REJECTS with RunError when any SINK node failed (RunError carries .result with partials). Non-sink failure that no sink depends on β†’ warning in result.errors only. (A failed non-sink makes its downstream sinks fail with "upstream failed: <name>" β€” so effectively any failure that matters rejects.)
26
+ - Media values: class MediaRef { url (data: or https), mime?, async bytes(), async save(path), toString() β†’ url }. Text outputs are plain strings. Inputs accept: string (text), data: URL, https URL, Buffer/Uint8Array (+ mime option via {data, mime}), or local file path via Workflow helpers (mediaFromFile(path)).
27
+ - onProgress(evt): { type: "node-start"|"node-done"|"node-error"|"poll", nodeId, name, ... }.
28
+ - Constructor opts: { apiKey = process.env.NANOGPT_API_KEY, baseUrl = "https://nano-gpt.com", fetch = globalThis.fetch, pollIntervals, timeouts } β€” injectable fetch/baseUrl is what the test harness uses.
29
+
30
+ CLI (bin/nanoodle.mjs, "nanoodle" bin entry):
31
+ ```
32
+ nanoodle run graph.json --input Text="a cozy ramen shop" --input n2.system=@file.txt --set n3.size=1k --out ./out [--json]
33
+ nanoodle inspect graph.json # prints inputs/outputs/settings + node table
34
+ ```
35
+ --out saves media outputs to files (fetch https, decode data:), prints text outputs; --json prints machine-readable result.
36
+
37
+ ## Public API β€” Python (mirror, pythonic)
38
+ ```python
39
+ from nanoodle import Workflow, NanoodleError, UnsupportedNodeError, RunError, media_from_file
40
+
41
+ wf = Workflow.load("noodle-graph.json", api_key=None) # env NANOGPT_API_KEY fallback; Workflow.from_dict(d) too
42
+ wf.inputs / wf.outputs / wf.settings # lists of dataclasses, same fields as JS
43
+ result = wf.run({"Text": "a cozy ramen shop"}, settings=None, timeout=None, on_progress=None)
44
+ result["Image"] # __getitem__ = outputs lookup (friendly key or node id)
45
+ result.outputs, result.cost_usd, result.cost_exact, result.remaining_balance, result.nodes, result.errors
46
+ ```
47
+ - Sync API (urllib + concurrent.futures ThreadPoolExecutor for node concurrency). Same RunError semantics.
48
+ - MediaRef: .url, .mime, .bytes(), .save(path), __str__ β†’ url.
49
+ - Injectable transport: Workflow(..., base_url=..., http=callable) for the harness (default small urllib wrapper).
50
+ - CLI: `python -m nanoodle run|inspect ...` mirroring the JS flags.
51
+
52
+ ## Shared behaviors (both)
53
+ - run() input validation UPFRONT: unknown input key β†’ error listing valid keys; missing required input with empty field default β†’ error naming it; no API key while graph has network nodes β†’ error BEFORE any node runs.
54
+ - Unsupported node types (resize/vframes/combine/soundtrack/trim/extractaudio) and unknown types that must RUN β†’ UnsupportedNodeError at run start (fail fast, before spending), naming node + type. Workflow.load only warns.
55
+ - No locale suffix, no catalog fetch, no seed skip-cache, no telemetry/analytics of ANY kind. Never log the API key. Media over 4.4MB inline β†’ clear local error.
56
+ - Version: 0.1.0.
57
+
58
+ ## Repo layout (each)
59
+ README.md (quickstart: download the save from https://nanoodle.io β†’ 3-line usage; the starter-graph example; supported node matrix; cost note; link to nanoodle app)
60
+ LICENSE (MIT, "Copyright (c) 2026 nanoodle contributors")
61
+ src/... (js: src/*.mjs re-exported from src/index.mjs; py: src/nanoodle/*.py)
62
+ tests/ (harness + unit tests; node:test / unittest β€” NO third-party test deps)
63
+ tests/fixtures/*.json (starter graph copy + purpose-built graphs: join/choice chain, llm-vision, edit multi-image, video poll, tts binary, music async-poll, transcribe, unsupported-node, cycle, field-override, duplicate-names, unknown-key errors)
64
+ tests/harness/ mock NanoGPT server (js: node:http; py: http.server in a thread) — canned per-endpoint responses, records every request (method/path/headers/body) for payload assertions, scriptable sequences (video pending→pending→completed; audio runId→poll; 401/402 errors; binary audio response with x-cost header).
65
+ scripts/live-spot-check.{mjs,py} — OPT-IN live test: reads NANOGPT_API_KEY from env or --env-file, runs fixture text→llm (model zai-org/glm-5.2, maxTokens 60) and prints text + cost; --image flag additionally runs the starter graph image step. NEVER run by CI.
66
+ .github/workflows/test.yml β€” offline unit tests only, on push.
67
+ .gitignore (node_modules, dist, __pycache__, .env, out/)
68
+
69
+ ## Test plan (harness-first; the workflow phase will extend it)
70
+ Cover at minimum: graph load/aliases/link-migration/unknown-type; topo order + cycle error; concurrency (two parallel lanes both hit server); wiring incl. field override via textarea port; input derivation + all key-resolution orders + ambiguity errors + bare-scalar single-input; settings override + wired-field-refused; every network node type's payload EXACTLY (assert recorded body against spec) + response parse incl. b64 mime sniff, draw message.images, video poll loop + failure + timeout, audio JSON-url/JSON-runId-poll/binary branches, transcribe multipart (assert field name "file"); cost extraction priority incl. zero-cost-kept and header fallback; 401/402/500 error mapping; RunError partial results; MediaRef bytes/save; unsupported node fail-fast BEFORE any network call; no Authorization header leak in errors/repr.
@@ -0,0 +1,127 @@
1
+ # Nanoodle Run Engine β€” server-side re-implementation spec
2
+
3
+ Blueprint = play.html RUNTIME_JS (the exported-app runtime). Line refs are play.html.
4
+
5
+ ## Endpoints & auth
6
+ ```
7
+ NANOGPT = https://nano-gpt.com
8
+ IMG_ENDPOINT POST {NANOGPT}/v1/images/generations (note: NOT /api/v1)
9
+ CHAT_ENDPOINT POST {NANOGPT}/api/v1/chat/completions
10
+ VIDEO submit POST {NANOGPT}/api/generate-video
11
+ VIDEO poll GET {NANOGPT}/api/video/status?requestId=<id>
12
+ AUDIO speech POST {NANOGPT}/api/v1/audio/speech (music + tts + remix)
13
+ AUDIO poll GET {NANOGPT}/api/tts/status?<qs>
14
+ TRANSCRIBE POST {NANOGPT}/api/v1/audio/transcriptions (multipart)
15
+ Catalogs (public, no key): /api/v1/models?detailed=true, /api/v1/image-models,
16
+ /api/v1/video-models, /api/v1/audio-models
17
+ ```
18
+ Every JSON call: `Content-Type: application/json` + BOTH `Authorization: Bearer <key>` and `x-api-key: <key>`.
19
+ Multipart transcribe: the two auth headers, NO explicit Content-Type (let the http lib set boundary). File form field MUST be named "file"; also fields `model`, optional `language`.
20
+
21
+ All media is inlined as base64 `data:` URLs in JSON bodies (no upload endpoint). MEDIA_INLINE_MAX = 4.4MB β€” guard locally and raise a clear error above it. Chat runs NON-STREAMING (no `stream` key); parse `r.json()` once.
22
+
23
+ Model strings pass through VERBATIM (`body.model = node.fields.model`). Missing model β†’ error "pick a model first". Endpoint choice is by node TYPE, never by model lookup. The catalog is optional/best-effort (capability gating + clamps); the library must run fine with no catalog fetch.
24
+
25
+ ## Per-node payloads
26
+
27
+ ### llm β†’ CHAT_ENDPOINT (genChat 1481-1504, run 2574-2603)
28
+ ```
29
+ body = { model, messages, temperature: 0.8 }
30
+ if maxTokens body.max_tokens = +maxTokens
31
+ if format == "JSON" body.response_format = { type: "json_object" }
32
+ if reasoningEffort set and != "default" body.reasoning_effort = value
33
+ ```
34
+ messages: optional {role:"system", content: fields.system} then user message.
35
+ User content = plain string prompt, UNLESS wired images (img1,img2,... sorted by index) or wired audio:
36
+ then array `[{type:"text",text:prompt}, {type:"image_url",image_url:{url:<value verbatim>}}..., audioPart?]`.
37
+ audioPart = `{type:"input_audio", input_audio:{data:<base64 body, no data: prefix>, format:"mp3"|"wav"|...}}`.
38
+ prompt = wired `prompt` port value ?? fields.prompt; error "no prompt" if empty.
39
+ Parse: `j.choices[0].message.content` (if array, join `.map(p=>p.text)`); throw "no text in response" if null/empty.
40
+ (withLocale non-English system suffix: SKIP in the library / make opt-in.)
41
+
42
+ ### vision β†’ same as llm: one user message [{text: q||"Describe this image."}, {image_url: inp.image}]
43
+
44
+ ### image / edit / inpaint β†’ IMG_ENDPOINT (genImage 1465-1480)
45
+ ```
46
+ body = { model, size: fields.size || "1024x1024", n: variations||1, response_format: "b64_json" }
47
+ if prompt body.prompt = prompt
48
+ if source image body.imageDataUrl = <string OR array of strings (edit multi-ref)>
49
+ if mask body.maskDataUrl = <mask data URL; white = repaint>
50
+ + seed (when numeric), + customCivitaiAir (model "custom-civitai"), + LoRA params
51
+ ```
52
+ - image node: `variations` β†’ n (multi output = j.data list).
53
+ - edit: sources from wired `image, image2, ...` ports; single β†’ string, multiple β†’ array. Prompt may be empty for upscaler models β€” do not hard-require.
54
+ - inpaint: source+mask from ports or fields. (Browser composites mask onto black at source size via canvas; library v1: pass mask through verbatim and document the caveat.)
55
+ Parse: `j.data[]` β†’ `d.b64_json ? "data:<sniffed mime>;base64,"+b64 : d.url`. Sniff mime from magic bytes (PNG \x89PNG, JPEG \xFF\xD8, GIF, WEBP RIFF....WEBP; default image/png). Throw "no image in response" if empty.
56
+
57
+ ### draw β†’ CHAT_ENDPOINT (genChatImage 1508-1523)
58
+ body = {model, messages, temperature:0.8} (no response_format). Wired images like llm.
59
+ Parse: images from `j.choices[0].message.images[]` β†’ `im.image_url.url || im.url || im`; text from message.content (may be null β€” fine).
60
+
61
+ ### tvideo / ivideo / vedit / lipsync β†’ submit + poll (genVideo 1527-1588)
62
+ ```
63
+ body = { model, prompt }
64
+ + dims from node fields: aspect β†’ aspect_ratio, duration β†’ duration, resolution β†’ resolution
65
+ (catalog can rename e.g. aspect→orientation / duration→seconds; WITHOUT catalog use the standard names)
66
+ + ivideo/lipsync source image β†’ body.imageDataUrl (data: or https, verbatim)
67
+ + ivideo wired endframe β†’ body.last_image
68
+ + vedit source: https URL β†’ body.videoUrl ; local data β†’ body.videoDataUrl
69
+ + lipsync audio: https β†’ body.audioUrl ; local β†’ body.audioDataUrl
70
+ + Object.assign(body, fields.modelOpts || {}) (per-model knobs incl. seed)
71
+ + tvideo wired ref1.. β†’ body.reference_images (array) β€” catalog may rename key; default "reference_images"
72
+ ```
73
+ Submit response: `runId = j.runId || j.id`. Then poll every 5s: GET /api/video/status?requestId=<runId>.
74
+ status = (s.data?.status || s.status).toUpperCase().
75
+ COMPLETED|SUCCEEDED β†’ url = s.data?.output?.video?.url || out.url || out.video?.[0]?.url (out = s.data?.output or s).
76
+ FAILED|ERROR|CANCELED β†’ raise "video failed: " + (s.data?.error || status). Timeout 600s.
77
+
78
+ ### music / tts / remix β†’ AUDIO speech endpoint (genAudio 1591-1637)
79
+ ```
80
+ body = { model, input: <wired text ?? fields.prompt> } + params
81
+ music params: lyrics, instrumental(bool), duration(number), negative_prompt, seed, response_format(default "mp3")
82
+ tts params: voice, speed(omit when 1), instructions, response_format(default "mp3")
83
+ remix params: lyrics, duration, response_format + body.audio = <source: https as-is | local data URL>
84
+ + merge fields.extraJson (parsed object) verbatim last
85
+ Omit empty params. (Catalog gating of voice/duration: skip in v1 β€” send what the node has.)
86
+ ```
87
+ Response handling:
88
+ - content-type JSON β†’ url = j.url||j.audioUrl||j.data?.url||j.data?.audioUrl; if none but j.runId||j.id β†’ poll
89
+ GET /api/tts/status?runId=..&model=..&cost=..&paymentSource=..&isApiRequest=true every 3s;
90
+ status lowercase: completed|succeeded β†’ s.audioUrl||s.url||s.data?.audioUrl||s.data?.url;
91
+ error|failed|content_policy_violation β†’ raise. Timeout 300s.
92
+ - else BINARY body β†’ the audio bytes; mime from response content-type (pin from requested format if generic).
93
+ Library returns bytes+mime (a data: URL or MediaRef), not an object URL.
94
+
95
+ ### transcribe β†’ multipart (1641-1666)
96
+ FormData: file=<audio blob> (field name "file"), model, language?. Local guard: >3.5MB raise.
97
+ Parse: `j.transcription ?? j.text ?? j.data?.transcription ?? j.data?.text`.
98
+
99
+ ## Cost extraction (costFromJson 998-1013)
100
+ USD priority: j.cost (if >0) β†’ j.x_nanogpt_pricing.(costUsd|cost|amount) β†’ j.metadata?.cost β†’ header x-cost / x-nano-cost.
101
+ Balance: header x-remaining-balance (wins) β†’ j.remainingBalance β†’ x_nanogpt_pricing.remainingBalance.
102
+ Present-but-zero = known-included (subscription), keep 0. Absent β†’ cost unknown (mark total inexact).
103
+
104
+ ## HTTP errors (922-935)
105
+ - 401/403 β†’ auth error ("API key rejected").
106
+ - 402 OR body matching /insufficient|balance|funds|not enough|payment required/i β†’ out-of-funds error.
107
+ - else β†’ error "<status>: <body first 160 chars>".
108
+ No streaming retries needed (engine is non-streaming). Poll GET failures: silently continue the loop until timeout.
109
+
110
+ ## Execution (runGraph 3000-3133)
111
+ 1. Alias/filter nodes (materialize): audio→tts, drop unknown types + orphaned links, migrate music/tts inbound "text" port → "prompt".
112
+ 2. Kahn topological order; cyclic β†’ error naming the cyclic nodes.
113
+ 3. Concurrency: node starts when ITS deps finish (siblings run concurrently). Library: same semantics (asyncio / Promise per node).
114
+ 4. Input resolution per node: for declared ports, value = srcNode.out[from.port]. Dynamic families: img\d+ / image\d*, vid\d+, clip\d+, audio, endframe, ref\d+, frame\d+ (vframes outputs). ANY other inbound link = FIELD OVERRIDE: run with fields = {...fields, [port]: value} (that's how wired prompt/system/lyrics/q override typed values).
115
+ 5. Node failure: record error for that node, continue independent lanes (library default: collect; raise at end if a sink failed β€” see DESIGN).
116
+ 6. comment nodes never run. Fixed-seed skip-cache: optional for a library (stateless one-shot runs don't need it) β€” SKIP in v1.
117
+
118
+ ## Local nodes to IMPLEMENT (pure logic)
119
+ - text: out.text = fields.text
120
+ - upload/aupload/vupload: out = the stored/provided data URL
121
+ - choice: options = fields.options.split("\n") non-empty trimmed; out = fields.selected if in options else first; error if no options
122
+ - join: [a,b].filter(non-empty).join(sep) where sep = fields.sep ?? " ", literal "\\n" in sep means newline
123
+ - comment: skip
124
+
125
+ ## Local nodes UNSUPPORTED in v1 (browser media ops β€” raise UnsupportedNodeError naming node + type)
126
+ resize, vframes, combine, soundtrack, trim, extractaudio.
127
+ Error message must say: "node type 'X' does local media processing that requires the nanoodle browser app; not supported by this library yet".
@@ -0,0 +1,70 @@
1
+ # Nanoodle Workflow JSON Format (the downloadable "save")
2
+
3
+ Verified against /home/ntc/dev/nanoodle/index.html (editor). The πŸ’Ύ Save button writes
4
+ `noodle-graph.json` = exactly `JSON.stringify(serializeGraph(), null, 2)` β€” no wrapper.
5
+
6
+ ## Top-level shape
7
+ ```json
8
+ {
9
+ "v": 1,
10
+ "nodes": [ { "id":"n1", "type":"text", "x":60, "y":130, "fields":{...}, "w":220, "sizes":{"system":120}, "name":"optional custom name" } ],
11
+ "links": [ { "id":"l1", "from":{"node":"n1","port":"text"}, "to":{"node":"n2","port":"prompt"} } ],
12
+ "nid": 4, "lid": 3,
13
+ "view": { "panX":40, "panY":60, "scale":1 }
14
+ }
15
+ ```
16
+ - `v` β€” always 1.
17
+ - Node: `id` (string "nN"), `type` (NODE_TYPES key), `x/y/w/sizes` layout-only (ignore), `fields` (param bag, type-specific), `name` (optional user label β€” the display name).
18
+ - Link: `{id, from:{node,port}, to:{node,port}}`.
19
+ - `nid/lid/view` β€” editor counters/camera; ignore for execution.
20
+ - ALL keys except `nodes` are optional to a loader: `d.nodes||[]`, `d.links||[]`. Accept `{nodes, links}` minimal form.
21
+
22
+ ## Loader semantics (applyGraphData, index.html:8815-8860) β€” REPLICATE THESE
23
+ - Type alias: `audio` β†’ `tts` (legacy).
24
+ - Unknown node type β†’ node silently skipped (library: keep node but error only if it must run β€” see DESIGN; simplest faithful behavior: drop unknown-type nodes AND their links, but surface a warning).
25
+ - Links kept only if BOTH endpoints exist after node filtering.
26
+ - Migration: links into a `music`/`tts` node with `to.port === "text"` are rewritten to `"prompt"`.
27
+ - Media fields are `data:` URLs inline in `fields` (image/audio/video/mask). Share links may have them blanked to "".
28
+
29
+ ## Node type registry (NODE_TYPES, index.html:5015-5679)
30
+ Port kinds: text | image | audio | video. Only matching kinds connect.
31
+ Every `<textarea>` field is also a wireable text input port with port name == field name
32
+ (EXCEPT on node types `text`, `choice`, comments, and the `extraJson` field). A wire into a
33
+ field-port overrides the typed field value at run time.
34
+
35
+ | type | title | static inputs | dynamic inputs | outputs | key fields |
36
+ |---|---|---|---|---|---|
37
+ | text | Text | β€” | β€” | text:text | text (the literal value; NOT wireable) |
38
+ | upload | Image input | β€” | β€” | image:image | image (data: URL) |
39
+ | aupload | Audio input | β€” | β€” | audio:audio | audio (data: URL) |
40
+ | vupload | Video input | β€” | β€” | video:video | video (data: URL) |
41
+ | choice | Choice | β€” | β€” | text:text | options (newline-separated), selected |
42
+ | join | Join | a:text, b:text | β€” | text:text | sep (default " "; literal "\n" means newline) |
43
+ | llm | LLM | β€” | img1..:image (vision), audio:audio | text:text | model, system, prompt, temperature, maxTokens, format(Text\|JSON), reasoningEffort, showThinking |
44
+ | image | Image | β€” | β€” | image:image | model, prompt, size, variations, seed, customCivitaiAir |
45
+ | draw | Draw | β€” | img1..:image | image:image, text:text | model, system, prompt, showThinking |
46
+ | edit | Edit | β€” | image,image2..:image | image:image | model, prompt, size, seed |
47
+ | inpaint | Inpaint | image:image, mask:image | β€” | image:image | model, prompt, size, seed, brush |
48
+ | resize | Resize/crop | image:image | β€” | image:image | mode(fit\|fill\|exact), width, height (LOCAL) |
49
+ | vision | Vision | image:image | β€” | text:text | model, q |
50
+ | tvideo | Text→Video | — | ref1..:image | video:video | model, prompt, duration, aspect, resolution, modelOpts |
51
+ | ivideo | Image→Video | image:image | endframe:image | video:video | model, prompt, duration, aspect, resolution, modelOpts |
52
+ | vedit | Video edit | video:video | β€” | video:video | model, prompt, resolution, modelOpts |
53
+ | vframes | Video→frames | video:video | — | frame1..frameN:image (dynamic, fields.frames) | frames(1-12), gap, dir(end\|start) (LOCAL) |
54
+ | combine | Combine videos | β€” | clip1..:video | video:video | dedup (LOCAL) |
55
+ | soundtrack | Soundtrack | video:video, audio:audio | β€” | video:video | loop (LOCAL) |
56
+ | lipsync | Avatar/lipsync | image:image, audio:audio | β€” | video:video | model, prompt, resolution, modelOpts |
57
+ | music | Music | β€” | β€” | audio:audio | model, prompt, lyrics, instrumental, duration, negative_prompt, seed, extraJson |
58
+ | remix | Remix audio | audio:audio | β€” | audio:audio | model, prompt, lyrics, duration, extraJson |
59
+ | tts | Speech | β€” | β€” | audio:audio | model, prompt, voice, speed, instructions, extraJson |
60
+ | trim | Trim audio | audio:audio | β€” | audio:audio | start, length (LOCAL) |
61
+ | extractaudio | Extract audio | video:video | β€” | audio:audio | start, length (LOCAL) |
62
+ | transcribe | Transcribe | audio:audio | β€” | text:text | model, language |
63
+ | comment | Comment | none (note:true, never runs) | β€” | β€” | text, color |
64
+
65
+ Display name resolution (play.html `displayName`): `node.name` (trimmed, if set) β†’ NODE_TYPES[type].title β†’ type β†’ "?".
66
+
67
+ ## Canonical fixture
68
+ /home/ntc/dev/nanoodle/noodle-graph.json β€” starter graph: text("a cozy ramen shop on a rainy night")
69
+ β†’ llm(model "zai-org/glm-5.2", system prompt-writer) β†’ image(model "nano-banana-2-lite", size "1k", variations "1").
70
+ Wire n1.text→n2.prompt, n2.text→n3.prompt.
@@ -0,0 +1,45 @@
1
+ # Nanoodle Workflow Public Interface (inputs / outputs / naming)
2
+
3
+ ## Inputs β€” deriveInputs (play.html 3140-3206)
4
+ An input = an INPUT_SPECS field on a node that is NOT fed by a wire (`links.some(l => l.to.node===id && l.to.port===field)` β†’ hidden).
5
+
6
+ INPUT_SPECS table (only these node types contribute; kind in parens):
7
+ - text: text (textarea)
8
+ - upload: image (image) Β· aupload: audio (audio) Β· vupload: video (video)
9
+ - llm: prompt (textarea, required); system (textarea, OPTIONAL, def "You are a helpful, concise assistant.")
10
+ - image: prompt Β· tvideo: prompt Β· music: prompt Β· remix: prompt Β· tts: prompt (all textarea, required)
11
+ - draw: prompt (required); system (optional)
12
+ - inpaint (special): prompt ("What to paint in"); image and/or mask when not wired
13
+ - choice (special): field `selected`, kind choice, options from fields.options newline-split
14
+
15
+ Required unless marked optional. `def` = prefilled default.
16
+
17
+ ## Input NAMING (for run({name: value}) resolution)
18
+ - Node display name = node.name (trimmed) β†’ NODE_TYPES[type].title β†’ type.
19
+ - The app labels an input with its generic spec label ("Image prompt", "Text", ...), EXCEPT:
20
+ when a node contributes exactly ONE required input AND has a custom name, that custom name is the label (PR #138).
21
+ - upload nodes feeding role ports get role labels ("End frame", "Reference N", "Image N") β€” UI nicety; library can skip.
22
+ - Unique key = (nodeId, field). LIBRARY RESOLUTION ORDER for a user-supplied key (case-insensitive, trimmed):
23
+ 1. exact node custom name (if that node has exactly one derived input β†’ that input; ambiguous β†’ error listing candidates)
24
+ 2. "nodeId.field" (e.g. "n2.prompt") and bare nodeId (if single input on the node)
25
+ 3. the input's label / field name if unique across inputs
26
+ Unknown key β†’ error listing available input names.
27
+ - If the workflow has exactly one required input, allow a bare scalar: run("hello").
28
+
29
+ ## Settings β€” deriveSettings (play.html 3229-3328)
30
+ Per-node knobs (model, size, temperature, duration, voice, seed, ...) that are NOT part of IO shape.
31
+ Library: expose overrides via run(..., settings={...}) resolved the same way (name/nodeId.field), applied as
32
+ node.fields[field] = value before execution. Same wired-hides rule (cannot override a wired field via settings).
33
+
34
+ ## Outputs β€” deriveOutputs (play.html 3208-3214)
35
+ Output nodes = nodes with a non-empty outputs list AND no outgoing link (sinks). No explicit marker.
36
+ Result value = node.out[NODE_TYPES[type].outputs[0].name] (primary port). draw also has secondary text; expose all ports.
37
+ Result keying: displayName(node) (custom name β†’ type title); on duplicate display names, suffix " 2", " 3" in topo order; always ALSO keyed by node id.
38
+ Intermediate nodes still run; expose them under result.nodes / result.steps for debugging.
39
+
40
+ ## Key & auth
41
+ - One key: NanoGPT API key (or OAuth access token β€” identical usage).
42
+ - Headers on every call: Authorization: Bearer <key> AND x-api-key: <key>.
43
+ - Library: constructor arg api_key, fallback env NANOGPT_API_KEY. No key + graph has network nodes β†’ clear upfront error.
44
+ - Balance: /api/check-balance POST {} returns {usd_balance} with ACAO * (works server-side fine). Optional helper.
45
+ - Whole-graph runs only (like exported apps). No subset/runGroup in v1.