Created
April 10, 2026 22:33
-
-
Save jbn/5e0e91f8bb2e3ce6efa3f07f6467fa0a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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