| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310 |
- #!/usr/bin/env python3
- """
- Rewrite the settings block of the SD.cpp generation nodes from the server's own
- option reference.
- The server publishes every field the generation endpoints accept at
- /options/generation, with a label, a type, a default, a description and which
- endpoints it applies to. Typing that list into five node files by hand is how
- they drifted in the first place - three of them exposed ten fields out of
- forty-six and hid the rest behind an "Extra Options" JSON box.
- So the list is generated. Run this against a server, check the diff, commit the
- result. The nodes stay self-contained JavaScript afterwards - nothing here runs
- at execution time.
- python3 scripts/gen-sdcpp-generation-options.py http://mulan:8077
- """
- import json
- import re
- import sys
- import urllib.request
- from pathlib import Path
- NODES = {
- 'sdcpp-txt2img': 'txt2img',
- 'sdcpp-img2img': 'img2img',
- 'sdcpp-edit': 'img2img',
- 'sdcpp-txt2vid': 'txt2vid',
- 'sdcpp-upscale': 'upscale',
- }
- # Settings that already exist under a name of their own, kept so workflows that
- # were configured before this generator do not lose their values.
- KEEP_NAME = {
- 'negative_prompt': 'negativePrompt',
- 'cfg_scale': 'cfgScale',
- 'batch_count': 'batchCount',
- 'clip_skip': 'clipSkip',
- 'init_image_base64': 'initImageBase64',
- 'mask_image_base64': 'maskImageBase64',
- 'image_base64': 'imageBase64',
- 'upscale_factor': 'upscaleFactor',
- 'tile_size': 'tileSize',
- 'video_frames': 'videoFrames',
- 'ref_images': 'refImages',
- 'ref_image_args': 'refImageArgs',
- }
- # Fields the node handles itself rather than passing through, or that make no
- # sense to type into a form.
- SKIP = {'title'}
- # Fields the endpoints accept that /options/generation does not describe yet.
- # They are in the OpenAPI request schemas, so they are real - the per-field
- # reference simply has not caught up. Declared here in the same shape the
- # reference uses, so that when it does catch up these can be deleted and
- # nothing else changes.
- UNDOCUMENTED = {
- 'ip_adapter_image_base64': {
- 'applies_to': ['txt2img', 'img2img', 'txt2vid'],
- 'label': 'IP-Adapter Image (base64)', 'type': 'string', 'default': '',
- 'description': 'A reference image whose style and subject guide the result, as base64. '
- 'Needs an IP-Adapter loaded alongside the model - see the Load Model node.',
- 'recommended': 'Take it from a Download or Fetch Output node rather than pasting one in.',
- 'category': 'image_input',
- },
- 'ip_adapter_strength': {
- 'applies_to': ['txt2img', 'img2img', 'txt2vid'],
- 'label': 'IP-Adapter Strength', 'type': 'number', 'default': 1.0,
- 'description': 'How strongly the reference image guides the result.',
- 'recommended': '1.0 is the upstream default. Lower it when the reference is overwhelming the prompt.',
- 'category': 'image_input',
- },
- 'ref_audios': {
- 'applies_to': ['txt2vid'],
- 'label': 'Reference Audios', 'type': 'array<string>', 'default': [],
- 'description': 'Reference audio as base64-encoded WAV, mono or stereo PCM (i16/i24/i32/f32).',
- 'recommended': 'For models that take audio guidance.',
- 'category': 'video',
- },
- 'ref_videos': {
- 'applies_to': ['txt2vid'],
- 'label': 'Reference Videos', 'type': 'array<object>', 'default': [],
- 'description': 'Reference videos. Each entry is an object with frames (base64 images), '
- 'fps (default 24) and an optional audio_wav_base64.',
- 'recommended': 'Built by an earlier node rather than typed.',
- 'category': 'video',
- },
- }
- # Fields a node takes that the reference files under a different endpoint. The
- # server documents ref_images and ref_image_args as txt2img fields, but the
- # image-edit node posts them to /img2img and the server accepts them - so they
- # stay, rather than a node losing a working feature to a documentation table.
- EXTRA_FOR = {
- 'sdcpp-edit': ['ref_images', 'ref_image_args'],
- }
- # Which group the connection settings go in, and its position.
- SERVER_GROUP = {'title': 'Server', 'fields': ['serverUrl', 'credentialId']}
- # Pressing the button asks the server which architecture the loaded model is and
- # writes that architecture's generation defaults into the form. Every preset
- # carries width, height, steps, cfg_scale, sampler and scheduler; a few carry
- # more. A field a preset does not mention is left alone rather than blanked.
- PREFILL_FIELDS = [
- 'width', 'height', 'steps', 'cfgScale', 'sampler', 'scheduler',
- 'cacheMode', 'distilledGuidance', 'flowShift', 'negativePrompt',
- 'videoFrames', 'fps',
- ]
- def camel(name: str) -> str:
- if name in KEEP_NAME:
- return KEEP_NAME[name]
- head, *rest = name.split('_')
- return head + ''.join(w[:1].upper() + w[1:] for w in rest)
- def js(value) -> str:
- """A JS literal. json.dumps is valid JS for everything used here."""
- return json.dumps(value, ensure_ascii=False)
- def describe(opt: dict) -> str:
- text = ' '.join(str(opt.get('description', '')).split())
- hint = ' '.join(str(opt.get('recommended', '')).split())
- if hint:
- text = f'{text} Recommended: {hint}' if text else f'Recommended: {hint}'
- # Every one of these is optional: the server fills an absent field from the
- # loaded model's architecture preset, which is almost always the right
- # answer and is not something the node can know.
- return f'{text} Leave empty for the architecture default.'
- def prop_for(name: str, opt: dict) -> dict:
- kind = opt.get('type')
- setting = camel(name)
- prop: dict = {'title': opt.get('label') or setting, 'description': describe(opt)}
- if kind == 'select':
- values = opt.get('values') or {}
- # The empty entry is what "leave it to the architecture" looks like in a
- # dropdown; without it a select cannot express "unset". Some of the
- # server's own value maps already carry one, so it is not added twice.
- keys = [k for k in values.keys() if k != '']
- prop['type'] = 'string'
- prop['enum'] = [''] + keys
- prop['enumLabels'] = ['(architecture default)'] + [
- ' '.join(str(values[k]).split())[:70] or k for k in keys
- ]
- prop['default'] = ''
- elif kind == 'boolean':
- prop['type'] = 'boolean'
- elif kind == 'number':
- prop['type'] = 'number'
- elif kind == 'array<string>':
- prop['type'] = 'array'
- prop['items'] = {'type': 'string'}
- elif kind == 'array<number>':
- prop['type'] = 'array'
- prop['items'] = {'type': 'number'}
- elif kind == 'array<object>':
- prop['type'] = 'array'
- prop['items'] = {'type': 'object'}
- else:
- prop['type'] = 'string'
- if name in ('prompt', 'negative_prompt'):
- prop['format'] = 'textarea'
- return prop
- def render_properties(props: dict, indent: str = ' ') -> str:
- out = []
- for key, prop in props.items():
- inner = ', '.join(f'{k}: {js(v)}' for k, v in prop.items())
- out.append(f'{indent}{key}: {{ {inner} }}')
- return ',\n'.join(out)
- def build(reference: dict, endpoint: str, extra: list) -> tuple:
- options = dict(reference['options'])
- categories = reference['categories']
- for name, described in UNDOCUMENTED.items():
- options[name] = {k: v for k, v in described.items() if k != 'category'}
- applicable = [
- (name, opt) for name, opt in options.items()
- if (endpoint in opt.get('applies_to', []) or name in extra) and name not in SKIP
- ]
- by_name = dict(applicable)
- props = {}
- table = []
- for name, opt in applicable:
- setting = camel(name)
- props[setting] = prop_for(name, opt)
- table.append((setting, name))
- # The undocumented fields belong in a group too, next to the ones they are
- # related to rather than dumped in "Other".
- extra_by_category: dict = {}
- for name, described in UNDOCUMENTED.items():
- if name in by_name:
- extra_by_category.setdefault(described['category'], []).append(name)
- groups = []
- for cat_key, cat in categories.items():
- fields = [camel(n) for n in list(cat['options']) + extra_by_category.get(cat_key, [])
- if n in by_name]
- if fields:
- groups.append({'title': cat['label'], 'fields': fields})
- # Core first, then the rest as the server lists them.
- groups.sort(key=lambda g: 0 if g['title'] == 'Core' else 1)
- return props, table, groups
- def rewrite(path: Path, endpoint: str, reference: dict) -> str:
- source = path.read_text()
- start = source.index('const configSchema')
- end = source.index('const inputSchema')
- props, table, groups = build(reference, endpoint, EXTRA_FOR.get(path.stem, []))
- # Node-owned settings that are not server generation options.
- tail_props = {
- 'title': {'type': 'string', 'title': 'Job Title',
- 'description': 'Optional label stored with the job, useful for finding it again in the queue'},
- 'extraOptions': {'type': 'object', 'title': 'Extra Options',
- 'description': 'Any other generation field passed straight through. Everything the server documents already has a setting above, so this is only needed for a field a newer server has gained'},
- 'timeout': {'type': 'number', 'title': 'Timeout (ms)',
- 'description': 'Applies to queueing the job, not to the render. The call returns as soon as the job is accepted',
- 'default': 30000},
- }
- ui_groups = [SERVER_GROUP] + groups + [{'title': 'Job', 'fields': ['title', 'extraOptions', 'timeout']}]
- prefill = {
- 'label': 'Take the architecture defaults',
- 'description': "Fill these in from the preset for whichever model the server has loaded - the same values it would use if these were left empty",
- 'node': 'sdcpp-architecture',
- 'needs': ['serverUrl', 'credentialId'],
- 'map': {f'defaults.{f}': f for f in PREFILL_FIELDS if f in props},
- }
- head = """const configSchema = {
- type: 'object',
- // Generated from the server's own reference at /options/generation - see
- // scripts/gen-sdcpp-generation-options.py. Every field the endpoint accepts
- // has a setting here, grouped the way the server groups them.
- uiGroups: %s,
- prefill: %s,
- properties: {
- serverUrl: {
- type: 'string', title: 'Server URL',
- description: 'Base address of the sdcpp-restapi server',
- default: 'http://localhost:8077'
- },
- credentialId: {
- type: 'string', title: 'Credential',
- description: 'A basic credential holding the sdcpp-restapi username and password',
- dynamicOptions: { source: 'credentials', filter: { type: ['sdcpp', 'basic'] } }
- },
- """ % (json.dumps(ui_groups, ensure_ascii=False, indent=8).replace('\n', '\n '),
- json.dumps(prefill, ensure_ascii=False, indent=8).replace('\n', '\n '))
- body = render_properties(props) + ',\n' + render_properties(tail_props)
- new_schema = head + body + "\n },\n required: ['credentialId']\n};\n\n"
- source = source[:start] + new_schema + source[end:]
- # The setting-to-field table the request is built from.
- rows = ',\n'.join(f" {{ setting: {js(s)}, server: {js(n)} }}" for s, n in table)
- table_js = (
- "// Every generation field the server documents, and the setting it comes\n"
- "// from. Generated alongside the schema above so the two cannot drift.\n"
- "const GENERATION_OPTIONS = [\n" + rows + "\n];\n"
- )
- marker = 'async function execute('
- if 'const GENERATION_OPTIONS' in source:
- source = re.sub(r'// Every generation field the server documents.*?\n\];\n',
- table_js, source, flags=re.S)
- else:
- source = source.replace(marker, table_js + '\n' + marker, 1)
- return source
- def main():
- server = sys.argv[1] if len(sys.argv) > 1 else 'http://localhost:8077'
- with urllib.request.urlopen(server.rstrip('/') + '/options/generation', timeout=30) as f:
- reference = json.loads(f.read().decode())
- root = Path(__file__).resolve().parent.parent / 'nodes' / 'sdcpp'
- for node, endpoint in NODES.items():
- path = root / f'{node}.js'
- path.write_text(rewrite(path, endpoint, reference))
- count = sum(1 for n, o in reference['options'].items()
- if endpoint in o.get('applies_to', []) or n in EXTRA_FOR.get(node, []))
- print(f'{node:18} {endpoint:8} {count} options')
- if __name__ == '__main__':
- main()
|