gen-sdcpp-generation-options.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. #!/usr/bin/env python3
  2. """
  3. Rewrite the settings block of the SD.cpp generation nodes from the server's own
  4. option reference.
  5. The server publishes every field the generation endpoints accept at
  6. /options/generation, with a label, a type, a default, a description and which
  7. endpoints it applies to. Typing that list into five node files by hand is how
  8. they drifted in the first place - three of them exposed ten fields out of
  9. forty-six and hid the rest behind an "Extra Options" JSON box.
  10. So the list is generated. Run this against a server, check the diff, commit the
  11. result. The nodes stay self-contained JavaScript afterwards - nothing here runs
  12. at execution time.
  13. python3 scripts/gen-sdcpp-generation-options.py http://mulan:8077
  14. """
  15. import json
  16. import re
  17. import sys
  18. import urllib.request
  19. from pathlib import Path
  20. NODES = {
  21. 'sdcpp-txt2img': 'txt2img',
  22. 'sdcpp-img2img': 'img2img',
  23. 'sdcpp-edit': 'img2img',
  24. 'sdcpp-txt2vid': 'txt2vid',
  25. 'sdcpp-upscale': 'upscale',
  26. }
  27. # Settings that already exist under a name of their own, kept so workflows that
  28. # were configured before this generator do not lose their values.
  29. KEEP_NAME = {
  30. 'negative_prompt': 'negativePrompt',
  31. 'cfg_scale': 'cfgScale',
  32. 'batch_count': 'batchCount',
  33. 'clip_skip': 'clipSkip',
  34. 'init_image_base64': 'initImageBase64',
  35. 'mask_image_base64': 'maskImageBase64',
  36. 'image_base64': 'imageBase64',
  37. 'upscale_factor': 'upscaleFactor',
  38. 'tile_size': 'tileSize',
  39. 'video_frames': 'videoFrames',
  40. 'ref_images': 'refImages',
  41. 'ref_image_args': 'refImageArgs',
  42. }
  43. # Fields the node handles itself rather than passing through, or that make no
  44. # sense to type into a form.
  45. SKIP = {'title'}
  46. # Fields the endpoints accept that /options/generation does not describe yet.
  47. # They are in the OpenAPI request schemas, so they are real - the per-field
  48. # reference simply has not caught up. Declared here in the same shape the
  49. # reference uses, so that when it does catch up these can be deleted and
  50. # nothing else changes.
  51. UNDOCUMENTED = {
  52. 'ip_adapter_image_base64': {
  53. 'applies_to': ['txt2img', 'img2img', 'txt2vid'],
  54. 'label': 'IP-Adapter Image (base64)', 'type': 'string', 'default': '',
  55. 'description': 'A reference image whose style and subject guide the result, as base64. '
  56. 'Needs an IP-Adapter loaded alongside the model - see the Load Model node.',
  57. 'recommended': 'Take it from a Download or Fetch Output node rather than pasting one in.',
  58. 'category': 'image_input',
  59. },
  60. 'ip_adapter_strength': {
  61. 'applies_to': ['txt2img', 'img2img', 'txt2vid'],
  62. 'label': 'IP-Adapter Strength', 'type': 'number', 'default': 1.0,
  63. 'description': 'How strongly the reference image guides the result.',
  64. 'recommended': '1.0 is the upstream default. Lower it when the reference is overwhelming the prompt.',
  65. 'category': 'image_input',
  66. },
  67. 'ref_audios': {
  68. 'applies_to': ['txt2vid'],
  69. 'label': 'Reference Audios', 'type': 'array<string>', 'default': [],
  70. 'description': 'Reference audio as base64-encoded WAV, mono or stereo PCM (i16/i24/i32/f32).',
  71. 'recommended': 'For models that take audio guidance.',
  72. 'category': 'video',
  73. },
  74. 'ref_videos': {
  75. 'applies_to': ['txt2vid'],
  76. 'label': 'Reference Videos', 'type': 'array<object>', 'default': [],
  77. 'description': 'Reference videos. Each entry is an object with frames (base64 images), '
  78. 'fps (default 24) and an optional audio_wav_base64.',
  79. 'recommended': 'Built by an earlier node rather than typed.',
  80. 'category': 'video',
  81. },
  82. }
  83. # Fields a node takes that the reference files under a different endpoint. The
  84. # server documents ref_images and ref_image_args as txt2img fields, but the
  85. # image-edit node posts them to /img2img and the server accepts them - so they
  86. # stay, rather than a node losing a working feature to a documentation table.
  87. EXTRA_FOR = {
  88. 'sdcpp-edit': ['ref_images', 'ref_image_args'],
  89. }
  90. # Which group the connection settings go in, and its position.
  91. SERVER_GROUP = {'title': 'Server', 'fields': ['serverUrl', 'credentialId']}
  92. # Pressing the button asks the server which architecture the loaded model is and
  93. # writes that architecture's generation defaults into the form. Every preset
  94. # carries width, height, steps, cfg_scale, sampler and scheduler; a few carry
  95. # more. A field a preset does not mention is left alone rather than blanked.
  96. PREFILL_FIELDS = [
  97. 'width', 'height', 'steps', 'cfgScale', 'sampler', 'scheduler',
  98. 'cacheMode', 'distilledGuidance', 'flowShift', 'negativePrompt',
  99. 'videoFrames', 'fps',
  100. ]
  101. def camel(name: str) -> str:
  102. if name in KEEP_NAME:
  103. return KEEP_NAME[name]
  104. head, *rest = name.split('_')
  105. return head + ''.join(w[:1].upper() + w[1:] for w in rest)
  106. def js(value) -> str:
  107. """A JS literal. json.dumps is valid JS for everything used here."""
  108. return json.dumps(value, ensure_ascii=False)
  109. def describe(opt: dict) -> str:
  110. text = ' '.join(str(opt.get('description', '')).split())
  111. hint = ' '.join(str(opt.get('recommended', '')).split())
  112. if hint:
  113. text = f'{text} Recommended: {hint}' if text else f'Recommended: {hint}'
  114. # Every one of these is optional: the server fills an absent field from the
  115. # loaded model's architecture preset, which is almost always the right
  116. # answer and is not something the node can know.
  117. return f'{text} Leave empty for the architecture default.'
  118. def prop_for(name: str, opt: dict) -> dict:
  119. kind = opt.get('type')
  120. setting = camel(name)
  121. prop: dict = {'title': opt.get('label') or setting, 'description': describe(opt)}
  122. if kind == 'select':
  123. values = opt.get('values') or {}
  124. # The empty entry is what "leave it to the architecture" looks like in a
  125. # dropdown; without it a select cannot express "unset". Some of the
  126. # server's own value maps already carry one, so it is not added twice.
  127. keys = [k for k in values.keys() if k != '']
  128. prop['type'] = 'string'
  129. prop['enum'] = [''] + keys
  130. prop['enumLabels'] = ['(architecture default)'] + [
  131. ' '.join(str(values[k]).split())[:70] or k for k in keys
  132. ]
  133. prop['default'] = ''
  134. elif kind == 'boolean':
  135. prop['type'] = 'boolean'
  136. elif kind == 'number':
  137. prop['type'] = 'number'
  138. elif kind == 'array<string>':
  139. prop['type'] = 'array'
  140. prop['items'] = {'type': 'string'}
  141. elif kind == 'array<number>':
  142. prop['type'] = 'array'
  143. prop['items'] = {'type': 'number'}
  144. elif kind == 'array<object>':
  145. prop['type'] = 'array'
  146. prop['items'] = {'type': 'object'}
  147. else:
  148. prop['type'] = 'string'
  149. if name in ('prompt', 'negative_prompt'):
  150. prop['format'] = 'textarea'
  151. return prop
  152. def render_properties(props: dict, indent: str = ' ') -> str:
  153. out = []
  154. for key, prop in props.items():
  155. inner = ', '.join(f'{k}: {js(v)}' for k, v in prop.items())
  156. out.append(f'{indent}{key}: {{ {inner} }}')
  157. return ',\n'.join(out)
  158. def build(reference: dict, endpoint: str, extra: list) -> tuple:
  159. options = dict(reference['options'])
  160. categories = reference['categories']
  161. for name, described in UNDOCUMENTED.items():
  162. options[name] = {k: v for k, v in described.items() if k != 'category'}
  163. applicable = [
  164. (name, opt) for name, opt in options.items()
  165. if (endpoint in opt.get('applies_to', []) or name in extra) and name not in SKIP
  166. ]
  167. by_name = dict(applicable)
  168. props = {}
  169. table = []
  170. for name, opt in applicable:
  171. setting = camel(name)
  172. props[setting] = prop_for(name, opt)
  173. table.append((setting, name))
  174. # The undocumented fields belong in a group too, next to the ones they are
  175. # related to rather than dumped in "Other".
  176. extra_by_category: dict = {}
  177. for name, described in UNDOCUMENTED.items():
  178. if name in by_name:
  179. extra_by_category.setdefault(described['category'], []).append(name)
  180. groups = []
  181. for cat_key, cat in categories.items():
  182. fields = [camel(n) for n in list(cat['options']) + extra_by_category.get(cat_key, [])
  183. if n in by_name]
  184. if fields:
  185. groups.append({'title': cat['label'], 'fields': fields})
  186. # Core first, then the rest as the server lists them.
  187. groups.sort(key=lambda g: 0 if g['title'] == 'Core' else 1)
  188. return props, table, groups
  189. def rewrite(path: Path, endpoint: str, reference: dict) -> str:
  190. source = path.read_text()
  191. start = source.index('const configSchema')
  192. end = source.index('const inputSchema')
  193. props, table, groups = build(reference, endpoint, EXTRA_FOR.get(path.stem, []))
  194. # Node-owned settings that are not server generation options.
  195. tail_props = {
  196. 'title': {'type': 'string', 'title': 'Job Title',
  197. 'description': 'Optional label stored with the job, useful for finding it again in the queue'},
  198. 'extraOptions': {'type': 'object', 'title': 'Extra Options',
  199. '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'},
  200. 'timeout': {'type': 'number', 'title': 'Timeout (ms)',
  201. 'description': 'Applies to queueing the job, not to the render. The call returns as soon as the job is accepted',
  202. 'default': 30000},
  203. }
  204. ui_groups = [SERVER_GROUP] + groups + [{'title': 'Job', 'fields': ['title', 'extraOptions', 'timeout']}]
  205. prefill = {
  206. 'label': 'Take the architecture defaults',
  207. '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",
  208. 'node': 'sdcpp-architecture',
  209. 'needs': ['serverUrl', 'credentialId'],
  210. 'map': {f'defaults.{f}': f for f in PREFILL_FIELDS if f in props},
  211. }
  212. head = """const configSchema = {
  213. type: 'object',
  214. // Generated from the server's own reference at /options/generation - see
  215. // scripts/gen-sdcpp-generation-options.py. Every field the endpoint accepts
  216. // has a setting here, grouped the way the server groups them.
  217. uiGroups: %s,
  218. prefill: %s,
  219. properties: {
  220. serverUrl: {
  221. type: 'string', title: 'Server URL',
  222. description: 'Base address of the sdcpp-restapi server',
  223. default: 'http://localhost:8077'
  224. },
  225. credentialId: {
  226. type: 'string', title: 'Credential',
  227. description: 'A basic credential holding the sdcpp-restapi username and password',
  228. dynamicOptions: { source: 'credentials', filter: { type: ['sdcpp', 'basic'] } }
  229. },
  230. """ % (json.dumps(ui_groups, ensure_ascii=False, indent=8).replace('\n', '\n '),
  231. json.dumps(prefill, ensure_ascii=False, indent=8).replace('\n', '\n '))
  232. body = render_properties(props) + ',\n' + render_properties(tail_props)
  233. new_schema = head + body + "\n },\n required: ['credentialId']\n};\n\n"
  234. source = source[:start] + new_schema + source[end:]
  235. # The setting-to-field table the request is built from.
  236. rows = ',\n'.join(f" {{ setting: {js(s)}, server: {js(n)} }}" for s, n in table)
  237. table_js = (
  238. "// Every generation field the server documents, and the setting it comes\n"
  239. "// from. Generated alongside the schema above so the two cannot drift.\n"
  240. "const GENERATION_OPTIONS = [\n" + rows + "\n];\n"
  241. )
  242. marker = 'async function execute('
  243. if 'const GENERATION_OPTIONS' in source:
  244. source = re.sub(r'// Every generation field the server documents.*?\n\];\n',
  245. table_js, source, flags=re.S)
  246. else:
  247. source = source.replace(marker, table_js + '\n' + marker, 1)
  248. return source
  249. def main():
  250. server = sys.argv[1] if len(sys.argv) > 1 else 'http://localhost:8077'
  251. with urllib.request.urlopen(server.rstrip('/') + '/options/generation', timeout=30) as f:
  252. reference = json.loads(f.read().decode())
  253. root = Path(__file__).resolve().parent.parent / 'nodes' / 'sdcpp'
  254. for node, endpoint in NODES.items():
  255. path = root / f'{node}.js'
  256. path.write_text(rewrite(path, endpoint, reference))
  257. count = sum(1 for n, o in reference['options'].items()
  258. if endpoint in o.get('applies_to', []) or n in EXTRA_FOR.get(node, []))
  259. print(f'{node:18} {endpoint:8} {count} options')
  260. if __name__ == '__main__':
  261. main()