Back to Hermes Agent

DAT-Based Scripting Reference

skills/creative/touchdesigner-mcp/references/dat-scripting.md

2026.6.511.3 KB
Original Source

DAT-Based Scripting Reference

TD's event/callback model — Python that runs in response to network events. The full set of "Execute DATs" plus their idiomatic patterns.

For arbitrary Python execution (not callback-based), see python-api.md. For the MCP's td_execute_python tool, see mcp-tools.md.


The Execute DAT Family

Every type watches one kind of event source and fires Python on changes.

DATWatchesUse for
chopExecuteDATA CHOP's channel valuesAudio triggers, threshold callbacks, state machines on numeric input
datExecuteDATA DAT's content (table cells, text)Reacting to data updates from APIs, parsing webDAT responses
parameterExecuteDATA parameter's value or pulseReacting to user-changed params, custom pulse buttons
panelExecuteDATA panel COMP's interactionButton clicks, slider drags, field commits
opExecuteDATOperator lifecycleNew operator created, deleted, name changed
executeDATProject lifecycle, frame eventsRun-once setup, per-frame logic, save/load hooks

All have a docked DAT with predefined callback functions. You only fill in the bodies of the ones you care about.


chopExecuteDAT — Numeric Triggers

python
ce = root.create(chopExecuteDAT, 'kick_handler')
ce.par.chop = '/project1/audio/out_kick'      # source CHOP
ce.par.offtoon = True                          # fire when channel rises above 0
ce.par.ontooff = False
ce.par.whileon = False
ce.par.valuechange = False

In the docked callback DAT:

python
def offToOn(channel, sampleIndex, val, prev):
    """Channel went from 0 to non-zero. Classic beat trigger."""
    op('/project1/strobe').par.flash.pulse()
    op('/project1/scene').par.index = (op('/project1/scene').par.index + 1) % 8
    return

def onToOff(channel, sampleIndex, val, prev):
    """Channel went from non-zero to 0."""
    return

def whileOn(channel, sampleIndex, val, prev):
    """Fires every frame while channel is non-zero. Use sparingly."""
    return

def valueChange(channel, sampleIndex, val, prev):
    """Fires every frame the value changes (continuous). Heavy."""
    return

channel is a Channel object — .name, .owner, .vals[]. Use channel.name == 'chan1' to filter.

Threshold-based custom triggers: wire the source CHOP through a triggerCHOP first to get clean 0/1 pulses, then watch with offtoon.


datExecuteDAT — Table/Text Changes

python
de = root.create(datExecuteDAT, 'api_response')
de.par.dat = '/project1/api/web1'              # source DAT
de.par.tablechange = True                      # any cell change
de.par.cellchange = False
de.par.rowchange = False
de.par.colchange = False
python
def onTableChange(dat):
    """Whole table changed (including text DAT content updates)."""
    if dat.numRows == 0:
        return
    # If it's a webDAT response, parse JSON
    import json
    try:
        data = json.loads(dat.text)
    except json.JSONDecodeError:
        debug(f'Bad JSON: {dat.text[:100]}')
        return
    # Write to a CHOP
    op('/project1/api_value').par.value0 = float(data.get('count', 0))
    return

def onCellChange(dat, cells, prev):
    """Specific cells changed."""
    for cell in cells:
        # cell.row, cell.col, cell.val
        pass
    return

debug() prints to the textport — readable via td_read_textport.


parameterExecuteDAT — Param Changes & Pulse

python
pe = root.create(parameterExecuteDAT, 'comp_params')
pe.par.op = '/project1/my_component'           # COMP whose params to watch
pe.par.parameters = '*'                         # or specific names like 'Intensity Reset'
pe.par.valuechange = True
pe.par.pulse = True
python
def onValueChange(par, prev):
    """par is a Par object. par.name, par.eval(), par.owner."""
    if par.name == 'Intensity':
        op('/project1/bloom').par.threshold = par.eval()
    return

def onPulse(par):
    """Pulse param was triggered."""
    if par.name == 'Reset':
        op('/project1/scene').par.index = 0
        op('/project1/audio_player').par.cuepoint = 0
        op('/project1/audio_player').par.cuepulse.pulse()
    return

def onExpressionChange(par, val, prev):
    """User changed the expression on a param."""
    return

def onExportChange(par, val, prev):
    """Export source changed."""
    return

def onModeChange(par, val, prev):
    """Param mode changed (CONSTANT / EXPRESSION / EXPORT / etc)."""
    return

panelExecuteDAT — UI Events

For interactive control surfaces. See panel-ui.md for the full panel COMP context.

python
pe = root.create(panelExecuteDAT, 'btn_handler')
pe.par.panel = '/project1/play_btn'
pe.par.click = True              # mouse click events
pe.par.value = True              # state changes (toggle)
pe.par.lockedchange = False
python
def onOffToOn(panelValue):
    """Panel value rose to 1 (button pressed, slider crossed threshold)."""
    op('/project1/scene_timer').par.start.pulse()
    return

def onOnToOff(panelValue):
    """Panel value dropped to 0."""
    return

def onValueChange(panelValue):
    """Continuous: every frame the value changes."""
    val = panelValue.eval()
    op('/project1/master').par.opacity = val
    return

def onClick(panelValue):
    """Discrete click event, fires once per click."""
    return

panelValue is a Par object on the panel COMP.


opExecuteDAT — Operator Lifecycle

Watches creation/deletion/renaming of operators in a parent COMP.

python
oe = root.create(opExecuteDAT, 'lifecycle')
oe.par.op = '/project1'
oe.par.create = True
oe.par.destroy = True
oe.par.namechange = True
oe.par.flagchange = False
python
def onCreate(opCreated):
    """A new operator was created. Useful for auto-applying conventions."""
    if opCreated.OPType == 'glslTOP':
        # Always wrap with a null
        n = opCreated.parent().create(nullTOP, opCreated.name + '_out')
        n.inputConnectors[0].connect(opCreated)
    return

def onDestroy(opDestroyed):
    """Operator was deleted. opDestroyed.path is still valid for one frame."""
    return

def onNameChange(opChanged):
    """Operator was renamed."""
    return

Useful for dev-time scaffolding (auto-create downstream nullTOPs, auto-name conventions). Disable in production projects to avoid surprise side effects.


executeDAT — Project Lifecycle & Per-Frame

The catch-all. Gets you hooks into project start, save, load, frame-start, frame-end.

python
exec_dat = root.create(executeDAT, 'lifecycle')
exec_dat.par.start = True
exec_dat.par.create = True
exec_dat.par.framestart = True
exec_dat.par.frameend = False
python
def onStart():
    """Project just started cooking. Run once."""
    op('/project1/scene').par.index = 0
    debug('Project started')
    return

def onCreate():
    """Component was just created (only fires for component executeDATs, not project root)."""
    return

def onFrameStart(frame):
    """Per-frame, BEFORE network cooks. Heavy logic here = bottleneck."""
    return

def onFrameEnd(frame):
    """Per-frame, AFTER network cooks. Use for capture, recording, post-network logic."""
    return

def onPlayStateChange(playing):
    """Project play/pause toggled."""
    return

def onProjectPreSave():
    """Right before saving the .toe file."""
    return

def onProjectPostSave():
    return

Heavy per-frame logic in onFrameStart is one of the top performance regressions in TD projects. Use CHOPs for per-frame computation, scripts for events.


Pattern: Triggering an Animation Sequence on Beat

python
# Source: a kick trigger CHOP
# Goal: on each kick, run a 1.5s scale pulse + color flash

# Setup (create once)
animator = root.create(timerCHOP, 'pulse_anim')
animator.par.length = 1.5
animator.par.cycle = False

# Param expressions on visual targets:
op('logo').par.sx.expr = "1.0 + (1 - op('pulse_anim')['timer_fraction']) * 0.3"
op('logo').par.sx.mode = ParMode.EXPRESSION
op('logo').par.sy.expr = "1.0 + (1 - op('pulse_anim')['timer_fraction']) * 0.3"
op('logo').par.sy.mode = ParMode.EXPRESSION

# In a chopExecuteDAT watching the kick CHOP:
def offToOn(channel, sampleIndex, val, prev):
    op('pulse_anim').par.start.pulse()
    return

Pattern: Live Editing a CHOP from API Data

python
# webDAT polls an API every 5 seconds
# datExecuteDAT parses the response and writes to a constantCHOP

def onTableChange(dat):
    import json
    try:
        data = json.loads(dat.text)
    except:
        return
    target = op('/project1/external_state')
    target.par.name0 = 'temperature'
    target.par.value0 = float(data['temp_c'])
    target.par.name1 = 'humidity'
    target.par.value1 = float(data['humidity'])
    return

Visuals just reference op('external_state')['temperature'] — they update live.


Pattern: Self-Cleaning Network

python
# An opExecuteDAT watching for orphaned helper ops, deleting them after their parent disappears

def onDestroy(opDestroyed):
    parent_name = opDestroyed.name
    helper = op(f'/project1/{parent_name}_helper')
    if helper:
        helper.destroy()
    return

Pitfalls

  1. Callbacks crash silently — exceptions print to the textport but don't show up in the UI. Always td_clear_textport before debugging, then td_read_textport after.
  2. debug() vs print() — both write to textport, but debug() includes the file/line of the calling DAT. Prefer debug() for scripts.
  3. val is the new value, prev is old — easy to swap. Always: def offToOn(channel, sampleIndex, val, prev). Check parameter order in TD docs if confused.
  4. whileOn and valueChange are per-frame — heavy. Avoid unless absolutely needed. Drive via expressions instead.
  5. Callbacks don't run during cooking-paused state — if the parent COMP has allowCooking=False, callbacks freeze. Useful for "disable me" toggles.
  6. par vs panelValue — parameterExecuteDAT gives par (a Par object), panelExecuteDAT gives panelValue (also a Par-like object). Both have .name and .eval() but their context differs.
  7. opExecuteDAT fires for itself — when you create an opExecuteDAT, it can fire onCreate for itself if par.create=True and parent matches. Filter by if opCreated == me: return.
  8. Reload behavior — when reloading an extension (td_reinit_extension), all callback DATs reset their internal state. Module-level vars are lost. Persist state in tableDATs or the docked DAT itself, not in module globals.
  9. Cooking dependencies — if a callback writes to an op that's upstream of the callback's source, you get a cooking loop. TD warns about it but doesn't always block. Keep dataflow one-directional.
  10. Active flag — every Execute DAT has par.active. False = silent. Easy to toggle for testing without deleting wiring.

Quick Recipes

GoalSetup
Beat triggerchopExecuteDAT.par.offtoon=True watching a triggerCHOP
API response handlerdatExecuteDAT.par.tablechange=True watching a webDAT
Custom button → actionparameterExecuteDAT.par.pulse=True watching a custom pulse param
Slider → continuous parampanelExecuteDAT.par.value=True watching a sliderCOMP
Run-once setupexecuteDAT.par.start=True with logic in onStart()
Per-frame metricsexecuteDAT.par.frameend=True recording values to a CHOP
Auto-name new opsopExecuteDAT.par.create=True enforcing naming conventions