# 160.0020 - TDPyMovieplayer Created: 2026-09-07 Completed: Type: #software Tags: #touchdesigner #python #video #live-visuals [Github Repository](https://github.com/wtkns/TDPyMovieplayer) ## Introduction **Description:** TouchDesigner movie player, built entirely from version-controlled Python, that plays a folder of video files in a random order — each clip cued to a random point and run at a random speed, cut to the next after a fixed dwell time. The first project built with [[100-Projects/160 - Software/0010 - TouchDesigner-Python|160.0010 - TouchDesigner-Python]], and therefore the test of whether authoring a TouchDesigner network in code is workable in practice rather than in principle. The framework's Phase 3 — the build layer — was deliberately left unwritten until a real project could say which helpers it actually needed. This is that project. The behaviour is simple to state and awkward to build: read a folder of video files, learn how long each one is, then play them in a shuffled order, each starting somewhere arbitrary inside itself, each running at its own speed, each cut off after a fixed interval regardless of where in the clip that lands. The result is closer to a scrubbing machine than a playlist — a way of turning a body of footage into continuously varying material rather than a sequence of works. The fixed dwell time is a placeholder for the thing that will eventually drive it. The cut is meant to be triggered by MIDI, which is why the design's one non-negotiable rule is that advancing to the next clip is a plain Python function call: the timer that fires it now, and the MIDI note that will fire it later, are both two-line shims around the same function. The working code lives in the [TDPyMovieplayer](https://github.com/wtkns/TDPyMovieplayer) repository, cloned locally to `300-Code/330 - TouchDesigner/0020 - TDPyMovieplayer/`. ## Repository - **Remote:** https://github.com/wtkns/TDPyMovieplayer - **Local:** `300-Code/330 - TouchDesigner/0020 - TDPyMovieplayer/` (excluded from Obsidian sync and from this vault's git tracking) - **Code-side page:** [[300-Code/330 - TouchDesigner/0020 - TDPyMovieplayer|330.0020 - TDPyMovieplayer]] — carries the per-commit history and the working notes - **State:** Phase 5 complete at commit `cecf48c` (2026-09-08), built out of order and scoped to transport. Phase 4 next — Phase 3 is largely obsolete, see Tasks. ## Development Plan Three decisions shape the work. **Advancing is a function, not a wire.** `advance()` picks the next clip, cues it, and starts it. Whatever calls it is a shim: the Timer CHOP's callback now, a MIDI In callback later, a button in between. Everything else in the design follows from refusing to let the trigger and the behaviour become entangled — the MIDI conversion is only cheap if nothing but the shim has to change. **Durations are asynchronous, and that is the whole difficulty.** A Movie File In TOP does not know a file's length until it has loaded and parsed the header, some frames after the `file` parameter is set. There is no synchronous call that reads a duration off a path. A naive Python loop over the playlist returns zeros for every file. Measuring a folder therefore means a state machine that walks it a file at a time — the single piece of real engineering in the project, and the first genuinely asynchronous thing the framework has had to express. The alternative was to shell out to `ffprobe` and read the durations synchronously in about ten lines. Rejected: it would put an external binary in the dependency-free path the framework has kept clear so far, and the in-TouchDesigner version is the more instructive thing to have built. The cost is paid once and cached — a sidecar keyed on path, size, and modification time, so a warm launch skips the probe entirely. **Superseded 2026-09-08.** The premise of that rejection was wrong, and checking it before writing Phase 1 was what turned it up. TouchDesigner ships `ffprobe.exe` and `ffmpeg.exe` inside its own `bin/`, beside `TouchDesigner.exe` and the `avcodec`/`avformat` DLLs the Movie File In TOP is built on, and hands the folder out as `app.binFolder`. The probe is not an external dependency — it is the same library that decodes the clip a moment later, reached a different way. Phase 1 therefore measures synchronously, and Phase 3 stops being on the critical path. The sidecar cache went with it: a full scan of a real folder of twenty clips takes well under a second, and a cache that is not needed is only an invalidation bug that has not happened yet. The asynchronous prober is still worth building as the more instructive version; it is now optional rather than blocking. **Media lives in the repository but is never committed.** Videos go in `media/`, which carries its own `.gitignore` ignoring everything but that file — the same pattern the virtual environment folders use, so the folder stays tracked and arrives empty on a clone. ## Tasks **Phase 1 — the playlist. Complete 2026-09-08** (commit `313af8d`). A Table DAT at `generated/playlist`, one row per video file, carrying name, relative path, size, duration, frame rate, dimensions and codec — more than the phase asked for, because ffprobe turned out to be free (see the superseded decision above). Verified in a running TouchDesigner against a real folder of clips, and again after files were added and removed between launches: the scan is re-entrant, which matters because the contents of `media/` are meant to change at will. `tdpy/playlist.py` imports no `td` and is standard library throughout, so the whole scan is tested at a normal prompt: 31 tests, run from a `dev_vEnv` built off `requirements-dev.txt`. That file is kept separate from `requirements.txt`, which is the portable record of the runtime environment tdPyEnvManager rebuilds and has no business holding pytest. Configuration ended up as module constants in `playlist.py` rather than an external config file — there are three of them and nothing yet needs to change one without editing code. Two details worth carrying forward. Accepted extensions are TouchDesigner's own movie-reader list, read out of `libTD.dll` rather than guessed, minus the audio-only containers it mixes in. Reading it rather than testing the files to hand is what makes it durable, given the folder is expected to change. And a missing ffprobe is not fatal: the playlist still builds with every duration reading 0, which is already TouchDesigner's own sentinel for an unmeasurable file, so Phase 4 had to handle the case regardless. **Phase 2 — one clip, playing. Complete 2026-09-08** (commits `0d9e339`, `965125c`). A Movie File In TOP into a null TOP, first playlist row loaded by relative path, playing at speed 1 in Sequential mode — and, added during the phase, a Window COMP putting the null on display 2, centered and exclusive. Verified on screen. The phase asked for parameter names to be established against a running 2025.32050 **and written down.** They were established; writing them down turned out to be the wrong shape of answer, since a table transcribed into a page goes stale at the next release. Two tools replaced it. `Config/TDParameterHelp.json` ships inside every install and lists every operator's parameters with internal name, label, type and help text, regenerated per release — read by `scaffold.params` in the framework. And `startup.set_menu()` resolves menu items, the half that file describes only in prose, against the live parameter, reporting the internal name it landed on. That workflow was not theoretical here: five menu items were written as UI labels, four resolved and reported their tokens (`sequential`, `center`, `exclusive`, `native`), and the fifth was refused rather than silently applied — `"Single Monitor"` is not an item on `justifyoffsetto`, and `set_menu` listed the three real ones instead of writing a wrong value. A menu is the one case `set_par()` cannot catch, because the parameter genuinely exists. **Cue Point Unit does offer a fractional option** — Index, Frames, Seconds, and Fraction (percentage) — so a random start point needs no duration. That matters less than it would have before Phase 1 measured everything anyway, but it still shapes Phase 4: a fractional cue is resolution-independent, and duration is then needed only to constrain the draw so a clip does not run off its own end. Two things surfaced that were not on anyone's list. `COMP.create()` appends a digit rather than fail when it will not use a name — *including names that are free* — which had the rebuild button living at `rebuild1` unnoticed since it was written; the framework's `startup.create()` now takes the name back, and every node here goes through it. And the machine's mixed-DPI displays made TouchDesigner warn about overlapping displays under scaled placement; `dpiscaling` is `native`, which cleared it. Both are recorded on [[300-Code/330 - TouchDesigner/0020 - TDPyMovieplayer|330.0020]]. **Phase 3 — durations. Largely obsolete as of 2026-09-08.** Every row is already measured by Phase 1. What remains is optional: a dedicated Movie File In TOP stepping through the playlist, one file every N frames, reading length and frame rate off an Info CHOP — worth building as the instructive version, and as the answer if ffprobe ever proves unavailable, but no longer the phase that can go long. **Phase 4 — the cycle.** Timer CHOP, callbacks DAT, `advance()`. Shuffled-deck ordering rather than independent random draws, so a clip cannot immediately repeat itself. Random cue point, random speed, fixed dwell. The player is finished at the end of this phase. A constraint worth being explicit about rather than discovering: at speed *s* and dwell *T*, a clip consumes *s·T* seconds of media, so a start point drawn from the whole duration will sometimes run off the end. Constraining the draw to the range that fits is one option and letting it run out is another; it should be a toggle, not an accident. This is what the durations buy beyond the random start. Seeding moves here from Phase 5, where it was listed as a panel control. It is a property of the sequence rather than of the surface that operates it, and a reproducible sequence is the only way to debug something this stochastic. `advance()` goes in `tdpy/player.py`, beside the `next_clip()` Phase 5 already built there — the shuffled deck replaces how the next clip is chosen and nothing else, since the panel's next button and a MIDI note both call a function rather than a wire. **Phase 5 — the control panel. Complete 2026-09-08** (commit `cecf48c`), built third rather than fifth and deliberately scoped down. A `controlPanel` container of three momentary buttons — play, pause, next — in a bounded, bordered `controlPanelWindow` on a display separate from the video's. Verified on screen: the buttons work, and the clips advance. The phase as planned carried dwell, speed range, seed, skip and reload playlist. Four of those five are parameters of a cycle that does not exist yet, so building them now would have meant building a panel for Phase 4 before Phase 4. What was kept is the part that stands alone and was the stated requirement anyway: **the separation of the two surfaces.** The controls on it were always described as negotiable, and transport is what a player has before it has a cycle. **The buttons do nothing themselves.** Each is a shim into a new `tdpy/player.py`, dispatched by name through one Panel Execute DAT — which is the project's one non-negotiable rule reaching its first real test, and `advance()`'s eventual home. `next_clip()` keeps no state at all: it reads the player's current `file`, finds that row in the playlist, and loads the next one. A rebuild therefore cannot desynchronise a stored index from what is on screen, because there is no stored index. That answers the "clear-and-rebuild destroys state" risk below for this phase, and defers rather than solves it for Phase 4's shuffled deck. **The panel is rebuilt on every build; its window is converged onto.** So relabelling a button is a click of rebuild rather than a restart, while the window stays open and where it was. Safe here for precisely the reason it is not safe for the rebuild button, which calls `build()` and would destroy the DAT running its own callback — nothing on this panel calls `build()`. The Phase 2 window was renamed `videoPlayerWindow` at the same time, since "the window" had stopped naming one thing, and the build now sweeps retired names: a renamed Window COMP does not rename the one a live session is holding, it leaves it open on a display the new one is about to ask for. **Display assignment turned out to be the phase's real lesson, and it was learned the wrong way round first.** The Window COMP's Display parameter is a **zero-based index into the Monitors DAT**, not the number Windows draws on the screen — so a three-display machine takes 0, 1 and 2, and the 3 written here for "the third display" is out of range. TouchDesigner does not refuse it. It opens the window somewhere else and carries on, which is how a wrong number reaches a user as a window that is simply not where it was asked for. Worse, the guard written *for exactly this* was wrong in the same direction and so warned about nothing: a 1-based range check passed the display 3 that does not exist and would have refused display 0, the primary. The numbering was settled the way this project settles things — out of `libTD.dll`, which carries TouchDesigner's own message for the case: *Monitor specified in `<op>` does not exist. Opening on monitor 0 instead.* A fallback onto monitor 0 only makes sense where 0 is a monitor. The check now reports the valid range rather than only the count, since a bare "3 attached" is what made 3 look reasonable to begin with. **Two predictions in this plan did not survive contact.** The `activeViewer` issue was expected to bite here and did not: a panel shown in a Window COMP is clicked through the window, not through the node's viewer flag, so the framework's outstanding bug never comes into it. And Phase 8 was supposed to be built first, on the reasoning that Phase 5 places its panel relative to the perform window — it wasn't, and it did not matter, because the panel is placed relative to a *display* rather than to another window. The two are independent after all. Still outstanding from the phase as written: seeding, which matters more than it looks, since a reproducible sequence is the only way to debug something this stochastic. It belongs with the cycle it seeds, so it moves to Phase 4. **Phase 6 (conditional) — seamless cuts.** Changing `file` on a single Movie File In TOP gives a hitch or a black frame at every cut. The fix is two players and a Switch TOP, preloading the next during the current clip's dwell. Not worth building until Phase 4 has shown the hitch and it has been judged to matter. **Phase 7 — MIDI.** A MIDI callback calling `advance()`, and CCs mapped onto the Phase 5 parameters. Small by construction, if the first decision above has been held to throughout. **Phase 8 — perform window mode.** Phase 2 opens `out` as a separate floating window, using the Window COMP's `winopen`. Derivative's own help calls that a tool for dialogs, popups and testing and says plainly it *should not* be used for putting final rendered content to outputs — a single large Perform Window is what it points at instead. So: `setperform` rather than `winopen`, one window owning the output display. A parameter change to build, and not a small decision, because it is the point at which this stops being a patch being looked at inside TouchDesigner and becomes a machine that occupies a screen. It was recorded here as needing to come *before* Phase 5, on the reasoning that Phase 5 places its control panel relative to wherever the perform window is. Phase 5 was then built first and the ordering never came up: the panel is placed relative to a display, not to another window, so the two phases turned out to be independent. The constraint is dropped rather than left standing as advice that has already been ignored without cost. ## Notes ### Risks - ~~**The prober may not be reliable.**~~ ~~**Phase 3 could swallow the project.**~~ Both retired 2026-09-08. They were the same risk twice, and it was removed rather than mitigated: durations come from TouchDesigner's own bundled ffprobe, synchronously, and every row measured on the first try. Left struck through rather than deleted because the reasoning that produced them is worth being able to compare against what actually happened. - **The build now depends on TouchDesigner's `bin/` layout.** The trade taken in exchange for the two risks above. If a future release stops shipping `ffprobe.exe`, or moves it, durations silently read 0 — which is why the playlist still builds in that case and says so in the log rather than failing. The asynchronous prober remains the answer if it ever comes to that. - **Media paths are per-machine even inside the repository.** `media/` keeps them relative, which is the point, but nothing stops a clip being referenced by absolute path elsewhere later. Keep everything path-relative from the start. Phase 1 holds to this: `path` is stored relative to the project root and forward-slashed, and a test asserts it. - **Clear-and-rebuild destroys state.** `build()` demolishes its own container, so anything that must survive a rebuild has to sit beside it — the way the framework's own rebuild button already does. Currently a non-issue: the playlist is cheap enough to regenerate that it is simply rebuilt with everything else, and there is no cache to preserve. It becomes a real question again at Phase 4, where the shuffled deck and the current clip are state that a rebuild would reset mid-performance. ### Precedent [[100-Projects/120 - Performance and Installation Art/0060 - PORTALS|120.0060 - PORTALS]] already does MIDI-driven video playback and compositing in TouchDesigner, built by hand. This project is a much smaller thing built the opposite way, and the comparison is part of the point: what PORTALS knows about playback is worth borrowing, but none of how it was made is.