Gotchas
Перевод английской документации от 29.09·English
Every td_ name here is an MCP tool the agent calls. Every fix below is something the agent does itself.
Every entry here was hit while building a real composition. Each one produced no error, no warning, and no visible sign that anything was wrong.
td_health prints each finding with a short kind in brackets, such as [WARN ] stalled or [WARN ] not-pulled. Every kind it can print is named below, so search for the kind directly. A test holds that, and a new detector cannot arrive without an entry.
#A branch nothing consumes never runs
TouchDesigner cooks on demand. An operator is only pulled if a viewer, a render chain, an output device or an export needs it. A network you build programmatically has no viewer on it, so it does not run at all. td_health calls this not-pulled, and reports it when an operator did not cook once across the two samples and had something new to compute when the check asked it to cook — twice, 0.2 s apart. An operator that did not cook because nothing it depends on changes (a Ramp TOP with fixed settings, a Text DAT, a material) is reported as the note static: that is right, not a fault. Three agent sessions read the old single [ERROR] on such operators as breakage. The same probe names never-cooked (nothing has asked for it since it was created; a first cook can be expensive, so it is not asked), quiet-outputs (a Movie File Out or another output, never asked to cook because a cook could write or send something), and not-cooking-unprobed (the check's half-second of cooking ran out). not-cooking is what is left when the probe could not run at all: static and unpulled are then not told apart.
The probe cooks what it asks, as a viewer glancing at the node would. An operator that keeps state (Speed, Trigger, Count) moves on by the time since its last cook; reset those before a recording in any case.
Symptom. Renders work (they force a cook), but feedback trails never accumulate, animation is frozen between calls, and a Movie File Out writes nothing while reporting no error.
Diagnosis. td_health compares each operator's cook count against the frame clock and names the dormant ones. A freshly built noise → blur pair reports 0/2 operators cooking. Measured 2026-09-29 on the cubes network loaded from its .tox with recording off: 9 operators not-pulled (the whole time-driven chain), 9 static (labels, shader DATs, the material), the Movie File Out under quiet-outputs.
Fix. Put a cacheTOP with alwayscook on at the end of the chain, pulling whatever must stay live. nullTOP has no such parameter. cacheTOP, touchoutTOP and webrenderTOP do, as do many CHOPs under the name cookalways, and td_search_parameters("cook every frame") lists them.
{"method": "op_create", "params": {
"parent": "/project1/AV", "type": "cacheTOP", "name": "keepalive",
"pars": {"alwayscook": true, "cachesize": 1},
"connect": [{"from": "/project1/AV/out", "index": 0}]}}
Adding that turns the same network into 3/3 operators cooking.
A Trail CHOP is the case that costs the most time. Hung off the end of a branch to watch a signal, it is terminal, so it never cooks, and every value read from it is a constant. Calibrating against that measures nothing.
A recorder must be inside what the keep-alive pulls. Wire out → rec → keepalive, not out → keepalive with rec hanging off out.
#A script that reads the clock inside cook() or onCook() runs once and freezes
The previous trap is about a branch nobody pulls. This one is the opposite and looks identical on screen: the branch is displayed, the render is cooking, and the picture still never changes.
A Script SOP or Script CHOP whose cook() — onCook in its callbacks DAT — reads absTime.seconds, me.time.frame or any other clock has no dependency on it. TouchDesigner decides what to re-cook from parameters and inputs, not from what a function body happens to read, so the node cooks once and its output is frozen from then on.
Symptom. Geometry built from a time-varying formula is correct but static. Sampling the same point twice, seconds apart, returns identical coordinates — which is the cheapest way to tell this apart from a slow network.
Fix. Put the clock on a parameter, because a parameter expression is a dependency. Add a custom parameter in setupParameters, drive it with absTime.seconds, and read that parameter in cook():
def setupParameters(scriptOp):
scriptOp.appendCustomPage('Form').appendFloat('Time')
def cook(scriptOp):
t = scriptOp.par.Time.eval() # depends on the clock; absTime here does not
Then set the expression once: op('…/curve').par.Time.expr = 'absTime.seconds'. For a render that has to reproduce, use me.time.seconds there instead. See A frame that reads the application clock does not reproduce.
A Script CHOP can take the clock on an input instead: wire in a Constant CHOP whose value is the expression me.time.frame. The input changes every frame, so the Script CHOP cooks every frame. An agent session (2025.32460) used this for five Script CHOPs driving a GLSL TOP.
#A render chain is not connected to anything the artist is looking at
Building camera + light + geometry → renderTOP → bloomTOP leaves a chain that renders correctly and appears nowhere. The artist is looking at whatever their output node shows, which is still the old network, and reports that nothing works.
td_render hides this, because it forces a cook of the node you name and returns its image, so the tool sees a correct picture while the screen does not. Wire the end of the chain into the project's output node before saying anything about how it looks.
#A Python extension attached the wiki's way is silently absent
The offline wiki's Extensions page shows the extension expression written as MyClass(me). On this build that leaves comp.extensions[0] equal to None. errors() is empty, warnings() is empty, and extensionsReady reports True. Nothing says the extension is missing, and every call into it fails later as a plain attribute error on the COMP.
The form that works, and the one TouchDesigner's own components use:
op('./<DAT>').module.<Class>(me)
Use td_extension_add, which writes that form, parses the code first, and reads the instance back. A class that fails to instantiate comes back as the real exception.
#A GLSL shader that does not compile is a warning
A GLSL TOP whose shader fails to compile outputs a checkerboard or black and reports through warnings(), not errors(). The warning text is only The GLSL Shader has compile errors (Use Info DAT to see details). It names no line and no file, so anything scanning for errors misses it entirely.
The compiler's own output is a property, compileResult, on glslTOP, glslmultiTOP and glslMAT (glslPOP has no documented equivalent). td_health reads it and prints the failing line and source DAT directly, as shader-compile.
A uniform array declared longer than the CHOP that feeds it compiles cleanly and warns of nothing. Measured on 2025.32460: uniform vec4 uSeg[256] fed 192 samples read NaN at uSeg[192] and 1.0 at uSeg[200]; an agent got 460 black frames of 1200 from exactly this after cutting its data short. td_health compares each uniform array's literal length in the source with its CHOP's sample count and reports glsl-array-length: a warning when the array is longer, a note when it is shorter. A length behind a #define is not read.
#Background throttling
TouchDesigner all but stops rendering when its window is not visible. Cook counts across the whole application drop to a few per second, including the default project's own output. Audio keeps running.
This is easy to mistake for a broken network. Bring the window to the front before measuring performance or recording in realtime.
td_health calls this stalled. The kind means fewer than five frames advanced between its two samples while the timeline is playing. It is a warning about the report, not about the network. Without frames there is no evidence either way, so td_health says so and stops short of naming operators as dormant. A paused timeline gives the same silence and is reported as paused, which is a note and not a warning.
#CPU operators hiding in a menu
Some operator options run on the CPU and cost orders of magnitude more. The Noise TOP is the trap. Its type menu labels distinguish "Simplex 3D (GPU)" from plain "Sparse", "Hermite", "Harmomic Summation" (TouchDesigner's own spelling; the value is harmonic), "Random" and "Alligator", which are CPU-side.
Measured on 2025.32460 at 1280×720, Apple Silicon, sampled by td_health over a 2 s interval. sparse cost 96 ms per cook (reproduced twice), alligator 104 ms, harmonic 104 ms and hermite 38 ms (each measured once). simplex3d and perlin3d never crossed the 8 ms threshold at all. Whether that shows up as dropped frames depends on the rest of the network, so do not wait for the frame rate to tell you.
Nothing warns you. td_health flags any operator costing more than 8 ms as expensive, which is per operator. The separate slow finding is about the whole application, and means the measured frame rate came in under 60% of the project's target. Tell the two apart. expensive with no slow means the rest of the network is absorbing it, and slow with no expensive means the cost is spread across many operators or is not in cooking at all.
A cook time is the duration of the operator's last cook, whenever that was. An agent read /project1/moviefilein1 (44 ms) as a cost of the current frame, when the node had last cooked at absolute frame 353 with the clock at 374497, and spent five calls on it. td_health now puts the frame beside each time. An operator that did not cook while the check ran goes into the note expensive-stale instead, with how many frames ago its cook was. It is not part of the current frame and not a suspect for the frame rate. Only expensive names those.
The split needs a clock that moved. The frame compared against is absTime.frame, and with the root timeline paused it stands still, so nothing cooks during the check and "frames ago" would count only the frames that played. On a paused timeline, or any check across which that frame did not advance, every time over 8 ms stays under expensive with the frame it was measured on, and the finding says the times are the last ones measured.
The operator summary says so in prose too, "The ones that are calculated on the GPU will have GPU in their name", which is easy to read past. Prefer perlin*, simplex* and randomgpu.
#Feedback loops amplify whatever is inside them
A feedback loop re-processes its own output every frame, so any operation placed inside it compounds:
- A hue rotation inside the loop rotates a little more each pass; within a second everything converges to grey.
- An additive composite inside the loop settles at
source / (1 - decay), which clips to white.
Fix. Feed back from before any colour grading, and grade only on the way out. Prefer a maximum composite over add for trails, which keeps the brightest history without summing. Decay by scaling RGB (brightness1) and not alpha (opacity), since a maximum composite ignores alpha.
Working shape:
melt ─┬─────────────► comp(maximum) ──► posterize ──► grade ──► strobe ──► out
│ ▲
└── fb ── drift ── decay fb.top = comp (not out)#A Feedback TOP's wire input is not its top parameter
A Feedback TOP takes the frame it replays from its top parameter. Its wired input is a different thing, and wiring that input to a node that is itself downstream of the loop makes a genuine cook dependency loop. TouchDesigner notices and writes Cook dependency loop detected into the node's warning.
A session reported that only td_network showed the warning and that td_errors stayed silent. Measured on the live instance (2026-08-30, build 2025.32460), the tools do not differ. A loop built from constant → composite(maximum) → level → feedback, with the Feedback TOP's wired input taken from the level inside the loop, reports
Warning: Cook dependency loop detected. ...
# Cook stack starts
/…/Lkeep
# Cook dependency loop starts
/…/Lgrade /…/Lcomp /…/Lfb
on Lgrade. td_errors and td_network both name it, in either call order, on repeated calls. Both are asked for the subtree the loop is in. td_errors walks the project by default and takes any other path by name, so a loop built outside the project is only reported when it is asked for. What does differ is the state before the loop has cooked. Freshly built with nothing pulling it, every operator sat at 0 cooks and both tools reported nothing at all. Adding a cacheTOP with alwayscook took them to 90 cooks, and from then on both tools carried the full text. The silence was the loop not running, which is A branch nothing consumes never runs above. Check errors after something pulls the chain.
td_health folds every warned operator into one note line, and carries the warning's first line:
[note ] 1 operator(s) reporting a warning
/…/Lgrade (Cook dependency loop detected. Check for exports, ...)
The cook stack behind that sentence is left to td_errors, which prints the whole thing.
Fix. Take the Feedback TOP's wired input from a node before the loop.
#A forced cook does not advance a feedback loop
A Feedback TOP takes its next frame when the timeline advances. cook(force=True) cooks the whole chain again but leaves the loop holding what the last real timeline frame put in it. An agent warmed each tile of a tiled render with 1200 forced cooks, and every tile carried the trail of the full frame from before the tiling began (build 2025.32460, reported 2026-09-20). It took an hour to find.
td_health lists every feedback operator with its target in the note feedback-loops. One held in reset passes its input through and is left out. The behaviour was observed on a Feedback TOP. The CHOP and the POP keep state between frames the same way and are listed too, but were not tested.
The wider class is every operator with a reset pulse — Trigger, Speed, Count, Lag, Filter, Trail, Audio Oscillator, Feedback, Cache — and td_health names them in the note history. Setting root.time.frame = N does not re-run them from the start: in one agent session six "different" frames showed one surface, because a Speed CHOP's phase stayed where the last played frame left it. A repeatable take starts from a reset; td_record pulses resetpulse on every one of them under its scope.
Fix. To get a correct frame out of a network with a loop, play the timeline and capture from it rather than forcing cooks.
#A composite's first input is the foreground
compositeTOP with operand = over is not symmetric, and nothing tells you which way round it goes. The shipped help gives the formula, and it settles it:
over: (input2.rgba * (1.0 - input1.a)) + input1.rgba
The help numbers inputs from 1; connectors are numbered from 0. So the help's input1 is connector index 0, and it is the layer that covers the other one. Put the foreground on index 0.
Get it backwards with an opaque foreground and the frame goes solid black, with no error and no warning. An opaque layer on index 0 hides index 1 completely. This cost a session about an hour, with an accumulating feedback frame wired to index 0 and the source to index 1, so the source was never seen. td_trace from the black output walks up its inputs and marks the node where the image went black while an input still carried one.
swaporder ("Swaps the order of the input pairs. A operation B is changed to B operation A") flips it without rewiring. The operand menu documents the formula for many of its entries, and td_docs("Composite TOP") prints them, which is faster than guessing which operations care about order. The commutative ones do not (add, multiply, average, maximum, minimum). The layer modes do, and the help names over and hardlight as its own examples. outside is documented as input1.rgba * (1.0 - input2.a), which is the same asymmetry again.
#A default is not the neutral value
levelTOP has a contrast parameter, and its default is 1.0, not 0. Setting it to 0 collapses the image to a flat grey, every pixel the same, with no error, no warning and nothing in td_health. Diagnosing it took four calls, rendering the upstream nodes one at a time to prove they were still alive.
Measured from the index on build 2025.32460 (params table, levelTOP): contrast default 1.0, range 0–1; and so are brightness1 and gamma1, which are the same trap. blacklevel defaults to 0.0.
Read par.default, which td_operator_schema prints, before assuming which number means "no change". Multiplicative parameters are neutral at 1, additive ones at 0, and the parameter name does not tell you which kind it is.
#A relative OP path is read from the network the node sits in
A COMP is a network, so a relative path in one of its own parameters looks as if it should start inside it. It does not. It resolves from the network the COMP sits in, alongside its siblings.
Measured, from TouchDesigner's own shipped snippet (OPSnippets/Snippets/COMP/geometryCOMP.tox, expanded): every example writes material 0 phong1 or material 0 constant1 on geo1, and phong1 sits beside geo1 in the same network, not inside it. So for /project1/REF/gobj, the MAT /project1/REF/mdark is written mdark. ../mdark points a level too high and resolves to nothing.
An OP-path parameter that resolves to nothing is silent. The geometry renders with a default shader, errors() is empty and td_errors says nothing. The only way to see it is par.eval(), which returns None; par.val happily returns the string you wrote. When td_op_info shows an OP parameter, check that its value is an operator and not just text you typed.
td_build and td_set_params catch the common case. A ../ reference that resolves, from the parent, to nothing is refused with the sibling and absolute spellings. Re-measured on 2026-09-24: material='mat1' beside mat1 resolves, '../mat1' gives None, and './x' reaches a child of the COMP itself. Whatever the spelling, after the write each step's answer names an OP parameter that reads back None (material = '../wire' points at nothing), so a reference the check could not judge is not silent either.
#A fresh Geometry COMP already has geometry in it
Measured on the live instance (2026-08-30, build 2025.32460). A newly created geometryCOMP arrives holding one child, torus1, which on this build is a torusPOP and not a SOP. It has display and render both on, and it is what gets drawn. A Render TOP at 128×128 pointed at an otherwise untouched COMP (camera at tz 5) returned 3268 of 16384 pixels with alpha above 0.5, in a bounding box of 98×38. Destroying torus1 took the same render to 0. The shipped help treats the torus as normal enough to write examples around it ("if you had torus1 inside geo1", Run Command Examples). The offline index does not record a fresh COMP's contents, since they are a runtime fact.
Fresh COMPs of other families are not empty either. A cameraCOMP came with one child and a lightCOMP with 21, but those are viewport gizmos and do not reach a Render TOP. The Geometry COMP is the one whose contents render.
td_network defaults to depth=1 and lists only direct children, so td_network("/project1/REF") names the COMP and stops there. It does print numChildren, which is the cheap tell, and reads geometryCOMP … numChildren: 1 on a COMP you have put nothing into. The contents themselves need td_network pointed at the COMP, or depth=2.
After creating a Geometry COMP, look inside it before wondering why the render shows something you did not build. While you are in there, check the flags on your own SOP. That was measured too, and it is the mirror-image trap. A sphereSOP created inside the COMP (through op_create, and through .create() in td_exec alike) arrives with display off and render off, while the torus beside it has both on. Until you set them, your geometry is the invisible one:
/…/g6/s6 sphereSOP display false render false
/…/g6/torus1 torusPOP display true render true
With the flags off, every measurement of the render is the same number no matter what you change upstream. See Identical numbers are usually a picture that did not change below.
td_build says both in its answer: the torus1 it found inside a new Geometry COMP, with its flags, and for a new SOP or POP inside one, that its render flag is off and which operator has it.
#Operators that bring DATs with them
Several operators arrive with DATs docked beside them (2025.32460): a GLSL TOP with _pixel, _info and _compute, a GLSL MAT with _pixel, _vertex and _info, a GLSL POP with _compute and _info, a Script CHOP or SOP with _callbacks — even when its callbacks parameter names another DAT in the same create — and a Ramp TOP with _keys. td_build names each one after the step that made it, and says whether the new node points at it through a parameter. One it does not point at is a leftover; remove it with op_delete in the next batch, or leave it, since it costs nothing.
#A Wireframe MAT on macOS draws 1-pixel lines and ignores fog
Measured 2026-09-29 (2025.32460, macOS): linewidth and wirewidth at 1, 3 and 6 render the same frame to the pixel, and black linear camera fog that took a Phong box from 19040 to 5864 left the same box in a Wireframe MAT unchanged. Nothing reports it; a sum of the frame is the only tell. td_operator_schema prints this under the MAT. An agent spent seven measuring runs on it before building its own GLSL MAT (wireframe topology, fade by depth) and a 3×3 maximum pass after the render for thickness.
#Movie File Out
td_record does everything below in one call — a fresh node per take, the record switched on a step after setup, realTime off, the resets, the end read from Info CHOP's active_records, the count of frames written. The list is for a recording made by hand.
- It is terminal. Nothing consumes it, so it needs a keep-alive (above).
- Set
fpsexplicitly, since the shipped examples all bind it tome.time.rate. - Add an
infoCHOPpointed at it.total_frames_writtenandtotal_audio_samples_writtenare the only way to know recording is happening; the node itself reports nothing. Both channels exist on this build and both read0while nothing is being written. - Measured once on this build, selecting the AAC audio codec made it report frames and samples written while producing no file at all.
pcm16worked, and so did recording with no audio. Transcode afterwards if you need AAC. This has not reproduced since, so treat it as a thing to check. - Realtime recording drops frames under load; the docs suggest turning Realtime off for a clean capture, and TouchDesigner repeats video frames to hold A/V sync when it cannot keep up.
- One node, one recording. An agent session (2025.32460) found that after
record = 0, settingrecord = 1again wrote no file, with a new path or the same one, and that changing the codec on a live node broke recording for good. Both were cured the same way: destroy the node and create a fresh one for each pass, then wire it and setfileandvideocodecbeforerecord. Not re-measured here, so checktotal_frames_writteneither way.
#Output devices switched off
An audiodeviceoutCHOP with active off produces silence and reports nothing. The same applies to MIDI Out, OSC Out and DMX Out. td_health checks all of them and reports output-off.
#Non-Commercial licence
- Resolution is capped at 1280×1280. Asking for 1920×1080 silently gives 1280×720. TouchDesigner emits a warning, not an error, and carries on. Measured on a Noise TOP,
resolutionw 1920, resolutionh 1080yieldswidth 1280, height 720.td_healthraises this to an error,resolution-clamped, since it changes the deliverable. The licence itself is reported once as the notelicence. - Movie File Out refuses H.264 and H.265, on macOS too. The licence text says "on Nvidia GPUs"; on macOS (2025.32460) the file is simply not written, and
td_errorsnames the cause: "GPU Accelerated H.264/H.265 Encoding requires a Commercial license". ProRes with PCM audio in a.movrecords; transcode afterwards if you need an mp4. - Blob Track TOP is limited to 2 blobs.
- Output may not be used in paid work.
#Parameter groups are not settable
The shipped help documents t (Translate), r (Rotate) and s (Scale), which are groups. The settable parameters are tx, ty, tz. td_operator_schema returns the members and marks the groups; td_build rejects a group name with the members as a suggestion. td_set_params does the same, asking the bridge for the operator's type when you do not pass op_type; only with no index built does the name reach TouchDesigner and come back as an AttributeError.
Not every operator has the transform parameters you expect. circleTOP has no tx, it has centerx/centery.
Some tuples are sized by another parameter. A POP's amp, exp, offset and the like have one member per parsize step: amp0 at parsize '1', up to amp3 at '4'. Below its size, a write to amp2 fails with "Index out of range". td_operator_schema marks such members only-when parsize>='3', and td_build refuses one written before its size is set. Set parsize first, in the same pars.
#Sequence parameters are read by their flat names
Parameters in sequential blocks (a GLSL TOP's vec uniforms, a POP's map) have flat names: vec0name, vec0valuex, vec1name. The block object does not answer them as attributes. Measured on a GLSL TOP (2025.32460):
g.seq.vec[0].par.vecname # tdAttributeError
g.seq.vec[0].par.vec0name # tdAttributeError too
g.par['vec0name'] # works
g.par.vec.numBlocks = 2 # AttributeError
g.seq.vec.numBlocks = 3 # works: vec1*, vec2* now exist
So the block count is set through seq, and the values through par by flat name, in td_build as {"vec0name": "uTime"}. Blocks past the first exist only after numBlocks is raised, which td_build cannot do. Raise it in td_exec first.
#An Execute DAT has no callbacks parameter
Its code is the DAT's own text. There is no parameter pointing at another DAT (hasattr(x.par, 'callbacks') is False, measured), so write the callbacks with op_create's text key and turn on the events you need (framestart, start, create, …), which are toggles on the DAT itself.
#Saved files use contracted type names
A .toe on disk records geoCOMP, evalDAT and parexecDAT, where the full names are geometryCOMP, evaluateDAT and parameterexecuteDAT. td-atlas carries a measured alias table (71 entries) and expands them when reading a project, so this only matters if you parse .n files yourself.
#exec blocks the main thread
Every bridge call runs inside a cook. A long exec freezes TouchDesigner, and polling during a realtime recording causes dropped frames. Start a recording, stay off the bridge, then stop it. This also ruins timing measurements taken from inside exec. A loop that samples cookTime is holding up the very frame it is measuring. Use td_health, which samples across an interval from the host side.
Time is the other thing that does not move inside a request. A noiseTOP driven by absTime.frame, cooked with cook(force=True) and read five times in one exec, returned five identical SHA-1s and the same frame number (51208) five times. The same node read once per request, three requests running, gave three different hashes on frames 51209, 51210, 51211. A loop with sleep in it does not sample a range; it samples one frame forty times. Take a strip with repeated calls. The host-side contact_sheet() helper in references/tools.md does that, one bridge call per frame with TouchDesigner left to run in between.
Deferring the work with run(..., delayFrames=N) has a trap of its own. The delay counts timeline frames by default, and a paused timeline never reaches them. There is no error; the call waits in runs and fires later, when something moves the frame, possibly alongside a newer chain you started. Count against the application instead with delayRef=op.TDResources, and give each chain a generation number in storage so a stale one sees it is not current and stops. For a single frame after an edit, td_render(settle_frames=3) is simpler.
#Identical numbers are usually a picture that did not change
A session reported that numpyArray(delayed=False) through td_exec hands back a stale frame. In that report cook(force=True) did not help, and three measurements in a row came back character-for-character identical while the scene was being changed underneath them. That does not reproduce. Measured on the live instance (2026-08-30, build 2025.32460), hashing the array:
- a
constantTOP → levelTOPchain, withcolorrset in one request and read in the next, gave three distinct hashes over0.1 → 0.5 → 0.9, andnumpyArray(),numpyArray(delayed=True),numpyArray(delayed=False)andsaveByteArray()all moved together; - the same change made and read back inside one request (set
0.2,cook(force=True), read, then set0.8,cook(force=True), read) gave two distinct hashes and the two pixel values; - a Render TOP, camera
tzstepped3 → 8 → 1.5one request apart, gave an alpha bounding box of128×80 → 60×22 → 128×128with a distinct hash each time, and dropping the resolution 128→64 came through as well; - a change made at the head of a four-deep chain that nothing pulls, read at the tail with
cook(force=True): fresh every time.
What does reproduce is the symptom. Toggling bypass on a noise SOP inside a Geometry COMP and measuring the Render TOP gave the identical hash and an alpha bounding box of 0×0 on every reading. That SOP was created with display and render off, and nothing of it was ever in the frame. Setting the two flags turned the same toggle into 64×64 → 0×0 → 64×64 and three different hashes.
The rule is that a number that will not move is evidence about the picture, not about the reader. Before theorising about staleness, render the node and look at it, and check the flags on whatever you expected to be in the frame. For a second opinion from a different code path, saveByteArray() is what td_render uses, and it was measured moving in step with numpyArray on every case above.
#A bypassed operator is not a broken one, and looks like neither
The bypass flag makes an operator pass its input through without applying itself. Downstream still gets a picture, the one it would have had if the operator were not there, and nothing reports an error. A bypass left on from an afternoon of debugging is the quietest way to lose an effect.
Bypass is not off, either. A bypassed Level TOP still passes its input down the chain, unlevelled, so bypassing a layer does not take it out of a composite. Disconnect it, or set its own opacity or brightness to zero.
td_health reports it as bypassed and lists every path, except a gain operator's, which goes under the note below instead. It reads the flag and nothing else, and cannot tell an intentional bypass from a forgotten one, so it names them all. Bypass is a flag, not a parameter. It is in NODE_FLAGS, so td_flags reads it and td_set_flags clears it, and it does not appear in td_operator_schema.
Bypass on an operator whose job is to scale a signal reads as "switched off", and does the opposite. A bypassed Level TOP hands its input through at full strength, so the layer it was dimming comes back. An agent bypassed a glow's Level TOP to rule the glow out, and the glow stayed. td_health names a bypassed levelTOP, mathTOP, hsvadjustTOP, mathCHOP or mathPOP under the warning bypass-passes-through, once, and not under bypassed as well. To take a layer out, bring its level to zero. In that case brightness1 = 0 on the Level TOP did it. Disconnecting it works too.
#A Level TOP in a float format can output negative values
A Level TOP in rgba16float or rgba32float, with the Post page clamp off, outputs values below zero when contrast is above 1 or the Range page's inlow is above 0. Measured on build 2025.32460: a Constant TOP at 0.2 through such a Level came out at −0.4 with contrast = 3 and at −0.6 with inlow = 0.5. outlow below 0 is the floor the Range page maps onto, so it goes below zero as well; that one was not measured. blacklevel does not do it. At 0.5 it took the same 0.2 to 0.0, and stopped there. brightness1 = −0.5 also gave 0.0, gamma1 = 0.3 gave 0.005 and invert = 1 gave 0.8.
An 8-bit format clamps the negatives away. A float one keeps them, and a Composite set to add then subtracts them from what it adds to. A background went black that way, and was turned up twice by hand before a sample down the chain found the cause. The agent measured −0.21 to −0.38 there and put it down to black level 0.42. By the measurement above, that Level had contrast or a Range setting as well.
td_health reports negative-float for such a Level TOP and names the setting that does it. It reads the TOP's actual pixel format, not its format parameter, which usually says useinput. It is a warning when an Add TOP or a Composite set to add sits within four operators downstream, and a note otherwise. Anything else that sums, such as a GLSL TOP or a Math, is not checked. td_trace from the darkened output finds the chain itself: it marks the Level with negative values start here and the Add it feeds with dropped here.
Fix. clamp = True, clamplow2 = 0, clamphigh2 = 1 on the Level TOP.
#A frame that reads the application clock does not reproduce
absTime does not follow the timeline's frame number. Stepping the timeline or jumping to a frame does not move it back or forward to match, so a render walked frame by frame, or the same timeline recorded a second time, reads other values at the same timeline frame. Nor is it a clock that always runs: with the root timeline paused it stands still (2025.32460: absTime.frame 2013719 -> 2013719 across one second, while op.TDResources.time.frame went 516 -> 577). A camera driven by absTime.seconds moved differently in every recording. It surfaced only when the tiles of a tiled render stopped lining up, and it had spoiled the ordinary recordings before that. time.time() and an unseeded random do the same.
td_health reports nondeterministic for every parameter in expression mode, and every Execute-family DAT or Script operator's callbacks, that reads absTime.*, time.time() or draws from random (NumPy's included) without a random.seed( in the same text. A parameter holds no seed, so a draw there is always named. tdu.rand(seed) is a hash of its argument and is not flagged. The scan stops at the bridge's time budget, script text first and parameters after it, and says so as nondeterminism-unscanned.
Fix. For a render that has to come out the same twice, read the timeline: me.time.seconds or me.time.frame. Seed any generator.
#Realtime off changes what "one frame" means
With Realtime on, the default, TouchDesigner drops frames to keep the timeline on the wall clock, which is what a performance needs. With it off, every frame is rendered no matter how long it takes, which is what an export needs. Nothing about a running network makes it obvious which mode it is in, and the two give different answers to "is this fast enough": with Realtime off the frame rate is not a measurement of anything.
td_health reports the state as the note non-realtime when it is off. Turn it off for a clean recording (see Movie File Out above), and turn it back on before judging performance.
#A health report that only looked at part of the network
Three of td_health's findings are about the report, not about the project:
walk-truncatedmeans the bridge stopped walking at its own node cap, so the report covers only the operators it reached. It names how many were skipped. Run the check again on a subtree to cover the rest.interval-clampedmeans the gap asked for between the two samples was reduced. The host sleeps through that gap and answers nothing else meanwhile, so the interval is bounded.nondeterminism-unscannedmeans the check for clock reads ran out of its time budget. It names how many operators' parameters it got through, and how many scripts when it stopped before reading them all. Scripts are read first, under the same budget.
#A file-synced DAT runs the old code for most of a second
A Text DAT with syncfile on reads its file on a poll: measured on 2025.32460, an edit made on disk reached the DAT 0.68–0.71 s later. A frame taken inside that window runs the previous code, and an agent went looking for a bug that was not there. td_exec, td_render and td_timeline_run now reload every synced Text DAT whose file differs from its text before they act, and say reloaded from disk first when they did. Outside those calls — a frame saved by a script in a DAT of your own — check the text before trusting the frame, or pulse the DAT's loadonstartpulse, which reloads text and module at once. Editing the text inside TouchDesigner writes the file immediately, so the reload never overwrites an edit made there.
#Stale errors
errors() keeps its last string until the operator cooks again, so a fixed problem can be reported for another moment. If a fix looks ineffective, wait a beat and check again.
An operator's own error and warning strings reach the report as node-errors and node-warnings. Tracebacks raised inside a script or a callback are kept by TouchDesigner in a different place entirely and appear in no operator's error list. td_health reads them separately and reports script-errors. When that read is the thing that fails, it says script-errors-unread. Whether anything raised is then unknown, and unknown is not clean.
A traceback's text outlives the fault. Measured on 2025.32460: a Script CHOP whose onCook failed on a missing operator went on cooking every frame and producing its channel once the operator existed, and the old traceback stayed on its callbacks DAT until cleared. So td_health clears each kept traceback and looks again 0.2 s later. Back: it is being raised now, script-errors as an error. Not back while the operator running that code cooked, and raised in onCook (the one callback a cook reruns): it was fixed, and script-errors-stale (note) says so and leaves it cleared. Anything else: script-errors as a warning, and the text is put back — also when the second read fails.