Skip to content

Instantly share code, notes, and snippets.

@jbn
Created April 10, 2026 22:33
Show Gist options
  • Select an option

  • Save jbn/5e0e91f8bb2e3ce6efa3f07f6467fa0a to your computer and use it in GitHub Desktop.

Select an option

Save jbn/5e0e91f8bb2e3ce6efa3f07f6467fa0a to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.14"
# dependencies = [
# "opencv-python",
# ]
# ///
"""
90s Pay-Per-View TV Scrambler Simulator
=======================================
Faithfully replicates the analog scrambling techniques used by cable/satellite
providers in the 1990s:
1. SYNC SUPPRESSION - Shifts scanlines horizontally (simulates lost H-sync)
2. VERTICAL ROLL - The picture rolls vertically (simulates lost V-sync)
3. VIDEO INVERSION - Luminance is inverted in bands (Videocipher-style)
4. COLOR DISTORTION - Chroma phase corruption
5. AUDIO CORRUPTION - Buzzing/static replacing the real audio (encrypted audio)
6. SCANLINE ARTIFACTS - Adds visible scanline noise and VHS-era grain
7. PARTIAL DECODE FLICKER - Brief moments where the picture almost resolves
(just like the real thing!)
Usage:
python ppv_scrambler.py input_video.mp4 [output_video.mp4] [--mode MODE]
Modes:
sync - Sync suppression only (cheapest cable scrambling)
invert - Video inversion only (Videocipher-style)
full - Full PPV scramble with all effects (default)
heavy - Maximum scrambling, nearly unwatchable
"""
import cv2
import numpy as np
import argparse
import sys
import os
import struct
import wave
import tempfile
import subprocess
import shutil
class PPVScrambler:
"""Simulates 90s-era analog pay-per-view scrambling."""
def __init__(self, mode="full", seed=42):
self.mode = mode
self.rng = np.random.RandomState(seed)
self.frame_count = 0
# Scrambling parameters that drift over time (like real analog systems)
self.sync_offset_phase = self.rng.uniform(0, 2 * np.pi)
self.roll_speed = self.rng.uniform(1.5, 3.0) # lines per frame of vertical roll
self.inversion_band_phase = self.rng.uniform(0, 2 * np.pi)
# "Almost decoding" moments - brief windows where picture partially clears
self.partial_decode_interval = self.rng.randint(90, 200)
self.partial_decode_duration = self.rng.randint(5, 15)
def _is_partial_decode(self):
"""Check if we're in a 'partial decode' flicker window."""
cycle_pos = self.frame_count % self.partial_decode_interval
return cycle_pos < self.partial_decode_duration
def apply_sync_suppression(self, frame):
"""
Simulate horizontal sync suppression.
Real scramblers removed the horizontal sync pulse, causing each scanline
to start at the wrong position. The TV would display the picture shifted
and torn horizontally, with the shift amount varying per line.
"""
h, w = frame.shape[:2]
result = np.zeros_like(frame)
t = self.frame_count * 0.07 + self.sync_offset_phase
# Generate per-line horizontal shifts
# Real sync loss created smooth-ish drift patterns, not random noise
line_indices = np.arange(h, dtype=np.float64)
# Primary drift: slow sinusoidal (the main "tear" pattern)
shifts = (
np.sin(line_indices * 0.02 + t) * (w * 0.25) +
np.sin(line_indices * 0.005 + t * 0.3) * (w * 0.15) +
# Occasional sharp discontinuity (sync pulse remnants)
np.where(
np.sin(line_indices * 0.01 + t * 2.1) > 0.85,
w * 0.3 * np.sin(t * 1.7),
0
)
)
if self._is_partial_decode():
shifts *= 0.1 # Almost locks on
shifts = shifts.astype(np.int32)
for y in range(h):
shift = shifts[y] % w
result[y] = np.roll(frame[y], shift, axis=0)
return result
def apply_vertical_roll(self, frame):
"""
Simulate vertical sync loss.
Without vertical sync, the TV can't tell where the top of the frame is.
The picture rolls continuously, with a visible black bar (the vertical
blanking interval) scrolling through.
"""
h, w = frame.shape[:2]
t = self.frame_count
# Calculate roll offset
roll_lines = int(t * self.roll_speed) % h
if self._is_partial_decode():
roll_lines = roll_lines % max(1, h // 20) # Almost stable
# Roll the frame
result = np.roll(frame, roll_lines, axis=0)
# Insert a "blanking interval" bar — the black band you'd see rolling through
blanking_height = max(4, h // 30)
blanking_start = roll_lines % h
blanking_end = min(blanking_start + blanking_height, h)
# The blanking bar: mostly black with some sync artifacts
result[blanking_start:blanking_end] = 0
# Add some noise in the blanking area (sync pulse garbage)
noise = self.rng.randint(0, 40, (blanking_end - blanking_start, w, 3), dtype=np.uint8)
result[blanking_start:blanking_end] = noise[:blanking_end - blanking_start]
return result
def apply_video_inversion(self, frame):
"""
Simulate Videocipher-style video inversion.
The Videocipher system inverted the luminance of horizontal bands.
The inversion pattern changed with each frame based on an encrypted
control signal. Without the key, you'd see bands of negative image
mixed with positive image, shifting over time.
"""
h, w = frame.shape[:2]
t = self.frame_count * 0.1 + self.inversion_band_phase
# Work in YCrCb so we can invert luminance only (like the real system)
ycrcb = cv2.cvtColor(frame, cv2.COLOR_BGR2YCrCb)
y_channel = ycrcb[:, :, 0].astype(np.float32)
# Create inversion mask: bands that shift over time
line_indices = np.arange(h, dtype=np.float64)
# Multiple overlapping band patterns (simulates the pseudo-random
# encryption pattern that the Videocipher used)
mask = (
np.sin(line_indices * 0.05 + t) *
np.sin(line_indices * 0.013 + t * 0.7)
)
# Threshold to create distinct inverted/normal bands
inversion = (mask > 0).astype(np.float32)
if self._is_partial_decode():
inversion *= 0.3 # Partial inversion during decode attempts
# Apply per-line inversion
for y in range(h):
if inversion[y] > 0.5:
y_channel[y] = 255.0 - y_channel[y]
ycrcb[:, :, 0] = np.clip(y_channel, 0, 255).astype(np.uint8)
return cv2.cvtColor(ycrcb, cv2.COLOR_YCrCb2BGR)
def apply_color_corruption(self, frame):
"""
Simulate chroma phase distortion.
When the color burst reference is corrupted, the TV can't properly
decode colors. You get shifting rainbow tints and desaturated bands.
"""
h, w = frame.shape[:2]
t = self.frame_count * 0.05
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV).astype(np.float32)
# Shift hue in rolling bands
line_indices = np.arange(h, dtype=np.float64)
hue_shift = np.sin(line_indices * 0.03 + t) * 60 + np.sin(t * 0.4) * 30
if self._is_partial_decode():
hue_shift *= 0.15
for y in range(h):
hsv[y, :, 0] = (hsv[y, :, 0] + hue_shift[y]) % 180
# Desaturate in some bands
sat_mask = np.sin(line_indices * 0.02 + t * 0.6)
for y in range(h):
if sat_mask[y] > 0.3:
hsv[y, :, 1] *= 0.3
hsv = np.clip(hsv, 0, 255).astype(np.uint8)
return cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
def apply_scanline_noise(self, frame):
"""
Add analog noise artifacts.
Real scrambled signals had:
- Herringbone interference patterns
- Random noise bursts
- Visible scanline structure
"""
h, w = frame.shape[:2]
t = self.frame_count
result = frame.astype(np.float32)
# Herringbone pattern (diagonal interference lines)
yy, xx = np.mgrid[0:h, 0:w]
herringbone = np.sin((xx * 0.5 + yy * 0.3 + t * 3.0)) * 15
herringbone = np.stack([herringbone] * 3, axis=-1)
result += herringbone
# Random noise (stronger during non-decode moments)
noise_strength = 8 if self._is_partial_decode() else 25
noise = self.rng.randn(h, w, 3) * noise_strength
result += noise
# Occasional horizontal noise burst lines
if self.rng.random() > 0.7:
num_noise_lines = self.rng.randint(1, 6)
for _ in range(num_noise_lines):
y = self.rng.randint(0, h)
result[y] = self.rng.randint(0, 255, (w, 3))
return np.clip(result, 0, 255).astype(np.uint8)
def apply_vbi_overlay(self, frame):
"""
Simulate the Vertical Blanking Interval data bars.
Real cable systems transmitted authorization data in the VBI
(the top few lines of the picture). On scrambled channels, you'd
sometimes see these as flickering colored bars at the top.
"""
h, w = frame.shape[:2]
t = self.frame_count
# VBI data bars at top of frame (lines 0-15 or so)
vbi_height = max(4, h // 35)
if t % 3 < 2: # Flickers
# Create data-like pattern
bar = np.zeros((vbi_height, w, 3), dtype=np.uint8)
# Simulate data bits as black/white transitions
bit_width = max(1, w // 200)
for x in range(0, w, bit_width):
val = 255 if self.rng.random() > 0.5 else 0
end_x = min(x + bit_width, w)
bar[:, x:end_x] = val
# Tint it slightly cyan (like real VBI data)
bar[:, :, 0] = (bar[:, :, 0] * 0.7).astype(np.uint8)
frame[:vbi_height] = bar
return frame
def scramble_frame(self, frame):
"""Apply scrambling effects based on the selected mode."""
self.frame_count += 1
if self.mode == "sync":
frame = self.apply_sync_suppression(frame)
frame = self.apply_scanline_noise(frame)
frame = self.apply_vbi_overlay(frame)
elif self.mode == "invert":
frame = self.apply_video_inversion(frame)
frame = self.apply_color_corruption(frame)
frame = self.apply_scanline_noise(frame)
elif self.mode == "full":
frame = self.apply_sync_suppression(frame)
frame = self.apply_vertical_roll(frame)
frame = self.apply_video_inversion(frame)
frame = self.apply_color_corruption(frame)
frame = self.apply_scanline_noise(frame)
frame = self.apply_vbi_overlay(frame)
elif self.mode == "heavy":
# Double up on sync suppression for maximum destruction
frame = self.apply_sync_suppression(frame)
frame = self.apply_vertical_roll(frame)
frame = self.apply_video_inversion(frame)
frame = self.apply_sync_suppression(frame) # Second pass
frame = self.apply_color_corruption(frame)
frame = self.apply_scanline_noise(frame)
frame = self.apply_vbi_overlay(frame)
return frame
def generate_scrambled_audio(duration_sec, sample_rate=44100):
"""
Generate scrambled audio — simulates encrypted/corrupted PPV audio.
Real Videocipher audio was digitized and DES-encrypted. Without the key,
you'd hear a harsh buzzing/warbling sound (the encrypted digital data
being interpreted as audio) with occasional fragments of the real audio
bleeding through.
"""
num_samples = int(duration_sec * sample_rate)
t = np.linspace(0, duration_sec, num_samples)
# Base: the characteristic PPV buzz (encrypted digital data as audio)
# This was typically a ~15.734 kHz horizontal sync frequency heterodyne
buzz = np.sin(2 * np.pi * 480 * t) * 0.3
buzz += np.sin(2 * np.pi * 960 * t) * 0.15
buzz += np.sin(2 * np.pi * 15734 * t) * 0.05 # H-sync whine
# Warbling modulation (the encryption pattern cycling)
warble = np.sin(2 * np.pi * 2.5 * t) * 0.5 + 0.5
buzz *= warble
# Random static bursts
static = np.random.randn(num_samples) * 0.1
# Gate the static to come in bursts
gate = (np.sin(2 * np.pi * 0.7 * t) > 0.6).astype(np.float32)
static *= gate
audio = buzz + static
# Occasional brief "almost decodes" — quick volume drops (silence)
for i in range(int(duration_sec / 3)):
start = np.random.randint(0, max(1, num_samples - sample_rate))
length = np.random.randint(sample_rate // 30, sample_rate // 10)
end = min(start + length, num_samples)
audio[start:end] *= 0.05 # Near silence during "decode attempt"
# Normalize
audio = audio / (np.max(np.abs(audio)) + 1e-8) * 0.7
return (audio * 32767).astype(np.int16)
def process_video(input_path, output_path, mode="full"):
"""Process a video file with PPV scrambling effects."""
if not os.path.exists(input_path):
print(f"Error: Input file '{input_path}' not found.")
sys.exit(1)
cap = cv2.VideoCapture(input_path)
if not cap.isOpened():
print(f"Error: Could not open video '{input_path}'.")
sys.exit(1)
# Get video properties
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
duration = total_frames / fps if fps > 0 else 0
print(f"╔══════════════════════════════════════════════╗")
print(f"║ 90s PPV SCRAMBLER SIMULATOR ║")
print(f"╠══════════════════════════════════════════════╣")
print(f"║ Input: {os.path.basename(input_path):<32s}║")
print(f"║ Resolution: {width}x{height:<27}║")
print(f"║ FPS: {fps:<32.1f}║")
print(f"║ Duration: {duration:<32.1f}║")
print(f"║ Frames: {total_frames:<32d}║")
print(f"║ Mode: {mode:<32s}║")
print(f"╚══════════════════════════════════════════════╝")
print()
# Create scrambler
scrambler = PPVScrambler(mode=mode)
# Write scrambled video (without audio first)
temp_dir = tempfile.mkdtemp()
temp_video = os.path.join(temp_dir, "scrambled_video.mp4")
temp_audio = os.path.join(temp_dir, "scrambled_audio.wav")
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
writer = cv2.VideoWriter(temp_video, fourcc, fps, (width, height))
frame_num = 0
while True:
ret, frame = cap.read()
if not ret:
break
scrambled = scrambler.scramble_frame(frame)
writer.write(scrambled)
frame_num += 1
if frame_num % 30 == 0 or frame_num == total_frames:
pct = (frame_num / total_frames) * 100 if total_frames > 0 else 0
bar_len = 30
filled = int(bar_len * frame_num / max(total_frames, 1))
bar = "█" * filled + "░" * (bar_len - filled)
print(f"\r Scrambling: [{bar}] {pct:5.1f}% ({frame_num}/{total_frames})", end="", flush=True)
print()
cap.release()
writer.release()
# Generate scrambled audio
print(" Generating encrypted audio signal...", end="", flush=True)
scrambled_audio = generate_scrambled_audio(duration)
# Write audio to WAV
with wave.open(temp_audio, 'w') as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(44100)
wav.writeframes(scrambled_audio.tobytes())
print(" done.")
# Mux video + audio with ffmpeg
print(" Muxing video and audio...", end="", flush=True)
ffmpeg_cmd = [
"ffmpeg", "-y", "-loglevel", "error",
"-i", temp_video,
"-i", temp_audio,
"-c:v", "libx264", "-preset", "fast", "-crf", "18",
"-c:a", "aac", "-b:a", "128k",
"-shortest",
output_path
]
try:
subprocess.run(ffmpeg_cmd, check=True, capture_output=True)
print(" done.")
except FileNotFoundError:
print("\n Warning: ffmpeg not found, outputting video-only.")
shutil.copy(temp_video, output_path)
except subprocess.CalledProcessError as e:
print(f"\n Warning: ffmpeg error, outputting video-only.")
print(f" {e.stderr.decode()[:200]}")
shutil.copy(temp_video, output_path)
# Cleanup
shutil.rmtree(temp_dir, ignore_errors=True)
file_size = os.path.getsize(output_path) / (1024 * 1024)
print(f"\n ✓ Scrambled output saved to: {output_path} ({file_size:.1f} MB)")
print(f" ✓ To view: open the file in any video player")
print()
print(" ┌─────────────────────────────────────────────┐")
print(" │ CALL 1-800-555-ORDER TO UNSCRAMBLE THIS │")
print(" │ CHANNEL. $49.95 PER EVENT. │")
print(" └─────────────────────────────────────────────┘")
def main():
parser = argparse.ArgumentParser(
description="90s PPV TV Scrambler Simulator",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Modes:
sync - Sync suppression only (basic cable scrambling)
invert - Video inversion only (Videocipher-style)
full - Full PPV scramble with all effects (default)
heavy - Maximum scrambling, nearly unwatchable
Examples:
python ppv_scrambler.py movie.mp4
python ppv_scrambler.py movie.mp4 scrambled.mp4 --mode invert
python ppv_scrambler.py movie.mp4 --mode heavy
"""
)
parser.add_argument("input", help="Input video file")
parser.add_argument("output", nargs="?", default=None, help="Output video file (default: input_scrambled.mp4)")
parser.add_argument("--mode", choices=["sync", "invert", "full", "heavy"], default="full",
help="Scrambling mode (default: full)")
args = parser.parse_args()
if args.output is None:
base, ext = os.path.splitext(args.input)
args.output = f"{base}_scrambled.mp4"
process_video(args.input, args.output, args.mode)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment