gen-sdcpp-generation-options.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  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. # The values map for cache_mode lists two modes; the field's own description in
  52. # the schema names six. Neither source is machine-readable and complete, so the
  53. # list is written here from the description, with a note of where it came from.
  54. CACHE_MODES = {
  55. 'easycache': 'EasyCache - single threshold, simple',
  56. 'ucache': 'UCache',
  57. 'dbcache': 'DBCache',
  58. 'taylorseer': 'TaylorSeer',
  59. 'cache_dit': 'Cache-DiT',
  60. 'spectrum': 'Spectrum - frequency-domain analysis (best quality/speed tradeoff)',
  61. }
  62. UNDOCUMENTED = {
  63. 'ip_adapter_image_base64': {
  64. 'applies_to': ['txt2img', 'img2img', 'txt2vid'],
  65. 'label': 'IP-Adapter Image (base64)', 'type': 'string', 'default': '',
  66. 'description': 'A reference image whose style and subject guide the result, as base64. '
  67. 'Needs an IP-Adapter loaded alongside the model - see the Load Model node.',
  68. 'recommended': 'Take it from a Download or Fetch Output node rather than pasting one in.',
  69. 'category': 'image_input',
  70. },
  71. 'ip_adapter_strength': {
  72. 'applies_to': ['txt2img', 'img2img', 'txt2vid'],
  73. 'label': 'IP-Adapter Strength', 'type': 'number', 'default': 1.0,
  74. 'description': 'How strongly the reference image guides the result.',
  75. 'recommended': '1.0 is the upstream default. Lower it when the reference is overwhelming the prompt.',
  76. 'category': 'image_input',
  77. },
  78. 'ref_audios': {
  79. 'applies_to': ['txt2vid'],
  80. 'label': 'Reference Audios', 'type': 'array<string>', 'default': [],
  81. 'description': 'Reference audio as base64-encoded WAV, mono or stereo PCM (i16/i24/i32/f32).',
  82. 'recommended': 'For models that take audio guidance.',
  83. 'category': 'video',
  84. },
  85. 'ref_videos': {
  86. 'applies_to': ['txt2vid'],
  87. 'label': 'Reference Videos', 'type': 'array<object>', 'default': [],
  88. 'description': 'Reference videos. Each entry is an object with frames (base64 images), '
  89. 'fps (default 24) and an optional audio_wav_base64.',
  90. 'recommended': 'Built by an earlier node rather than typed.',
  91. 'category': 'video',
  92. },
  93. }
  94. # Fields a node takes that the reference files under a different endpoint. The
  95. # server documents ref_images and ref_image_args as txt2img fields, but the
  96. # image-edit node posts them to /img2img and the server accepts them - so they
  97. # stay, rather than a node losing a working feature to a documentation table.
  98. EXTRA_FOR = {
  99. 'sdcpp-edit': ['ref_images', 'ref_image_args'],
  100. }
  101. # Which group the connection settings go in, and its position.
  102. SERVER_GROUP = {'title': 'Server', 'fields': ['serverUrl', 'credentialId']}
  103. # Pressing the button asks the server which architecture the loaded model is and
  104. # writes that architecture's generation defaults into the form. Every preset
  105. # carries width, height, steps, cfg_scale, sampler and scheduler; a few carry
  106. # more. A field a preset does not mention is left alone rather than blanked.
  107. PREFILL_FIELDS = [
  108. 'width', 'height', 'steps', 'cfgScale', 'sampler', 'scheduler',
  109. 'cacheMode', 'distilledGuidance', 'flowShift', 'negativePrompt',
  110. 'videoFrames', 'fps',
  111. ]
  112. def camel(name: str) -> str:
  113. if name in KEEP_NAME:
  114. return KEEP_NAME[name]
  115. head, *rest = name.split('_')
  116. return head + ''.join(w[:1].upper() + w[1:] for w in rest)
  117. def js(value) -> str:
  118. """A JS literal. json.dumps is valid JS for everything used here."""
  119. return json.dumps(value, ensure_ascii=False)
  120. # The server writes its descriptions with em-dashes. They are shown to people
  121. # in our editor, where the house style is a plain hyphen, and the meaning is
  122. # identical - so they are normalised on the way in rather than every reader
  123. # meeting two conventions in one form.
  124. def plain(text: str) -> str:
  125. return str(text).replace('\u2014', '-').replace('\u2013', '-')
  126. def describe(opt: dict) -> str:
  127. text = ' '.join(plain(opt.get('description', '')).split())
  128. hint = ' '.join(plain(opt.get('recommended', '')).split())
  129. if hint:
  130. text = f'{text} Recommended: {hint}' if text else f'Recommended: {hint}'
  131. # Every one of these is optional: the server fills an absent field from the
  132. # loaded model's architecture preset, which is almost always the right
  133. # answer and is not something the node can know.
  134. return f'{text} Leave empty for the architecture default.'
  135. def openapi_enums(spec: dict) -> dict:
  136. """The values each generation field accepts, from the request schema.
  137. /options/generation carries a values map with nice labels, and it has
  138. drifted: it lists 15 samplers spelled dpmpp2m where the server accepts 21
  139. spelled dpm++2m. The OpenAPI schema is generated from the running build and
  140. had them all, so it decides what the values are; the values map is only
  141. consulted for wording.
  142. This matters more than a missing entry in a dropdown. The server answers 202
  143. to any sampler name at all - including one that is simply wrong - and
  144. silently falls back to a default, so a misspelt value produces a different
  145. image with nothing to say so.
  146. """
  147. enums = {}
  148. schemas = spec.get('components', {}).get('schemas', {})
  149. for schema in schemas.values():
  150. for field, described in (schema.get('properties') or {}).items():
  151. values = described.get('enum')
  152. if values:
  153. enums.setdefault(field, list(values))
  154. return enums
  155. def prop_for(name: str, opt: dict) -> dict:
  156. kind = opt.get('type')
  157. setting = camel(name)
  158. prop: dict = {'title': plain(opt.get('label') or setting), 'description': describe(opt)}
  159. if kind == 'select':
  160. values = opt.get('values') or {}
  161. authoritative = OPENAPI_ENUMS.get(name)
  162. if authoritative:
  163. # Keep the wording from the values map where there is any, but the
  164. # list itself comes from the schema.
  165. values = {v: values.get(v, v) for v in authoritative}
  166. elif name == 'cache_mode':
  167. values = dict(CACHE_MODES)
  168. # The empty entry is what "leave it to the architecture" looks like in a
  169. # dropdown; without it a select cannot express "unset". Some of the
  170. # server's own value maps already carry one, so it is not added twice.
  171. keys = [k for k in values.keys() if k != '']
  172. prop['type'] = 'string'
  173. prop['enum'] = [''] + keys
  174. prop['enumLabels'] = ['(architecture default)'] + [
  175. ' '.join(plain(values[k]).split())[:70] or k for k in keys
  176. ]
  177. prop['default'] = ''
  178. # For most of these, empty means "say nothing and let the loaded
  179. # architecture decide". For cache_mode the server also uses empty to
  180. # mean "off" - so leaving it empty is indistinguishable from not
  181. # choosing, and the preset wins. Z-Image's preset turns caching on,
  182. # which is why it could not be switched off. A separate entry says it
  183. # outright, and the body builder sends an explicit empty for it.
  184. if name == 'cache_mode':
  185. prop['enum'] = ['', 'off'] + keys
  186. prop['enumLabels'] = [
  187. '(architecture default, which may switch it on)',
  188. 'Off - no caching, whatever the architecture prefers',
  189. ] + [' '.join(plain(values[k]).split())[:70] or k for k in keys]
  190. elif kind == 'boolean':
  191. prop['type'] = 'boolean'
  192. elif kind == 'number':
  193. prop['type'] = 'number'
  194. elif kind == 'array<string>':
  195. prop['type'] = 'array'
  196. prop['items'] = {'type': 'string'}
  197. elif kind == 'array<number>':
  198. prop['type'] = 'array'
  199. prop['items'] = {'type': 'number'}
  200. elif kind == 'array<object>':
  201. prop['type'] = 'array'
  202. prop['items'] = {'type': 'object'}
  203. else:
  204. prop['type'] = 'string'
  205. if name in ('prompt', 'negative_prompt'):
  206. prop['format'] = 'textarea'
  207. return prop
  208. def render_properties(props: dict, indent: str = ' ') -> str:
  209. out = []
  210. for key, prop in props.items():
  211. inner = ', '.join(f'{k}: {js(v)}' for k, v in prop.items())
  212. out.append(f'{indent}{key}: {{ {inner} }}')
  213. return ',\n'.join(out)
  214. def build(reference: dict, endpoint: str, extra: list) -> tuple:
  215. options = dict(reference['options'])
  216. categories = reference['categories']
  217. for name, described in UNDOCUMENTED.items():
  218. options[name] = {k: v for k, v in described.items() if k != 'category'}
  219. applicable = [
  220. (name, opt) for name, opt in options.items()
  221. if (endpoint in opt.get('applies_to', []) or name in extra) and name not in SKIP
  222. ]
  223. by_name = dict(applicable)
  224. props = {}
  225. table = []
  226. for name, opt in applicable:
  227. setting = camel(name)
  228. props[setting] = prop_for(name, opt)
  229. table.append((setting, name))
  230. # The undocumented fields belong in a group too, next to the ones they are
  231. # related to rather than dumped in "Other".
  232. extra_by_category: dict = {}
  233. for name, described in UNDOCUMENTED.items():
  234. if name in by_name:
  235. extra_by_category.setdefault(described['category'], []).append(name)
  236. groups = []
  237. for cat_key, cat in categories.items():
  238. fields = [camel(n) for n in list(cat['options']) + extra_by_category.get(cat_key, [])
  239. if n in by_name]
  240. if fields:
  241. groups.append({'title': cat['label'], 'fields': fields})
  242. # Core first, then the rest as the server lists them.
  243. groups.sort(key=lambda g: 0 if g['title'] == 'Core' else 1)
  244. return props, table, groups
  245. def rewrite(path: Path, endpoint: str, reference: dict) -> str:
  246. source = path.read_text()
  247. start = source.index('const configSchema')
  248. end = source.index('const inputSchema')
  249. props, table, groups = build(reference, endpoint, EXTRA_FOR.get(path.stem, []))
  250. # Node-owned settings that are not server generation options.
  251. tail_props = {
  252. 'title': {'type': 'string', 'title': 'Job Title',
  253. 'description': 'Optional label stored with the job, useful for finding it again in the queue'},
  254. 'extraOptions': {'type': 'object', 'title': 'Extra Options',
  255. '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'},
  256. 'timeout': {'type': 'number', 'title': 'Timeout (ms)',
  257. 'description': 'Applies to queueing the job, not to the render. The call returns as soon as the job is accepted',
  258. 'default': 30000},
  259. }
  260. ui_groups = [SERVER_GROUP] + groups + [{'title': 'Job', 'fields': ['title', 'extraOptions', 'timeout']}]
  261. prefill = {
  262. 'label': 'Take the architecture defaults',
  263. '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",
  264. 'node': 'sdcpp-architecture',
  265. 'needs': ['serverUrl', 'credentialId'],
  266. 'map': {f'defaults.{f}': f for f in PREFILL_FIELDS if f in props},
  267. }
  268. head = """const configSchema = {
  269. type: 'object',
  270. // Generated from the server's own reference at /options/generation - see
  271. // scripts/gen-sdcpp-generation-options.py. Every field the endpoint accepts
  272. // has a setting here, grouped the way the server groups them.
  273. uiGroups: %s,
  274. prefill: %s,
  275. properties: {
  276. serverUrl: {
  277. type: 'string', title: 'Server URL',
  278. description: 'Base address of the sdcpp-restapi server',
  279. default: 'http://localhost:8077'
  280. },
  281. credentialId: {
  282. type: 'string', title: 'Credential',
  283. description: 'A basic credential holding the sdcpp-restapi username and password',
  284. dynamicOptions: { source: 'credentials', filter: { type: ['sdcpp', 'basic'] } }
  285. },
  286. """ % (json.dumps(ui_groups, ensure_ascii=False, indent=8).replace('\n', '\n '),
  287. json.dumps(prefill, ensure_ascii=False, indent=8).replace('\n', '\n '))
  288. body = render_properties(props) + ',\n' + render_properties(tail_props)
  289. new_schema = head + body + "\n },\n required: ['credentialId']\n};\n\n"
  290. source = source[:start] + new_schema + source[end:]
  291. # What the server says each choice-field accepts. Emitted because the
  292. # server does not check: it answers 202 to any sampler name at all,
  293. # including one that is simply a typo, and silently falls back to a default.
  294. # A generation that quietly used a different sampler than the one asked for
  295. # is not something anyone would notice from the result.
  296. choice_rows = []
  297. for name, opt in sorted(reference['options'].items()):
  298. if opt.get('type') != 'select' or (endpoint not in opt.get('applies_to', [])
  299. and name not in EXTRA_FOR.get(path.stem, [])):
  300. continue
  301. allowed = [k for k in (opt.get('values') or {}).keys() if k != '']
  302. choice_rows.append(f" {js(camel(name))}: {js(allowed)}")
  303. choices_js = ("// What the server accepts for each choice, as it described them when this\n"
  304. "// file was generated. See scripts/gen-sdcpp-generation-options.py.\n"
  305. "const KNOWN_CHOICES = {\n" + ",\n".join(choice_rows) + "\n};\n")
  306. if 'const KNOWN_CHOICES' in source:
  307. source = re.sub(r'// What the server accepts for each choice.*?\n\};\n',
  308. choices_js, source, flags=re.S)
  309. else:
  310. source = source.replace('async function execute(', choices_js + '\n' + 'async function execute(', 1)
  311. # The setting-to-field table the request is built from.
  312. rows = ',\n'.join(f" {{ setting: {js(s)}, server: {js(n)} }}" for s, n in table)
  313. table_js = (
  314. "// Every generation field the server documents, and the setting it comes\n"
  315. "// from. Generated alongside the schema above so the two cannot drift.\n"
  316. "const GENERATION_OPTIONS = [\n" + rows + "\n];\n"
  317. )
  318. marker = 'async function execute('
  319. if 'const GENERATION_OPTIONS' in source:
  320. source = re.sub(r'// Every generation field the server documents.*?\n\];\n',
  321. table_js, source, flags=re.S)
  322. else:
  323. source = source.replace(marker, table_js + '\n' + marker, 1)
  324. return source
  325. OPENAPI_ENUMS: dict = {}
  326. def main():
  327. global OPENAPI_ENUMS
  328. server = sys.argv[1] if len(sys.argv) > 1 else 'http://localhost:8077'
  329. with urllib.request.urlopen(server.rstrip('/') + '/options/generation', timeout=30) as f:
  330. reference = json.loads(f.read().decode())
  331. # Both are unauthenticated. The schema decides what a field accepts; the
  332. # reference above decides what it is called, what it does and where it
  333. # belongs.
  334. with urllib.request.urlopen(server.rstrip('/') + '/openapi.json', timeout=30) as f:
  335. OPENAPI_ENUMS = openapi_enums(json.loads(f.read().decode()))
  336. print(f'{len(OPENAPI_ENUMS)} field(s) have an authoritative list in the schema')
  337. root = Path(__file__).resolve().parent.parent / 'nodes' / 'sdcpp'
  338. for node, endpoint in NODES.items():
  339. path = root / f'{node}.js'
  340. path.write_text(rewrite(path, endpoint, reference))
  341. count = sum(1 for n, o in reference['options'].items()
  342. if endpoint in o.get('applies_to', []) or n in EXTRA_FOR.get(node, []))
  343. print(f'{node:18} {endpoint:8} {count} options')
  344. if __name__ == '__main__':
  345. main()