LLMSkillHub
mirroredCopied from dylanroscover/Embody under MIT. dylanroscover has not claimed this page, and is not affiliated with LLM SkillHub.

dylanroscover / parameter-design

MUST READ before creating or designing custom parameters on any COMP: pages, styles, ranges, help text, naming, ParGroup gotchas.

0
SKILL.md
10.0 KB

<!-- Generated by Embody/Envoy - Do not remove this comment - sha:5454b92f34285f1a -->

Parameter Design

Ownership and Lifecycle

Code owns the schema; the user owns the value. A parameter's name, style, page, range, default and help text are yours to declare and re-assert. Its VALUE is the user's, and nothing you write may quietly discard it.

Extensions reinitialize on every source-file save, so every creation path must be get-or-create, never create-blindly:

def ensureCustomPage(comp, name):
    for page in comp.customPages:
        if page.name == name:
            return page
    return comp.appendCustomPage(name)

def ensureCustomPar(comp, page, name, style, **attrs):
    """Get-or-create one custom par: the Par, or the ParGroup for a tuplet."""
    # Probe the WHOLE COMP by tupletName. Names are a flat per-COMP namespace,
    # and a multi-value style (RGB, XYZ) is stored as <name>r/<name>g/<name>b,
    # so a probe by .name never finds it and append*() (replace=True) would
    # re-create it on every reinit.
    found = [p for p in comp.customPars if p.tupletName == name]
    if not found:
        getattr(page, 'append' + style)(name)
        found = [p for p in comp.customPars if p.tupletName == name]
    elif found[0].style not in STYLE_FAMILY.get(style, (style,)):
        raise ValueError('par %s is %s, declared %s -- refusing to replace it'
                         % (name, found[0].style, style))
    for p in found:                          # symmetric: schema attrs re-applied
        for key, value in attrs.items():     # on every reinit; the user's state
            if key not in ('val', 'expr', 'bindExpr', 'mode', 'export'):
                setattr(p, key, value)       # is never touched -- and a typo RAISES
    return found[0] if len(found) == 1 else found[0].parGroup

# TD reports a tuplet's style by family: RGB reads 'RGBA', XY/XYZ read 'XYZW'.
STYLE_FAMILY = {'RGB': ('RGB', 'RGBA'), 'XY': ('XY', 'XYZW'), 'XYZ': ('XYZ', 'XYZW')}

Four rules that make it safe:

  • Probe the whole COMP, not the page. Custom parameter names are a flat per-COMP namespace, and append*() defaults to replace=True: a page-scoped probe will happily destroy a same-named par living on another page.
  • Re-read after appending, by tupletName. append*() returns a ParGroup, and a multi-value style is stored as component pars (Tintr/Tintg/Tintb) whose tupletName is the name you declared; comp.par['Tint'] is None. Embody ships this exact helper as op.Embody.op('embody_pardef').module.ensureCustomPar.
  • Never destroy() on style drift. Par.destroy() takes the user's value, expressions and exports with it. Report the mismatch and refuse; removal is a separate, deliberate act.
  • Apply declared attributes symmetrically. If you set min when creating, set it when the par already exists too, or a schema change never reaches existing installs.

Parameter Callbacks

One dispatcher, not one promoted method per parameter. A parexec DAT that grows an elif par.name == ... chain drags a public-tier method onto the COMP for every branch (the three tiers in td-python.md). Name the handler for its parameter and let the DAT find it:

# the WHOLE onValueChange in the parexec DAT
def onValueChange(par, prev):
    ext = parent.MyComp.ext.MyExt
    handler = getattr(ext, '_on%sValueChange' % par.name, None)
    if handler is None:
        return
    try:
        handler(par, prev)
    except Exception as e:
        parent.MyComp.Error('%s handler failed: %s' % (par.name, e))
# on the extension: tier 3, invisible on the COMP
def _onSpeedValueChange(self, par, prev):
    self.ownerComp.op('./timer1').par.speed = par.eval()

def _onResetPulse(self, par):
    self.rebuild()
  • Audit once at init that every _on* handler matches a real parameter: a renamed par turns its handler into dead code that fails no test; the audit turns that into a warning.
  • Never swallow the exception silently: a handler that raises into nothing is indistinguishable from a parameter that does nothing.
  • Pulse handlers take (par); value handlers take (par, prev). The Parameter Execute DAT ships with builtin ON as well as custom: turn builtin off for a custom-parameter dispatcher (/operator-gotchas).

Parameter, Storage, or Dependency?

Custom parameters are one of three ways to hold state on a COMP, and the choice is about lifetime, not taste:

The state isUseLives onSurvives an extension reinitSurvives a tox reload / TDXN rebuild
User-facing, persisted, part of the COMP's interfacecustom parameterthe COMPyesyes
Durable bookkeeping the user should not seestorage (write through store())the COMPyesno (wiped)
A derived value that must recook whatever reads ittdu.Dependencythe extension instanceno (rebuild in __init__)no

Publish once and let readers pull; never push a value into each consumer. comp.storage[k] = v, in-place mutation of a stored container, and a plain self.attr all update the value and notify nobody; store() and dep.val = x notify. fetch() without a default RAISES, fetch() searches UP the parents, unstore() is glob-matched, and a bind expression to a Dependency is two-way. The measured tables, the storage pickling rules and the Dependency traps are in references/state-lifetimes.md in this skill's folder (get_guidance(topic='parameter-design/state-lifetimes')).

Help Text

Every custom parameter MUST have help text set via par.help = "...". It appears as the tooltip when users hover the parameter name. Describe what the parameter controls and what its values mean.

  • Good: "Maximum number of rows displayed in the manager list. Set to 0 for unlimited."
  • Bad: "Max rows" (restates the label)
  • Unacceptable: no help text at all

In TDXN files, "help": "..." rides in the parameter definition; Embody exports and imports it.

Section Breaks, Ordering, Pages

  • par.startSection = True on the first parameter of each logical group draws a separator above it ("startSection": true in TDXN).
  • Parameters appear in the order appended: primary controls first, secondary and advanced after, read-only status last. Reorder with par.order (floats such as 11.5 insert between neighbours).
  • Group into pages by function with comp.appendCustomPage('PageName') (creation order). Common pattern: Main or Settings, Tags, UI, About (read-only version, build, author).

Naming and Styles

Names start with one uppercase letter, then lowercase letters and digits only (Speed, Maxrows, Autosave, Envoyenable); no underscores, spaces or special characters.

Use caseStyleNotes
On/off toggleToggleValues are 0/1
Fire-once actionPulseNo persistent value
Enumerated choicesMenuSet menuNames and menuLabels separately
Editable dropdownStrMenuFree text with suggestions
Numeric valueFloat or IntSet the range properties below
Text inputStrFree-form string
File or folder pathFile / FolderOpens a system dialog
Operator referenceOP, COMP, TOP, CHOP, SOP, DAT, MATFiltered by family
Section headerHeaderVisual label only, no value

Numeric Ranges, Read-Only, Defaults

min/max are the limits, clampMin/clampMax decide whether they are enforced, normMin/normMax are what the slider covers visually:

p = page.appendFloat('Speed', label='Speed')[0]
p.default = 1.0
p.min = 0.0; p.max = 10.0
p.clampMin = True; p.clampMax = False    # values above 10 allowed by typing
p.normMin = 0.0; p.normMax = 5.0         # slider covers 0-5
p.help = "Playback speed multiplier. 1.0 = normal speed."
  • par.readOnly = True for status and informational parameters (version, build, connection state).
  • Always set par.default: it enables Revert to Default and keeps TDXN round-trips consistent. .default never sets the value: after appendFloat + p.default = 5.0 the par still evaluates 0.0, and a later min/clampMin clamps it to the min, not the default (verified 2025.33230, issue #94). Set p.val = p.default on the CREATE branch of get-or-create only, so a user's value is never overwritten.
  • All page.append*() methods return a ParGroup: index with [0] for the Par.

Sequence Parameters

Resizable blocks (glslTOP uniforms, constantCHOP channels, mathmixPOP combines, custom sequences). Function Store's note on issue #94, "building sequence pars takes a bit more thinking than it should", is fair: three things are easy to get wrong.

  • Reach the sequence, not the parameter. comp.seq.<name> from the operator, or par.sequence from any block parameter. The individual parameters are named <seq><index><par> (uniname0, uniname1); never build those names by hand when the sequence object is there.
  • numBlocks is the get-or-create. Assigning it grows or shrinks in one step and is idempotent, which is what an extension that reinitializes on every save needs. insertBlock(i) / destroyBlock(i) only when position matters.
seq = comp.seq.uni
seq.numBlocks = max(seq.numBlocks, len(uniforms))   # grow, never shrink blindly
for i, u in enumerate(uniforms):
    seq[i].par.uniname = u.name                     # block i, by index
  • Shrinking destroys values. destroyBlock or lowering numBlocks takes the block's values, expressions and exports with it, exactly like Par.destroy(). Code owns the block COUNT only where the count is genuinely derived; if the user can add blocks, grow to fit and leave the excess alone.
  • In Embody's TDXN, sequences are stored by BASE name (sequences: keyed by sequence name, blocks holding only non-default values under uniname, not uniname0); the default block count is probed from a throwaway instance, and blocks are created in import Phase 2.5 before Phase 3 sets parameters. Full detail: docs/tdxn/specification.md.