# 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:** Phases 4b and 4d complete at commit `41b1b50`; latest commit `f96c484` (2026-09-08) — the four settings are custom parameters with a bound surface on the control panel, and speed works. **Phase 4c is next**, and is what makes the other three settings do anything: the timer, the cue policy and the end-of-file watcher. 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. The deck is built, 2026-09-08** (commit `abbaf85`); the timer is not. Split that way at jms's request: shuffle became a button rather than a mode, which made the ordering worth settling on its own before anything drove it automatically.
**What is done.** A shuffled deck and a `previous` button beside `next`. The deck is stored as a **seed rather than an order** — one integer regenerates it, which makes a run reproducible from the number in the log, cannot fall out of step with a rescanned playlist the way a stored list of row indices could, and is small enough that the question of where runtime state lives has an honest answer: a module global that survives a rebuild, dies with the process, and never enters the `.toe`. That also settles the seeding item below, which arrives as a property of the design rather than as a control on the panel. The default is the playlist's own order, so a launch starts unshuffled.
`next_index` gave way to `step_index`, which works in deck positions and answers playlist rows — once shuffled those are not the same thing, and next and previous are then the same function with a sign. Pressing shuffle does not cut the clip: nothing in it touches the player, so what is on screen keeps playing and only what comes next changes. That falls out of the transport reading its position off `file` every time, which is now the fourth time refusing to store something has paid.
Adding both buttons cost one row in `CONTROL_BUTTONS` and one function in `player.COMMANDS` each — the first real return on the name-dispatch built at Phase 5.
**What remains** is planned as 4b, 4c and 4d below — the timer, the cue and end-of-file policies, and the surface that sets them. Shuffled-deck ordering is done; random cue point, speed and dwell are not. The player is finished at the end of 4d.
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.~~ **Done, and it never became a control.** It is a property of the sequence rather than of the surface that operates it, and storing the deck as a seed made reproducibility fall out rather than have to be added: `shuffle()` reports the number it drew, and `tdpy.player.set_seed(n)` at the textport deals that deck again. `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.
The display half is already built and needs nothing: the clip list reads its order from `player.play_order()`, so a shuffled deck appears in the panel the moment that function returns one.
**Phase 4b — settings become parameters. Complete 2026-09-08** (commit `41b1b50`, with 4d). Groundwork for everything below, and the decision that determines what Phase 7 costs.
Four settings are needed: what happens at the end of a file, where a clip starts when it changes, the dwell time, and the speed. Each has **several writers** — a field, a slider, and eventually MIDI — which is what makes them different from the deck's seed. A seed has one writer and is an action, so it stays a Python global in `player.py`. A value with three writers held as a Python global needs bespoke plumbing per writer and nothing keeping them in sync.
So the settings become **custom parameters on a `settings` COMP**, and TouchDesigner's [Binding](https://docs.derivative.ca/Binding) does the synchronising: the custom parameter is the bind master, the slider's `value0` and the field's value are bind references, MIDI writes the master, and Python reads it. One value, many writers, no synchronisation code — the same shape as the derived-highlight rule from Phase 5a. Binding is settable from code (`Par.bindExpr`, and the parameter's mode), which is what keeps this in the source rather than in the `.toe`.
`settings` lives **beside** the build container, converged onto, for the reason the windows do: a rebuild while tuning should not reset the dwell. `build()` creates parameters that are missing and leaves existing values alone.
**Built as described, with one addition and one hazard worth recording.** The addition: the player's own `speed` is bound to the Speed parameter, so item 5 works at the end of this phase with no engine behind it — a binding, not code. Nothing else may write `speed` on the player now, since a binding is two-way and a stray `set_par` would overwrite the master rather than be overridden by it. The hazard: `appendFloat` and its siblings default to `replace=True`, documented as replacing a parameter with fresh attributes, so a build that appended unconditionally would reset a dwell being tuned on every rebuild — `startup.custom_par()` appends only what is missing, which is the whole of what makes "converged onto" true rather than aspirational.
Defaults follow `SEED = None`: **every automatic or stochastic behaviour is off at launch** — playlist order, clips from their start, speed 1, and a dwell of 0 meaning the timer is switched off rather than a very fast cut. Advance-at-end is the exception and has to be, since with no timer and no button press nothing else would ever advance.
~~**The one risk worth retiring first.**~~ **Retired 2026-09-08 by a throwaway spike, which passed on every count.** Binding can be set up from code with `Par.bindExpr` and `Par.mode`; it is genuinely two-way, so a dragged slider, a typed value and a MIDI CC all write one place; and propagation is synchronous, so there is no cook-timing subtlety to design around. The Panel-Execute fallback and its feedback hazard are off the table. Details on [[300-Code/330 - TouchDesigner/0020 - TDPyMovieplayer|330.0020]], along with the two things the spike turned up that nobody was looking for: TouchDesigner's enums are not on the `td` module, and the clip list's text justification had never once been applied because the guard written for that case could not tell a missing constant from a wrong path.
**Phase 4c — the engine. Planned 2026-09-08.** The cycle itself, reading the parameters 4b created.
**End of file (item 1) resolves to one configuration and one watcher.** `textendright` is set to `cycle` permanently, so looping is the default behaviour, and a CHOP Execute DAT watches the Info CHOP's **`loop_frame`** channel — "1 if the movie has just looped". The toggle then decides only whether that watcher advances or does nothing; both behaviours come from the same network.
The obvious build — `hold`, then detect the last frame — is the one to avoid, and TouchDesigner's own documentation says why: of `last_frame` it notes *"if a frame is dropped the last frame may never get shown"*. Reading that before building is the same move as reading the extension list out of `libTD.dll` at Phase 1. The cost of the `cycle` approach is one frame of the looped clip before the cut; check it on screen, and if it shows, that is Phase 6's argument rather than a reason to change this.
**Cue point (item 2)** is `cuepoint` with `cuepointunit` set to Fraction — confirmed available at Phase 2 — pulsed on `cuepulse`. Start is fraction 0 and random is a random fraction. It goes inside `_step()`, so next, previous, the timer and MIDI inherit it identically rather than each applying their own.
**The timer** is a Timer CHOP with `length` from the Dwell parameter and its `onCycle` callback calling `advance()` — which is `next_clip()`, since the plan's founding rule is that advancing is a function call. **A dwell of 0 switches the timer off**, so the player runs on end-of-file and button presses alone; that makes the bottom of the dwell slider a real mode rather than a very fast cut, and it costs one guard.
Two things become true at once here and are worth stating before they surprise someone. There are now **two advance triggers** — the dwell expiring and the file ending — and whichever comes first wins. And the constraint above finally bites: at speed *s* and dwell *T* a clip consumes *s·T* seconds, so a random cue point will sometimes run off the end, and **item 1's toggle is the answer to what happens when it does.** That is why these are one phase rather than four separate jobs.
**Phase 4d — the settings surface. Complete 2026-09-08** (commit `41b1b50`, with 4b), built before 4c rather than after it: 4b alone changes nothing on screen, and the panel's geometry is better changed once, since an already-open control window keeps the size it opened at. Sliders and an editable parameter panel on the control panel, bound to 4b's parameters, sitting between the transport row and the clip list. All built-in operators, so no palette component and no repeat of the Phase 5a argument.
**The slider range was the one thing that nearly went wrong.** A Slider COMP's `value0` is a panel position, so a slider bound to a dwell of 0–60 would have written 0–1 into it — with nothing in the log to say so, since the binding would have been working exactly as asked. `Config/TDParameterHelp.json` carries no parameter that sets a slider's range; `valuerange0l`/`valuerange0h` do, and they are in the type stub TouchDesigner generates from the operator itself and nowhere else, with the help text "Help Not Available." Confirmed on screen at 0–60 and 0–4. The lasting part is that the install describes its own operators twice and the help table is the stale one — [[100-Projects/160 - Software/0010 - TouchDesigner-Python|160.0010]]'s `scaffold.params` now reads both.
**Not a Field COMP.** A Field COMP has no value parameter to bind — only a panel value, and panel values can be bind masters only — so the typed half of items 3 and 5 has no clean binding. The **Parameter COMP** does it instead: built in, pointed at `settings` with its `op` parameter, rendering all four settings as live editable controls of the right type, verified during the 4b spike following a bound slider in real time. The sliders then exist for the tactile half rather than because the values are otherwise unreachable.
Nothing here decides anything: every control is a view of a parameter. Built before the behaviour rather than after it, which has one consequence worth having written down, because it reads exactly like a bug: **three of the four controls are inert.** The toggles hold their state and do nothing, dwell's slider and field track each other and do nothing, and only speed reaches the player — because the timer, the cue and the end-of-file watcher are Phase 4c. That was the plan and it still wants saying out loud when the panel is handed over.
The panel grew again, 980x800 to 980x1110, and the build now reports it when the panel outgrows its window — the symptom is otherwise a panel with its bottom cut off and nothing to explain it.
**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 5a — the clip list. Complete 2026-09-08** (commit `140def6`). The panel gained a list under the buttons: every file in the playlist with its length, in the order the clips will be played, the playing one highlighted. Verified on screen against a folder of nineteen clips, walked end to end and round the wrap.
Not the palette's **lister**, which was the obvious candidate and would have read the existing playlist table with two parameters. Its configuration lives in a Config COMP inside a `.tox`, and that is the one place this project has agreed to keep nothing — the appearance of the panel would have stopped being described by the source. Its custom parameters are also outside `Config/TDParameterHelp.json`, so the tooling built to stop this project guessing parameter names would not have covered it. The built-in **List COMP** it is built on inverts both, and its `rowAttribs[i].bgColor` is writable at runtime, which makes the highlight one assignment rather than a look defined inside someone else's component.
**The highlight is derived rather than pushed, and that is the part worth keeping.** A Parameter Execute DAT watches the player's `file`, so the list follows what is on screen no matter what changed it — the next button, `advance()`, a MIDI note, or a hand edit. Having `next_clip()` update the list instead would have worked exactly until the second caller existed, and then failed as a lie rather than an error. It is the same refusal to store state that keeps `next_clip()` from holding an index, and the second time in this project that the refusal has removed a category of bug instead of deferring it.
Ordering is asked for rather than assumed: `player.play_order()` returns the row indices to display, `range(n)` today. Phase 4 replaces that function and the display needs nothing — which is the concrete return on the decision, made two phases ago, that advancing is a function call.
One cost was paid for the first time. The panel grew from 760x160 to 760x800, and because the window is converged onto rather than rebuilt, an already-open one keeps its old size — so a geometry change now wants TouchDesigner restarted rather than a rebuild click. Labels and columns still only want a rebuild.
Tests went 60 → 102.
**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 settings parameters. Small by construction if the first decision above has been held to — and smaller still after Phase 4b, which is chosen largely to make this phase nearly free: a CC drives a custom parameter, the panel's slider and field are bound to that same parameter, and nothing has to be told that MIDI moved it. Buttons stay function calls, since shuffle and next are actions rather than values.
**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.