A Zig port of the VapourSynth-IT
plugin (3:2-pulldown removal for NTSC), with the Avisynth-original
parameters restored, high-bit-depth and 4:2:2/4:4:4 support, full
frame-property support, and @Vector-based SIMD.
Verified bit-exact against the upstream --c reference path across the
integration test grid (8-bit 4:2:0, the only format upstream supports)
and on real-world telecined NTSC VOB samples.
| Item | State |
|---|---|
| Algorithm port (~5200 LoC Zig) | ✅ |
| Bit-exact vs upstream C path | ✅ 8-bit 4:2:0 (198 fixture + 720 real-VOB frames) |
| 10/12/16-bit, 4:2:2, 4:4:4 | ✅ decisions bit-depth-deterministic; no external oracle exists (upstream is YV12-only) — guarded by consistency + golden tests |
All Avisynth params (ref, blend, diMode) |
✅ |
| Frame properties | ✅ standard + diagnostic |
SIMD via @Vector |
✅ ~2× over scalar, ~4× over VIVTC VFM |
| Cross-compile Linux / macOS (x86_64 + aarch64), Windows x86_64 | ✅ |
| CI workflow | ✅ lint + unit (Debug & ReleaseFast) + cross + gating integration suite |
| AI-assisted port | ✅ Anthropic's Claude — verified byte-for-byte against the upstream C reference |
import vapoursynth as vs
core = vs.core
clip = core.bs.VideoSource("source.vob")
clip = core.zit.IT(clip) # default: fps=24, ref="TOP", diMode=3
clip.set_output()Plugin namespace: zit. Function: IT.
Full signature:
core.zit.IT(
clip,
fps=24,
threshold=20,
pthreshold=75,
ref="TOP",
blend=0,
diMode=3,
)| Name | Type | Default | Description |
|---|---|---|---|
clip |
vnode |
— | Input clip. Integer YUV, 8/10/12/16-bit, 4:2:0 / 4:2:2 / 4:4:4. Width a multiple of 16 and ≤ 8192; height even (a multiple of 4 for 4:2:0), ≤ 8192; constant format and frame rate. |
fps |
int |
24 |
24 = inverse telecine (decimate 5→4, output 24000/1001 fps), 30 = field-matching only (input fps preserved). |
threshold |
int |
20 |
Field-match decision sensitivity. Lower = more aggressive matching. Valid range 0–100000. |
pthreshold |
int |
75 |
Progressive-classification threshold for _Combed. Adjusted internally for resolution. Below: frame is ip='P' (clean match); above: ip='I' (deinterlaced). Valid range 0–100000. |
ref |
data |
"TOP" |
Field-order / match-search direction (case-insensitive). One of: TOP, BOTTOM, ALL, NONE (see below). |
blend |
int (0/1) |
0 |
When 1 and fps=24, blends adjacent post-matched frames with a triangular kernel for smoother 24p output. Motion-gated — only fires on high-motion 5-frame blocks. Ignored when fps=30. |
diMode |
int |
3 |
Deinterlace strategy applied when a frame is classified ip='I'. See below. |
| Value | Avisynth name | Effect |
|---|---|---|
"TOP" |
REF_PREV |
Field match looks at the previous frame as the bottom-field source. Standard for TFF source. Same as the VapourSynth upstream's behaviour. |
"BOTTOM" |
REF_NEXT |
Field match looks at the next frame. Use for BFF source. |
"ALL" |
REF_ALL |
Evaluates both prev and next, picks the one with stronger evidence. |
"NONE" |
REF_NONE |
Skips field-matching entirely; every frame is treated as interlaced and dispatched to the deinterlacer. |
| Value | Avisynth name | Behaviour |
|---|---|---|
0 |
DI_MODE_NONE |
No deinterlace. Just field-copy from the chosen match (same as the clean-match path). |
1 |
DI_MODE_DEINTERLACE |
The full Avisynth deinterlacer: per-pixel scores C, P, N, avg(C,P), avg(C,N) and picks the lowest interlace score, with motion-gated vertical-average fallback. |
2 |
DI_MODE_SIMPLE_BLUR |
Vertical (T + 2·C + B) / 4 blur on pixels flagged by the motion map. |
3 |
DI_MODE_ONE_FIELD |
Default. Field-interpolation using motion + simple-blur maps. The VapourSynth upstream hardcodes this mode. |
| Key | Type | Description |
|---|---|---|
_FieldBased |
int | Always 0. Output is progressive after IVTC. |
_Combed |
int | 0 if the algorithm matched cleanly (ip='P'), 1 if it had to deinterlace (ip='I'). |
_DurationNum, _DurationDen |
int | Per-frame duration derived from the output framerate. At fps=24 mode this becomes 1001 / 24000 per frame instead of the source's 1001 / 30000. |
_Matrix, _Transfer, _Primaries, _ChromaLocation, _Range/_ColorRange, _SARNum/_SARDen |
int | Inherited from the source frame via propSrc. Pass-through unchanged. |
| Key | Type | Description |
|---|---|---|
ITMatch |
utf8 (1 char) | Match decision: 'C', 'P', 'N' (uppercase = strong, lowercase = weak), 'U' if not evaluated. |
ITMflag |
utf8 (1 char) | Decimation code in fps=24 mode: 'D'/'d'/'x'/'y'/'z'/'+'/'.'. 'U' in fps=30 mode. |
ITIpFlag |
utf8 (1 char) | 'P' (progressive) or 'I' (interlaced); 'U' if not run. |
ITIvC, ITIvP, ITIvN, ITIvM |
int | Interlace-evidence counters from EvalIV against C, P, N, and the chosen match M. |
ITDiffP0, ITDiffP1, ITDiffS0, ITDiffS1 |
int | Motion-map stats: rough motion (P0/P1) and saturated motion (S0/S1) on even/odd field rows. |
ITBlended |
int (0/1) | 1 if the blend=true code path produced this output frame. |
Convention follows VFM/VDecimate (camelCase, plugin-name prefix, no dots — VS API 4 silently rejects keys with dots).
Requires Zig 0.16.0+. The VapourSynth API 4 bindings come from the
vapoursynth-zig package
pinned in build.zig.zon.
zig build --release=fast # native shared library -> zig-out/lib/libzit.so
zig build test # unit tests (also run with --release=fast in CI)
zig build cross # release artefacts for all five targetsCross-compile produces (ReleaseFast, stripped):
zig-out/linux-x86_64/libzit.so,zig-out/linux-aarch64/libzit.sozig-out/macos-x86_64/libzit.dylib,zig-out/macos-aarch64/libzit.dylibzig-out/windows-x86_64/zit.dll
pip install vapoursynth-zitThe wheel installs the platform-matched binary into VapourSynth's
auto-discovery path (site-packages/vapoursynth/plugins/), so
core.zit.IT(...) is available without any further LoadPlugin
calls.
Pre-built wheels are available for Linux (x86_64, aarch64), macOS (x86_64, aarch64), and Windows (x86_64).
If you'd rather not pull in pip, grab the matching *.zip from the
Releases page
and drop the binary into a VapourSynth plugin directory:
- Linux:
/usr/local/lib/vapoursynth/ - macOS:
~/Library/ApplicationSupport/VapourSynth/plugins/ - Windows:
vapoursynth64/plugins/next tovsedit.exe
500 frames of a 720×480 NTSC telecined VOB, ReleaseFast build:
| Pipeline | fps |
|---|---|
core.zit.IT(clip, fps=30) |
2697 |
core.zit.IT(clip, fps=24) |
2236 |
core.zit.IT(clip, fps=24, diMode=1) |
2355 |
core.zit.IT(clip, fps=24, diMode=2) |
2459 |
core.zit.IT(clip, fps=24, blend=1) |
1725 |
core.vivtc.VFM(clip, order=1) |
596 |
core.vivtc.VFM → core.vivtc.VDecimate |
1271 |
zit fps=30 is ~4.5× faster than vivtc.VFM; zit fps=24 is ~1.8×
faster than vivtc.VFM + VDecimate.
The VapourSynth upstream (HomeOfVapourSynthEvolution/VapourSynth-IT)
ported only a subset of the Avisynth original. This port reintroduces
everything plus fixes a few latent bugs:
- Avisynth-original parameters back:
ref(TOP/BOTTOM/ALL/NONE),blend,diMode(0/1/2/3). The upstream hardcodedref="TOP"/blend=false/diMode=3and removed the parameters. - Frame properties (
_FieldBased,_Combed,_Duration*, inheritance of source props, plusIT*diagnostics). Upstream callsnewVideoFrame(propSrc=null)so output frames carry no metadata, which breaks downstreamcore.resize.*colorspace handling. - Threading: registered as
fmParallelRequests. Upstream usesfmParalleldespite sharing mutable per-instance state across calls — a latent race condition that VS R55+ exposes more often. - Frame request range: widened to cover what the algorithm actually
reads (
[base-2, base+6]for fps=24,[n-2, n+2]for fps=30 — plus[base-3, base+7]whenblend=true). Upstream relied on the API 3 syncgetFrameto retrieve neighbours, which is no longer permitted from insidegetFrameFilterunder API 4. - Edge clamping of frame indices passed to
getFrameFilter. The upstream algorithm fetchesn-1even atn=0; under API 3 the core silently clamped, under API 4 it returns null and dereferences crash.
The reference build under reference/vapoursynth-cpp-api4/ applies the
same framework-level fixes (otherwise it would segfault under VS R76)
and is what the bit-exact comparison test runs against.
src/
├── plugin.zig # VapourSynth plugin entry (VapourSynthPluginInit2)
├── filter.zig # Filter instance + getFrame lifecycle + frame-prop setting
├── state.zig # CFrameInfo, CTFblockInfo, CallState
├── plane.zig # syp/dyp accessors, adjPara, clipFrame/X/Y
├── edge.zig # makeDeMap (SIMD)
├── eval_iv.zig # evalIv (SIMD)
├── motion.zig # makeMotionMap, makeMotionMap2Max/Min, makeSimpleBlurMap (SIMD)
├── scene.zig # checkSceneChange (SIMD)
├── decide.zig # compCp / compCn / decide / setFt
├── output.zig # copyCPNField / deintOneField / simpleBlur / deinterlace
├── blend.zig # BlendFrame_YV12 port
├── simd.zig # @Vector helpers (pavgb, absDiff, subSat, expandPairs)
└── scalar.zig # scalar twins of the SIMD helpers (pavgbScore, toMapByte)
reference/
├── avisynth/ # original IT_YV12 0.1.03 source (read-only)
├── vapoursynth-cpp/ # upstream VS-IT @ 6fc9be8 (read-only)
└── vapoursynth-cpp-api4/ # mechanical API3→API4 port of upstream; build for bit-exact comparison
tests/integration/ # pytest suite — properties, golden hashes, upstream-compare, determinism, bit depth
scripts/ # gen_testclip / param_grid / regen_golden / compare_upstream / compare_vivtc / ...
docs/upstream_reference.md # design notes
- Original IT 0.051 — thejam79 (2002)
- Avisynth IT_YV12 0.1.03 — minamina (2003)
- 64-bit / 8k mod — poodle
- VapourSynth port — msg7086 (2014)
- Zig port — this repo
GPL-2.0-or-later (inherited from upstream). See LICENSE.