Recipes

Patterns for common jobs are expressed here as complete scripts. Installation hints are included for recipes that require more than VapourSynth and vsanalog.

Cropping Out Captions and Half-Lines

525-line content often carries closed caption data on lines 21 and 284. Both of those lines are in the 486i default ST 170 standard active window used by vsanalog.decode_4fsc_video() and are likely unwanted in the final video.

An active window size can be selected during the decode (see Active Window) to remove those caption data lines and the analog half lines at the edge of the active window, giving an even-lined 482i crop for the interlaced weave:

import vsanalog

clip = vsanalog.decode_4fsc_video(
    "capture.tbc", first_active_line=22, last_active_line=525)

The top row is now a field 1 line, so output switches from bottom-field-first to top-field-first, reflected automatically in the _FieldBased frame property for downstream filters to use.

Inverse Telecine with VIVTC

Film transferred to 525-line video at 2:3 pulldown decodes as 29.97 fps interlaced frames, two in every five of which are spurious combinations of two film frames. VIVTC undoes that: VFM re-pairs fields into the original film frames and VDecimate drops the one duplicate in five that matching leaves behind, returning 23.976 fps progressive.

pip install vapoursynth vsanalog vapoursynth-vivtc
uv add vapoursynth vsanalog vapoursynth-vivtc

Both filters make their decisions on integer frames — VFM on 8-bit ones, VDecimate on 8- to 16-bit — but each takes a clip2 of the same length to emit output frames from. Analyse a converted copy and hand the decoder’s own 32-bit float clip through clip2, and the frames that reach the rest of your script are matched but contain original lines from decoder output.

import vapoursynth as vs
import vsanalog

src = vsanalog.decode_4fsc_video("film_transfer.tbc")

# Field matching: decisions from an 8-bit copy, frames from original.
# order=0 (bottom-field-first) for decode_4fsc_video's default 486i
# window, but _FieldBased frame property will be used first anyway.
# Optionally, y0 and y1 exclude lines with captions data from calculations.
match_reference = src.resize.Point(format=vs.YUV444P8)
matched = match_reference.vivtc.VFM(order=0, y0=0, y1=2, clip2=src)

# Decimation: VDecimate wants integer metrics too, so measure a 16-bit
# view of the matched clip and again emit float frames.
dedupe_reference = matched.resize.Point(format=vs.YUV444P16)
film = dedupe_reference.vivtc.VDecimate(clip2=matched)

film.set_output()

film is YUV444PS at 24000/1001 fps with _FieldBased cleared, and each frame carries VFMMatch and _Combed from the matcher. Frames the matcher could not pair cleanly (a bad edit, a broken cadence) come out combed; inspect _Combed and post-process those with a deinterlacer if there are enough to matter.

The 8-bit analysis copy is deliberate: VFM’s combing threshold cthresh and the field-difference metrics are calibrated in 8-bit terms, and the PyPI release’s VFM accepts nothing deeper in any case. It only affects which fields get paired, never the pixels you keep.

CRT-Style Interlaced Display

Interlaced video was never meant to be seen a frame at a time: a CRT painted one field while lines from the other faded, and the eye and brain did the rest. vsfieldkit’s scan_interlaced reproduces that display and perception, one output frame per field with the freshly painted lines over the retained ones, a “bob” without interpolation, showing exactly the lines the tape or disc carried.

pip install vapoursynth vsanalog vsfieldkit
uv add vapoursynth vsanalog vsfieldkit
import vsanalog
import vsfieldkit

src = vsanalog.decode_4fsc_video("capture.tbc")

# Each output frame paints one field's lines fresh at 120% brightness over
# the prior field's, as a phosphor's newer scan outshines its older one.
scanned = vsfieldkit.scan_interlaced(src, attack_factor=1.2, decay_factor=0.2)

scanned.set_output()

The result has twice the frames at twice the frame rate (60000/1001 for 525-line sources), tagged progressive. scan_interlaced reads the decoded clip’s _FieldBased for the field order and takes the 32-bit float YUV444PS directly. attack_factor multiplies the fresh field’s luma (clamped at the format’s white), decay_factor dims the retained field toward black.

Deinterlace and Denoise with vs-jetpack

Tape noise is best attacked once the fields are woven into full frames, and vs-jetpack covers both halves: QTGMC in vsdeinterlace, and in vsdenoise a motion-compensated degrain to steady a reference clip, BM3D for the luma and NLMeans for the chroma. Every stage here takes the decoder’s YUV444PS as-is. Colorimetry work and integer conversion can happen after.

The extras bring in the native plugins these functions call, a few of whose wheels live on JET’s own index rather than PyPI — hence the second index in the install line. Add nvidia (or amd, cl) to the extras for the GPU BM3D and NLMeans backends, which the functions pick up automatically.

pip install vapoursynth vsanalog "vsjetpack[deinterlace,denoise]" \
    --extra-index-url https://jaded-encoding-thaumaturgy.github.io/vs-wheels/simple
uv add vapoursynth vsanalog "vsjetpack[deinterlace,denoise]" \
    --index https://jaded-encoding-thaumaturgy.github.io/vs-wheels/simple
import vsanalog
from vsdeinterlace import QTempGaussMC
from vsdenoise import MVToolsPreset, bm3d, mc_degrain, nl_means

src = vsanalog.decode_4fsc_video("tape.tbc", "tape_chroma.tbc")

# Field-order and format come from the decoded clip's frame properties.
progressive = QTempGaussMC().deinterlace(src)

# A lightly degrained, motion-compensated reference: the block matcher and
# NLMeans both search it instead of the noisy picture, so they find real
# detail rather than noise that happens to look alike.
ref = mc_degrain(progressive, preset=MVToolsPreset.HQ_SAD, thsad=100)

# BM3D on the luma plane, using the reference as its basic estimate.
denoised = bm3d(progressive, sigma=0.8, tr=2,
                profile=bm3d.Profile.NORMAL, ref=ref, planes=0)

# NLMeans on the two chroma planes, searching the same reference.
denoised = nl_means(denoised, 0.2, tr=2, ref=ref, planes=[1, 2])

denoised.set_output()

sigma, h (NLMeans’ second positional argument) and thsad are the strengths to tune to the tape; tr is the temporal radius in frames each side. BM3D’s non-legacy backends only take 32-bit float, so the decoder’s output is exactly what they want; the chroma planes pass through the luma-only BM3D call untouched and are then denoised in place by NLMeans, whose planes=[1, 2] merges its result over the luma BM3D produced.