NTSC QT fork for real-time webcam input to NDI
  • Python 85.5%
  • QML 14.5%
Find a file
Daniel Malik 1a9d873cad
Some checks failed
ci / lint (push) Failing after 14s
ci / publish (push) Has been skipped
fix: reliability
2026-05-15 16:03:43 +02:00
.forgejo/workflows feat: add GUI, REVERT FROM HERE IF BROKEN! 2026-05-15 01:54:17 +02:00
app feat: add GUI, REVERT FROM HERE IF BROKEN! 2026-05-15 01:54:17 +02:00
config feat: add GUI, REVERT FROM HERE IF BROKEN! 2026-05-15 01:54:17 +02:00
docs/img feat: add GUI, REVERT FROM HERE IF BROKEN! 2026-05-15 01:54:17 +02:00
ntsc_mixer fix: reliability 2026-05-15 16:03:43 +02:00
ntsc_rt fix: reliability 2026-05-15 16:03:43 +02:00
tests fix: reliability 2026-05-15 16:03:43 +02:00
translate Ui and translate 2021-08-13 02:46:28 +03:00
.gitignore feat: add GUI, REVERT FROM HERE IF BROKEN! 2026-05-15 01:54:17 +02:00
demo.gif update demo 2020-11-25 15:32:10 +03:00
icon.png New icon 2022-01-05 23:15:50 +03:00
LICENSE Bump files 2020-11-15 21:10:07 +03:00
ntsc_core.py feat: add GUI, REVERT FROM HERE IF BROKEN! 2026-05-15 01:54:17 +02:00
ntscQT.spec Feature/workflows CI (#54) 2022-08-20 21:42:39 +03:00
ntscqt_icon.ico Revert "Delete ntscqt_icon.ico" 2022-02-05 23:52:12 +03:00
pyproject.toml feat: add GUI, REVERT FROM HERE IF BROKEN! 2026-05-15 01:54:17 +02:00
README.md fix: reliability 2026-05-15 16:03:43 +02:00
uv.lock fix: reliability 2026-05-15 16:03:43 +02:00

ntsc-rt-py

Live NTSC / VHS look for webcam or UVC (ATEM) → NDI (for example into Resolume). The effect core lives in app/ntsc.py. The supported interactive entry point is:

  • ntsc-mixer — a PySide6 + QML live mixer: sliders, preview, Preferences for capture / output / NDI / engine / control / health / MIDI, plus optional OSC, all driving the same pipeline through a thread-safe ParamBus.

Python 3.103.12, uv recommended.

Getting started (normal use)

  1. Install dependencies

    uv sync
    
  2. Start the mixer

    uv run ntsc-mixer
    
  3. Tune everything in Preferences (toolbar button or menu). Use Apply on each tab to push settings into the running pipeline. You do not need to edit JSON by hand for day-to-day work.

The app loads the bundled default preset (ntsc_rt/default.preset.json) automatically (or your --preset file first, if given); that preset ships with moderate VHS-style NTSC overrides so the first run is not an all-zero “flat” look. If ~/.config/ntsc-rt-py/mixer_state.json exists from Preferences → Control → Save for next launch, it is deep-merged on top of that loaded preset (unknown JSON keys under each model are ignored; see merge_preset_with_mixer_state) so last-session tweaks return without discarding the whole file when one field is stale.

NDI sender (visiongraph-ndi) is a normal dependency: uv sync installs it alongside visiongraph and cyndilib. Advanced: PyPI wheels are platform-specific; if import visiongraph_ndi still fails after a successful sync, check that your OS/arch/Python minor version has a published wheel, or install from a matching index.

Where settings are stored

What Path
Window geometry, OSC port/host, fullscreen screen, preview throttle ~/.config/ntsc-rt-py/ui_preferences.json
Last full mixer snapshot for “Save for next launch” ~/.config/ntsc-rt-py/mixer_state.json
MIDI CC → parameter map (also editable in Preferences → MIDI) ~/.config/ntsc-rt-py/midi_map.json

Power users and automation

  • --preset / -p — load any valid MixerPreset JSON instead of starting only from the bundled default (the optional mixer_state.json overlay still applies afterward if present).
  • --midi-port N — open MIDI input port index N (requires python-rtmidi). Configure CC routing in Preferences → MIDI; the same data is written to midi_map.json.
  • --osc / --osc-host — override the saved OSC bind for this process; see Preferences → Control for the usual workflow.

Use --version to print the package version.

Live mixer UI notes

If visiongraph_ndi cannot be imported (unusual after uv sync; see note above), the UI still starts; the pipeline thread prints recovery hints and the preview stays idle until the import works.

  • Preview — processed frames via a QQuickImageProvider. Optional throttle (ms) in ui_preferences.json caps preview work (BGR→QImage + UI refresh); the NDI sender still receives every processed frame.

  • Toolbar / Health — measured output FPS and a running HH:MM:SS:FF timecode derived from ndi.frame_rate (display-only). Expect FPS to scale roughly with output resolution × NTSC cost; on Apple Silicon, lower Output height in Preferences (or preset output.target_height / output.ntsc_internal_max_width) until you reach ~15fps at your desired look. Clearing output.ntsc_internal_max_width (full-width NTSC) or choosing cubic / Lanczos for output interpolation increases CPU cost quickly—use area (default) for downscales when tuning for speed. Health FPS is the ground truth while you change these. Ringing below 1.0 runs an extra frequency-domain path per frame; the bundled default preset keeps ringing: 1.0 for real-time headroom (raise it toward 0.52 in Preferences → Engine when you want that artifact back).

  • Output / Capture tabsOutput exposes target height, NTSC internal max width (0 = full raster), interpolation, plus quick buttons (480p/486/540p caps). Capture exposes driver request width×height with 960×540 / 720p / 1080p shortcuts (apply before picking FourCC/backend). Defaults favor 480-line working height and a 720px NTSC cap so the core stays near SD-ish cost.

  • Dropout / black flashes — the pipeline always holds the last good NDI frame on hiccups (no black placeholders). Stale-stream keepalives fire near the nominal frame period so Resolume does not blank when NTSC work runs long. Use Capture → stall watchdog only if you need forced reopen on a stuck camera.

  • Performance (Apple Silicon)ntsc-mixer sets cv2.setNumThreads from NTSC_CV2_THREADS (default 4 on macOS, 1 elsewhere). Lower to 1 if you see CPU oversubscription; raise cautiously on M-series. The NTSC stack also batches VHS luma/chroma/sharpen IIRs along each frame plane (instead of one SciPy call per scanline), vectorizes the chroma-from-luma luma decode, and avoids scipy.ndimage.shift in the noise paths. Composite chroma lowpass still uses long 1-D lfilter passes. If profiling still shows time inside SciPy linear algebra, you can cap BLAS for this process only when launching, for example: OPENBLAS_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 OMP_NUM_THREADS=1 uv run ntsc-mixer. To inspect hotspots yourself, profile in-process (disable video noise so the RNG path does not dominate):

    uv run python -c "
    import cProfile, io, pstats
    import numpy as np
    from app.ntsc import Ntsc
    nt = Ntsc()
    nt._ringing = 1.0
    nt._enable_ringing2 = False
    nt._video_noise = 0
    f = np.zeros((480, 854, 3), np.uint8)
    pr = cProfile.Profile()
    pr.enable()
    for _ in range(30):
        nt.composite_layer(f, f, 0, 1)
    pr.disable()
    buf = io.StringIO()
    pstats.Stats(pr, stream=buf).sort_stats('cumtime').print_stats(20)
    print(buf.getvalue())
    "
    

    After the chroma blur change, cumulative time should not show four heavy cv2.resize Lanczos passes per frame at the top of the list for typical rasters.

  • GPU (Metal / MPS) — The pipeline is NumPy + SciPy + OpenCV-Python on the CPU; the opencv-python-headless wheel does not run these kernels on Metal. Real GPU gain needs rewritten stages (e.g. MLX / PyTorch MPS / Metal shaders) plus careful buffer management—not a small flag. Until then, resolution caps and light NTSC remain the practical levers toward ~15fps.

  • Fullscreen previewF11 or the toolbar button; screen index from Preferences → Control.

  • Device picker — choose the capture device in the main window; fingerprints still appear for ambiguous hardware.

  • New random base — bumps engine.seed and rebuilds the Ntsc instance.

Troubleshooting

On macOS, if you see ObjectiveC / objc duplicate class messages or FFmpeg libavdevice / libavformat symbol warnings when importing OpenCV together with NDI HX or other capture stacks, two different builds of FFmpeg or device glue are being loaded into one process. That is usually a harmless onetime warning; if the app crashes instead, try loading components in a consistent order (avoid mixing a custom DYLD_LIBRARY_PATH that prepends another FFmpeg with the one your Python wheels expect), use a single capture path for a session (e.g. OpenCV or a separate NDI receiver, not both fighting the same AVFoundation device layer), and align NDI HX runtime versions with what your OpenCV / cv2 build was linked against. Reinstalling from a clean uv sync virtualenv isolates many accidental doubleloads.

Symptom What to check
Blank preview Confirm the pipeline thread is running (Health FPS moving). If stderr mentions the NDI module, reinstall from the repo with uv sync (or check platform wheels); pick a capture device in the main window; try lowering preview throttle (ms) in Preferences → Control.
NDI missing in Resolume Confirm uv sync completed and the stream appears under the configured stream name and resolution in Resolumes NDI source list; macOS: allow local network for Python if prompted.
Bad preset / app exits on start Run with a known-good --preset path; if mixer_state.json is corrupt, delete or rename ~/.config/ntsc-rt-py/mixer_state.json (unknown keys are ignored, but invalid values still fall back to the base preset for that file).

Advanced: preset JSON and schema

Internally the engine still uses a single validated MixerPreset object (ntsc_rt/config_models.py: capture, output, NDI, engine, NTSC overrides). Preferences maps onto that model via ParamBus.

Optional JSON Schema for editor tooling can be regenerated from the repo root:

uv run python -c "from pathlib import Path; from ntsc_rt.schema_export import write_mixer_preset_schema; write_mixer_preset_schema(Path('config/mixer_preset.schema.json'))"

That writes config/mixer_preset.schema.json. Save preset in Preferences → Control exports the current live state (including engine + NTSC) through the app without hand-merging files.

CI (Forgejo / Gitea Actions)

.forgejo/workflows/ci.yml runs uv sync --group dev, then uv run black --check ., uv run isort --check ., uv run flake8 ., and uv run python -m unittest discover -s tests -p 'test_*.py' — the same style of commands as in Development below. On tags v*, it builds with uv build and runs uv publish using the PYPI_API_TOKEN repository secret.

Development

uv sync --group dev
uv run black .
uv run isort .
uv run flake8 .
uv run python -m unittest discover -s tests -p 'test_*.py'

macOS cameras

Grant Camera privacy for the terminal app running Python. Use the in-app device list to read fingerprints when descriptions collide.