I will now generate the complete, production-ready Barrier-Grid (Scanimation / Kinegram) Generator and Simulator as a single, self-contained HTML application.
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scanimation Studio — Barrier-Grid Animation Generator & Simulator</title>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: {
50: '#f0fdf4',
500: '#10b981',
600: '#059669',
700: '#047857',
},
aggie: '#500000',
},
fontFamily: {
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'monospace'],
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
}
}
}
}
</script>
<style>
/* Custom utility scrollbar & canvas viewport styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #090d16;
}
::-webkit-scrollbar-thumb {
background: #1e293b;
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: #334155;
}
.checkered-bg {
background-color: #0f172a;
background-image:
linear-gradient(45deg, #1e293b 25%, transparent 25%),
linear-gradient(-45deg, #1e293b 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #1e293b 75%),
linear-gradient(-45deg, transparent 75%, #1e293b 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
}
.touch-none-all {
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
</style>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex flex-col font-sans antialiased overflow-x-hidden select-none">
<!-- Top Navigation Bar -->
<header class="border-b border-slate-800 bg-slate-900/80 backdrop-blur-md px-6 py-3.5 flex items-center justify-between z-30 sticky top-0">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-600 flex items-center justify-center shadow-lg shadow-emerald-500/20">
<svg class="w-5 h-5 text-slate-950" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12"/>
</svg>
</div>
<div>
<h1 class="text-base font-bold tracking-tight text-white flex items-center gap-2">
Scanimation Studio
<span class="text-[10px] font-mono px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 uppercase tracking-widest">PRO v2.0</span>
</h1>
<p class="text-xs text-slate-400">Barrier-Grid Optical Synthesizer & Physics Simulator</p>
</div>
</div>
<!-- Quick stats header tags -->
<div class="hidden md:flex items-center gap-4 text-xs font-mono text-slate-300">
<div class="bg-slate-800/80 px-3 py-1.5 rounded-md border border-slate-700/60 flex items-center gap-2">
<span class="text-slate-400">Pitch (P):</span>
<span id="badge-pitch" class="text-emerald-400 font-bold">18px</span>
</div>
<div class="bg-slate-800/80 px-3 py-1.5 rounded-md border border-slate-700/60 flex items-center gap-2">
<span class="text-slate-400">Duty:</span>
<span id="badge-duty" class="text-teal-400 font-bold">16.7%</span>
</div>
<div class="bg-slate-800/80 px-3 py-1.5 rounded-md border border-slate-700/60 flex items-center gap-2">
<span class="text-slate-400">Bar (B):</span>
<span id="badge-bar" class="text-indigo-400 font-bold">15px</span>
</div>
</div>
</header>
<!-- Workspace Grid Layout -->
<div class="flex-1 grid grid-cols-1 lg:grid-cols-12 min-h-0">
<!-- Controls Sidebar (Left Column: 4 cols) -->
<aside class="lg:col-span-4 border-r border-slate-800 bg-slate-900/60 p-5 overflow-y-auto space-y-6 max-h-[calc(100vh-61px)]">
<!-- 1. Source Image & Presets Section -->
<section class="space-y-3">
<div class="flex items-center justify-between">
<label class="text-xs font-semibold uppercase tracking-wider text-slate-400 flex items-center gap-2">
<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"/></svg>
1. Graphic Source
</label>
<span class="text-[11px] text-slate-500 font-mono">Silhouette Mode</span>
</div>
<!-- Presets quick picker -->
<div class="grid grid-cols-2 gap-2">
<button id="preset-aggie" class="preset-btn px-3 py-2 rounded-lg text-xs font-medium bg-red-950/40 hover:bg-red-900/50 text-red-200 border border-red-800/50 transition-all flex items-center gap-2 justify-center">
<span class="w-2 h-2 rounded-full bg-red-500"></span>
Aggie Band Marcher
</button>
<button id="preset-heart" class="preset-btn px-3 py-2 rounded-lg text-xs font-medium bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 transition-all flex items-center gap-2 justify-center">
<span class="w-2 h-2 rounded-full bg-rose-500"></span>
Pulsing Heart
</button>
<button id="preset-gear" class="preset-btn px-3 py-2 rounded-lg text-xs font-medium bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 transition-all flex items-center gap-2 justify-center">
<span class="w-2 h-2 rounded-full bg-amber-500"></span>
Rotating Gear
</button>
<button id="preset-runner" class="preset-btn px-3 py-2 rounded-lg text-xs font-medium bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 transition-all flex items-center gap-2 justify-center">
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
Stride Runner
</button>
</div>
<!-- Custom Upload button -->
<div class="relative group">
<input type="file" id="image-upload" accept="image/*" class="absolute inset-0 opacity-0 cursor-pointer w-full h-full z-10">
<div class="w-full border border-dashed border-slate-700 rounded-lg p-3 text-center bg-slate-800/40 group-hover:bg-slate-800/80 group-hover:border-slate-500 transition-all">
<p class="text-xs text-slate-300 font-medium">Drop custom silhouette or <span class="text-emerald-400 underline">Browse File</span></p>
<p class="text-[10px] text-slate-500 mt-0.5">PNG, SVG, or high-contrast JPG</p>
</div>
</div>
</section>
<!-- 2. Motion Synthesis Mode -->
<section class="space-y-3 pt-3 border-t border-slate-800">
<div class="flex items-center justify-between">
<label class="text-xs font-semibold uppercase tracking-wider text-slate-400 flex items-center gap-2">
<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"/></svg>
2. Motion Generator
</label>
</div>
<div class="grid grid-cols-4 gap-1.5 p-1 bg-slate-950/70 rounded-lg border border-slate-800 text-xs">
<button data-motion="march" class="motion-btn py-1.5 rounded font-medium text-slate-300 transition-all hover:bg-slate-800 active-motion bg-slate-800 text-emerald-400">March</button>
<button data-motion="rotate" class="motion-btn py-1.5 rounded font-medium text-slate-400 transition-all hover:bg-slate-800">Spin</button>
<button data-motion="pulse" class="motion-btn py-1.5 rounded font-medium text-slate-400 transition-all hover:bg-slate-800">Pulse</button>
<button data-motion="wave" class="motion-btn py-1.5 rounded font-medium text-slate-400 transition-all hover:bg-slate-800">Wave</button>
</div>
<!-- Motion magnitude parameter -->
<div class="space-y-1.5">
<div class="flex justify-between text-xs">
<span class="text-slate-400">Motion Magnitude</span>
<span id="motion-mag-val" class="font-mono text-emerald-400 font-semibold">18 px</span>
</div>
<input type="range" id="motion-mag" min="4" max="60" value="18" class="w-full h-1.5 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-emerald-500">
</div>
</section>
<!-- 3. Interleaving Geometric Parameters -->
<section class="space-y-3 pt-3 border-t border-slate-800">
<label class="text-xs font-semibold uppercase tracking-wider text-slate-400 flex items-center gap-2">
<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75"/></svg>
3. Geometric Calibration
</label>
<!-- Frame Count N -->
<div class="space-y-1.5">
<div class="flex justify-between text-xs">
<span class="text-slate-300">Phase Frame Count (N)</span>
<span id="n-val" class="font-mono text-emerald-400 font-bold">6 frames</span>
</div>
<input type="range" id="param-n" min="3" max="10" value="6" step="1" class="w-full h-1.5 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-emerald-500">
<div class="flex justify-between text-[10px] text-slate-500 font-mono">
<span>3 (Staccato)</span>
<span>6 (Optimal)</span>
<span>10 (Fluid/Dim)</span>
</div>
</div>
<!-- Slit Width w -->
<div class="space-y-1.5">
<div class="flex justify-between text-xs">
<span class="text-slate-300">Slit Width (w)</span>
<span id="w-val" class="font-mono text-emerald-400 font-bold">3 px</span>
</div>
<input type="range" id="param-w" min="1" max="8" value="3" step="1" class="w-full h-1.5 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-emerald-500">
<div class="flex justify-between text-[10px] text-slate-500 font-mono">
<span>1 px (Hi-DPI)</span>
<span>3 px (Standard)</span>
<span>8 px (Macro)</span>
</div>
</div>
<!-- Invert Silhouette checkbox -->
<div class="flex items-center justify-between pt-1">
<span class="text-xs text-slate-400">Dark Silhouette on White</span>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="invert-colors" checked class="sr-only peer">
<div class="w-9 h-5 bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-emerald-600"></div>
</label>
</div>
</section>
<!-- 4. Simulator Inspection & Guide Controls -->
<section class="space-y-3 pt-3 border-t border-slate-800">
<label class="text-xs font-semibold uppercase tracking-wider text-slate-400 flex items-center gap-2">
<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"/></svg>
4. Inspection & Alignment
</label>
<!-- Slit Bar Opacity -->
<div class="space-y-1.5">
<div class="flex justify-between text-xs">
<span class="text-slate-300">Mask Bar Opacity (Inspection)</span>
<span id="opacity-val" class="font-mono text-emerald-400 font-bold">100%</span>
</div>
<input type="range" id="param-opacity" min="20" max="100" value="100" step="5" class="w-full h-1.5 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-emerald-500">
<div class="text-[10px] text-slate-500">Lower opacity to inspect interleaved frame slices underneath.</div>
</div>
<div class="flex items-center justify-between pt-1">
<span class="text-xs text-slate-400">Registration Ticks</span>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="show-ticks" checked class="sr-only peer">
<div class="w-9 h-5 bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-emerald-600"></div>
</label>
</div>
</section>
<!-- 5. Export Suite -->
<section class="space-y-3 pt-3 border-t border-slate-800">
<label class="text-xs font-semibold uppercase tracking-wider text-slate-400 flex items-center gap-2">
<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
5. Export Fabrication Plates
</label>
<div class="space-y-2">
<button id="btn-export-base" class="w-full py-2.5 px-3 rounded-lg text-xs font-semibold bg-emerald-600 hover:bg-emerald-500 text-slate-950 transition-all flex items-center justify-center gap-2 shadow-lg shadow-emerald-950/40">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
Export Interleaved Base (PNG)
</button>
<div class="grid grid-cols-2 gap-2">
<button id="btn-export-slit-png" class="py-2 px-3 rounded-lg text-xs font-medium bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 transition-all flex items-center justify-center gap-1.5">
<span>Slit Sheet (PNG)</span>
</button>
<button id="btn-export-slit-svg" class="py-2 px-3 rounded-lg text-xs font-medium bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 transition-all flex items-center justify-center gap-1.5">
<span>Vector Slits (SVG)</span>
</button>
</div>
</div>
</section>
</aside>
<!-- Main Interactive Viewport (Right Column: 8 cols) -->
<main class="lg:col-span-8 flex flex-col bg-slate-950 relative overflow-hidden">
<!-- Viewport Header Toolbar -->
<div class="px-6 py-2.5 border-b border-slate-800/80 bg-slate-900/40 flex flex-wrap items-center justify-between gap-3 text-xs">
<div class="flex items-center gap-3">
<span class="inline-flex items-center gap-1.5 text-slate-400 font-mono">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
Simulator View
</span>
<span class="text-slate-600">|</span>
<span class="text-slate-400 hidden sm:inline">Drag mouse/touch across canvas to slide barrier sheet</span>
</div>
<div class="flex items-center gap-2">
<!-- Reset Offset -->
<button id="btn-reset-pos" class="px-2.5 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-300 transition-all font-mono text-[11px] border border-slate-700">
Reset (0px)
</button>
</div>
</div>
<!-- Main Stage Area -->
<div class="flex-1 flex items-center justify-center p-4 md:p-8 checkered-bg overflow-auto relative select-none">
<!-- Interactive Stage Enclosure -->
<div id="stage-card" class="relative bg-white rounded-xl shadow-2xl p-6 select-none border border-slate-300/20 max-w-full">
<!-- Top Registration Ticks -->
<div id="top-ticks" class="h-4 w-full mb-1 flex items-end overflow-hidden border-b border-slate-400/40"></div>
<!-- Canvas Interactive Stack -->
<div id="canvas-container" class="relative overflow-hidden rounded shadow-inner touch-none-all cursor-ew-resize mx-auto" style="width: 540px; height: 380px;">
<!-- Bottom Layer: Interleaved Base Plate -->
<canvas id="base-canvas" width="540" height="380" class="absolute inset-0 block bg-white"></canvas>
<!-- Top Layer: Draggable Slit Overlay Sheet -->
<canvas id="slit-canvas" width="600" height="380" class="absolute top-0 left-0 pointer-events-none will-change-transform"></canvas>
<!-- Drag indicator HUD overlay (fades on drag) -->
<div id="drag-hint" class="absolute inset-0 pointer-events-none flex items-center justify-center bg-black/20 backdrop-blur-[1px] transition-opacity duration-300 opacity-0">
<div class="bg-slate-900/90 text-white text-xs px-3 py-1.5 rounded-full border border-slate-700 flex items-center gap-2 shadow-xl">
<svg class="w-4 h-4 text-emerald-400 animate-bounce" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9"/></svg>
<span>Drag horizontally to animate</span>
</div>
</div>
</div>
<!-- Bottom Registration Ticks -->
<div id="bottom-ticks" class="h-4 w-full mt-1 flex items-start overflow-hidden border-t border-slate-400/40"></div>
<div class="mt-2 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>Physical Simulation: 540 x 380 px</span>
<span id="offset-readout">Offset: 0.00 px (Phase 0/6)</span>
</div>
</div>
</div>
<!-- Playback Transport Bar (Bottom) -->
<div class="border-t border-slate-800 bg-slate-900/90 p-4 px-6 flex flex-wrap items-center justify-between gap-4 z-20">
<!-- Play / Pause & Step Controls -->
<div class="flex items-center gap-2">
<button id="btn-play" class="w-10 h-10 rounded-full bg-emerald-500 hover:bg-emerald-400 text-slate-950 flex items-center justify-center font-bold shadow-lg shadow-emerald-500/20 transition-all">
<svg id="play-icon" class="w-5 h-5 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
<svg id="pause-icon" class="w-5 h-5 hidden" fill="currentColor" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
<button id="btn-step-prev" class="w-8 h-8 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 flex items-center justify-center transition-all border border-slate-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg>
</button>
<button id="btn-step-next" class="w-8 h-8 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 flex items-center justify-center transition-all border border-slate-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/></svg>
</button>
</div>
<!-- Scrub Range Slider -->
<div class="flex-1 max-w-md mx-2 flex items-center gap-3">
<span class="text-xs font-mono text-slate-400">Scrub:</span>
<input type="range" id="scrubber" min="0" max="18" step="0.1" value="0" class="flex-1 h-2 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-emerald-500">
</div>
<!-- Playback Speed (FPS / Hertz) -->
<div class="flex items-center gap-3">
<span class="text-xs text-slate-400">Rate:</span>
<select id="playback-speed" class="bg-slate-800 text-slate-200 text-xs rounded-lg px-2.5 py-1.5 border border-slate-700 font-mono focus:outline-none focus:border-emerald-500">
<option value="0.5">0.5 Hz (Slow)</option>
<option value="1" selected>1.0 Hz (Standard)</option>
<option value="2">2.0 Hz (Fast Aggie 120bpm)</option>
<option value="4">4.0 Hz (Hyper)</option>
</select>
</div>
</div>
</main>
</div>
<!-- Notification Toast Modal Container -->
<div id="toast" class="fixed bottom-6 right-6 bg-slate-900 border border-emerald-500/30 text-white px-4 py-3 rounded-xl shadow-2xl flex items-center gap-3 transition-all duration-300 opacity-0 pointer-events-none translate-y-3 z-50">
<div class="w-7 h-7 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
</div>
<span id="toast-msg" class="text-xs font-medium">Export completed successfully.</span>
</div>
<!-- Main Application Logic Script -->
<script>
/* ==========================================================================
SCANIMATION STUDIO CORE ENGINE
Single-File Production Implementation
========================================================================== */
// --- State Management ---
const state = {
// Geometric parameters
N: 6, // Frame count (phases)
w: 3, // Slit width in pixels
B: 15, // Opaque bar width = (N - 1) * w
P: 18, // Spatial period (pitch) = N * w
// Canvas dimensions
width: 540,
height: 380,
// Motion & Synthesis
motionType: 'march', // 'march', 'rotate', 'pulse', 'wave'
motionMagnitude: 18,
invertColors: true,
// Simulation & Playback
slitOffset: 0, // Current horizontal translation in px [0 .. P]
isPlaying: true,
playbackFrequency: 1.0, // Full cycles per second (Hz)
maskOpacity: 1.0,
showTicks: true,
// Source & Buffers
sourceImage: null, // HTMLImageElement or offscreen canvas
isDragging: false,
dragStartX: 0,
dragStartOffset: 0,
};
// --- DOM Elements Cache ---
const dom = {
baseCanvas: document.getElementById('base-canvas'),
slitCanvas: document.getElementById('slit-canvas'),
canvasContainer: document.getElementById('canvas-container'),
paramN: document.getElementById('param-n'),
paramW: document.getElementById('param-w'),
nVal: document.getElementById('n-val'),
wVal: document.getElementById('w-val'),
badgePitch: document.getElementById('badge-pitch'),
badgeDuty: document.getElementById('badge-duty'),
badgeBar: document.getElementById('badge-bar'),
motionMag: document.getElementById('motion-mag'),
motionMagVal: document.getElementById('motion-mag-val'),
motionBtns: document.querySelectorAll('.motion-btn'),
paramOpacity: document.getElementById('param-opacity'),
opacityVal: document.getElementById('opacity-val'),
showTicks: document.getElementById('show-ticks'),
topTicks: document.getElementById('top-ticks'),
bottomTicks: document.getElementById('bottom-ticks'),
invertColors: document.getElementById('invert-colors'),
btnPlay: document.getElementById('btn-play'),
playIcon: document.getElementById('play-icon'),
pauseIcon: document.getElementById('pause-icon'),
scrubber: document.getElementById('scrubber'),
playbackSpeed: document.getElementById('playback-speed'),
btnStepPrev: document.getElementById('btn-step-prev'),
btnStepNext: document.getElementById('btn-step-next'),
btnResetPos: document.getElementById('btn-reset-pos'),
offsetReadout: document.getElementById('offset-readout'),
imageUpload: document.getElementById('image-upload'),
btnExportBase: document.getElementById('btn-export-base'),
btnExportSlitPng: document.getElementById('btn-export-slit-png'),
btnExportSlitSvg: document.getElementById('btn-export-slit-svg'),
toast: document.getElementById('toast'),
toastMsg: document.getElementById('toast-msg'),
presetAggie: document.getElementById('preset-aggie'),
presetHeart: document.getElementById('preset-heart'),
presetGear: document.getElementById('preset-gear'),
presetRunner: document.getElementById('preset-runner'),
};
const baseCtx = dom.baseCanvas.getContext('2d', { willReadFrequently: true });
const slitCtx = dom.slitCanvas.getContext('2d');
// --- Procedural Preset Vector Generators ---
// Generates clean high-contrast silhouette canvases without external asset dependencies
function createAggieBandMarcherCanvas() {
// Texas A&M Fightin' Texas Aggie Band Marcher with Trumpet silhouette
const cvs = document.createElement('canvas');
cvs.width = state.width;
cvs.height = state.height;
const ctx = cvs.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, cvs.width, cvs.height);
ctx.fillStyle = '#000000';
ctx.save();
ctx.translate(cvs.width / 2, cvs.height / 2 + 10);
// Marcher silhouette coordinates
// Aggie Cadet Cover (shako / peaked military hat)
ctx.beginPath();
ctx.rect(-16, -115, 32, 28);
ctx.fill();
// Hat Visor
ctx.beginPath();
ctx.moveTo(14, -92);
ctx.lineTo(26, -92);
ctx.lineTo(16, -88);
ctx.fill();
// Head silhouette
ctx.beginPath();
ctx.arc(0, -78, 14, 0, Math.PI * 2);
ctx.fill();
// Torso / Midnight Military Tunic & Cross-belts
ctx.beginPath();
ctx.moveTo(-18, -64);
ctx.lineTo(18, -64);
ctx.lineTo(15, 10);
ctx.lineTo(-15, 10);
ctx.closePath();
ctx.fill();
// Horizontal Trumpet held at attention
ctx.fillRect(4, -62, 75, 7);
// Trumpet bell flare
ctx.beginPath();
ctx.moveTo(79, -68);
ctx.lineTo(92, -76);
ctx.lineTo(92, -45);
ctx.lineTo(79, -53);
ctx.closePath();
ctx.fill();
// Arms angled forward holding instrument
ctx.lineWidth = 10;
ctx.strokeStyle = '#000000';
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(-6, -58);
ctx.lineTo(15, -45);
ctx.lineTo(45, -58);
ctx.stroke();
// Marching Stride Legs (Crisp high-step position)
// Left lead leg lifted high
ctx.lineWidth = 14;
ctx.beginPath();
ctx.moveTo(-8, 8);
ctx.lineTo(16, 42);
ctx.lineTo(24, 88); // Boot
ctx.stroke();
// Right back leg pushing off
ctx.beginPath();
ctx.moveTo(4, 8);
ctx.lineTo(-24, 46);
ctx.lineTo(-32, 85);
ctx.stroke();
// Military Boot Soles
ctx.fillRect(16, 85, 22, 10);
ctx.fillRect(-42, 82, 18, 9);
// Aggie Chevron accents on sleeve
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(8, -48);
ctx.lineTo(15, -44);
ctx.lineTo(22, -48);
ctx.stroke();
ctx.restore();
return cvs;
}
function createHeartCanvas() {
const cvs = document.createElement('canvas');
cvs.width = state.width;
cvs.height = state.height;
const ctx = cvs.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, cvs.width, cvs.height);
ctx.fillStyle = '#000000';
ctx.save();
ctx.translate(cvs.width / 2, cvs.height / 2 - 15);
ctx.beginPath();
const topY = -50;
ctx.moveTo(0, topY + 30);
ctx.bezierCurveTo(0, topY, -60, topY - 20, -70, topY + 35);
ctx.bezierCurveTo(-80, topY + 80, -20, topY + 120, 0, topY + 150);
ctx.bezierCurveTo(20, topY + 120, 80, topY + 80, 70, topY + 35);
ctx.bezierCurveTo(60, topY - 20, 0, topY, 0, topY + 30);
ctx.fill();
// Concentric inner hollow ring
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 10;
ctx.stroke();
ctx.restore();
return cvs;
}
function createGearCanvas() {
const cvs = document.createElement('canvas');
cvs.width = state.width;
cvs.height = state.height;
const ctx = cvs.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, cvs.width, cvs.height);
ctx.fillStyle = '#000000';
ctx.save();
ctx.translate(cvs.width / 2, cvs.height / 2);
const numTeeth = 8;
const radiusOuter = 95;
const radiusInner = 72;
ctx.beginPath();
for (let i = 0; i < numTeeth * 2; i++) {
const angle = (i * Math.PI) / numTeeth;
const r = (i % 2 === 0) ? radiusOuter : radiusInner;
const x = Math.cos(angle) * r;
const y = Math.sin(angle) * r;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.fill();
// Cutout center
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(0, 0, 36, 0, Math.PI * 2);
ctx.fill();
// Keyway notch
ctx.fillRect(-8, -45, 16, 20);
ctx.restore();
return cvs;
}
function createRunnerCanvas() {
const cvs = document.createElement('canvas');
cvs.width = state.width;
cvs.height = state.height;
const ctx = cvs.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, cvs.width, cvs.height);
ctx.fillStyle = '#000000';
ctx.save();
ctx.translate(cvs.width / 2, cvs.height / 2 + 10);
// Head
ctx.beginPath();
ctx.arc(20, -85, 18, 0, Math.PI * 2);
ctx.fill();
// Leaning Torso
ctx.lineWidth = 20;
ctx.strokeStyle = '#000000';
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(15, -68);
ctx.lineTo(-8, 0);
ctx.stroke();
// Leading bent leg
ctx.lineWidth = 14;
ctx.beginPath();
ctx.moveTo(-8, 0);
ctx.lineTo(36, 25);
ctx.lineTo(24, 75);
ctx.stroke();
// Trailing extended leg
ctx.beginPath();
ctx.moveTo(-8, 0);
ctx.lineTo(-45, 20);
ctx.lineTo(-75, 45);
ctx.stroke();
// Pumping Arms
ctx.lineWidth = 11;
ctx.beginPath();
ctx.moveTo(10, -50);
ctx.lineTo(-30, -35);
ctx.lineTo(-25, 0);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(10, -50);
ctx.lineTo(48, -40);
ctx.lineTo(60, -70);
ctx.stroke();
ctx.restore();
return cvs;
}
// --- Math & Geometric Calibration Helpers ---
function updateCalculatedParameters() {
state.B = (state.N - 1) * state.w;
state.P = state.N * state.w;
const duty = ((1 / state.N) * 100).toFixed(1);
// Update UI Indicators
dom.nVal.textContent = `${state.N} frames`;
dom.wVal.textContent = `${state.w} px`;
dom.badgePitch.textContent = `${state.P} px`;
dom.badgeBar.textContent = `${state.B} px`;
dom.badgeDuty.textContent = `${duty}%`;
// Update Scrubber Max
dom.scrubber.max = state.P;
if (state.slitOffset > state.P) {
state.slitOffset = 0;
}
dom.scrubber.value = state.slitOffset;
renderRegistrationTicks();
renderSlitOverlaySheet();
synthesizeInterleavedBase();
}
// --- Frame Deformation & Transform Synthesis ---
// Takes the base graphic and transforms it into N unique animation phase canvases
function generateFramePhases() {
const frames = [];
const { N, motionType, motionMagnitude } = state;
const w = state.width;
const h = state.height;
for (let i = 0; i < N; i++) {
const offCanvas = document.createElement('canvas');
offCanvas.width = w;
offCanvas.height = h;
const ctx = offCanvas.getContext('2d');
// Clean white background
ctx.fillStyle = state.invertColors ? '#ffffff' : '#000000';
ctx.fillRect(0, 0, w, h);
const phaseRatio = i / N; // 0.0 to < 1.0
const angle = phaseRatio * Math.PI * 2;
ctx.save();
ctx.translate(w / 2, h / 2);
if (motionType === 'march') {
// Linear continuous marching stride forward with slight cadence bob
const dx = (phaseRatio - 0.5) * motionMagnitude * 2;
const dy = -Math.abs(Math.sin(angle)) * (motionMagnitude * 0.4);
ctx.translate(dx, dy);
} else if (motionType === 'rotate') {
// Pure continuous full cycle rotation
ctx.rotate(angle);
} else if (motionType === 'pulse') {
// Radial dilation & contraction
const scaleFactor = 1.0 + Math.sin(angle) * (motionMagnitude / 100);
ctx.scale(scaleFactor, scaleFactor);
} else if (motionType === 'wave') {
// Sinusoidal vertical bounce and shearing
const dy = Math.sin(angle) * motionMagnitude;
ctx.translate(0, dy);
ctx.rotate((Math.cos(angle) * motionMagnitude * Math.PI) / 360);
}
ctx.translate(-w / 2, -h / 2);
// Draw source graphic
if (state.sourceImage) {
ctx.drawImage(state.sourceImage, 0, 0, w, h);
}
ctx.restore();
// Invert to white silhouette if requested
if (!state.invertColors) {
const imgData = ctx.getImageData(0, 0, w, h);
const d = imgData.data;
for (let p = 0; p < d.length; p += 4) {
d[p] = 255 - d[p];
d[p + 1] = 255 - d[p + 1];
d[p + 2] = 255 - d[p + 2];
}
ctx.putImageData(imgData, 0, 0);
}
frames.push(ctx.getImageData(0, 0, w, h));
}
return frames;
}
// --- Core Interleaving Synthesis Algorithm ---
// Column x draws from frame phase: floor(x / w) % N
function synthesizeInterleavedBase() {
const frames = generateFramePhases();
const w = state.width;
const h = state.height;
const N = state.N;
const slitW = state.w;
const composite = baseCtx.createImageData(w, h);
const out = composite.data;
for (let x = 0; x < w; x++) {
// Precise integer floor mapping
const frameIndex = Math.floor(x / slitW) % N;
const src = frames[frameIndex].data;
for (let y = 0; y < h; y++) {
const idx = (y * w + x) * 4;
out[idx] = src[idx];
out[idx + 1] = src[idx + 1];
out[idx + 2] = src[idx + 2];
out[idx + 3] = 255;
}
}
baseCtx.putImageData(composite, 0, 0);
}
// --- Slit Overlay Sheet Generator ---
// Generates the matching opaque/clear grating with width B opaque bars and width w transparent slits
function renderSlitOverlaySheet() {
const totalWidth = state.width + state.P * 2; // Extra width for smooth horizontal translation
const h = state.height;
dom.slitCanvas.width = totalWidth;
dom.slitCanvas.height = h;
slitCtx.clearRect(0, 0, totalWidth, h);
// Draw the black bars and transparent slits
// In barrier grid: slit = clear, bar = opaque
slitCtx.fillStyle = `rgba(0, 0, 0, ${state.maskOpacity})`;
for (let x = 0; x < totalWidth; x += state.P) {
// Transparent slit from x to x + w
// Opaque bar from x + w to x + P
slitCtx.fillRect(x + state.w, 0, state.B, h);
}
}
// --- Alignment Calibration Ticks ---
function renderRegistrationTicks() {
if (!state.showTicks) {
dom.topTicks.style.display = 'none';
dom.bottomTicks.style.display = 'none';
return;
}
dom.topTicks.style.display = 'flex';
dom.bottomTicks.style.display = 'flex';
const pitch = state.P;
const totalTicks = Math.floor(state.width / pitch);
let ticksHtml = '';
for (let i = 0; i <= totalTicks; i++) {
const isMajor = i % 5 === 0;
const tickH = isMajor ? 'h-3.5 bg-slate-900 w-[1.5px]' : 'h-2 bg-slate-400 w-[1px]';
ticksHtml += `<div style="width: ${pitch}px;" class="flex items-end justify-start flex-shrink-0">
<div class="${tickH}"></div>
</div>`;
}
dom.topTicks.innerHTML = ticksHtml;
dom.bottomTicks.innerHTML = ticksHtml;
}
// --- Simulation & CSS GPU Translation ---
function applySlitPosition(pxOffset) {
// Modulo wrap around spatial pitch P
state.slitOffset = ((pxOffset % state.P) + state.P) % state.P;
// Hardware-accelerated subpixel translation
dom.slitCanvas.style.transform = `translate3d(${-state.slitOffset}px, 0, 0)`;
// Update transport feedback
dom.scrubber.value = state.slitOffset;
const activePhase = Math.floor((state.slitOffset / state.w) % state.N);
dom.offsetReadout.textContent = `Offset: ${state.slitOffset.toFixed(2)} px (Phase ${activePhase + 1}/${state.N})`;
}
// --- Animation Playback Loop ---
let lastTimestamp = 0;
function animationLoop(timestamp) {
if (!lastTimestamp) lastTimestamp = timestamp;
const deltaSec = (timestamp - lastTimestamp) / 1000;
lastTimestamp = timestamp;
if (state.isPlaying && !state.isDragging) {
// Velocity: P pixels per complete cycle * frequency
const step = state.P * state.playbackFrequency * deltaSec;
applySlitPosition(state.slitOffset + step);
}
requestAnimationFrame(animationLoop);
}
// --- Toast Notification Feedback ---
let toastTimer = null;
function showToast(message) {
dom.toastMsg.textContent = message;
dom.toast.classList.remove('opacity-0', 'pointer-events-none', 'translate-y-3');
dom.toast.classList.add('opacity-100', 'translate-y-0');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
dom.toast.classList.add('opacity-0', 'pointer-events-none', 'translate-y-3');
dom.toast.classList.remove('opacity-100', 'translate-y-0');
}, 3200);
}
// --- Drag & Touch Interaction ---
function initDragControls() {
const el = dom.canvasContainer;
el.addEventListener('pointerdown', (e) => {
state.isDragging = true;
state.dragStartX = e.clientX;
state.dragStartOffset = state.slitOffset;
el.setPointerCapture(e.pointerId);
el.classList.add('cursor-grabbing');
el.classList.remove('cursor-ew-resize');
});
el.addEventListener('pointermove', (e) => {
if (!state.isDragging) return;
const deltaX = e.clientX - state.dragStartX;
// Inverse direction: dragging right moves overlay right
applySlitPosition(state.dragStartOffset - deltaX);
});
const stopDrag = (e) => {
if (!state.isDragging) return;
state.isDragging = false;
try { el.releasePointerCapture(e.pointerId); } catch (_) {}
el.classList.remove('cursor-grabbing');
el.classList.add('cursor-ew-resize');
};
el.addEventListener('pointerup', stopDrag);
el.addEventListener('pointercancel', stopDrag);
}
// --- Export Generators ---
function exportInterleavedBase() {
// Create high-res download
const link = document.createElement('a');
link.download = `scanimation-base-N${state.N}-w${state.w}.png`;
link.href = dom.baseCanvas.toDataURL('image/png');
link.click();
showToast(`Exported Interleaved Base Plate (${state.width}×${state.height} px)`);
}
function exportSlitPng() {
// Export single-screen matching mask
const cvs = document.createElement('canvas');
cvs.width = state.width;
cvs.height = state.height;
const ctx = cvs.getContext('2d');
ctx.fillStyle = '#000000';
for (let x = 0; x < state.width; x += state.P) {
ctx.fillRect(x + state.w, 0, state.B, state.height);
}
const link = document.createElement('a');
link.download = `scanimation-slit-mask-N${state.N}-w${state.w}.png`;
link.href = cvs.toDataURL('image/png');
link.click();
showToast('Exported 1-Bit Slit Transparency (PNG)');
}
function exportSlitSvg() {
const w = state.width;
const h = state.height;
const p = state.P;
const barW = state.B;
const slitW = state.w;
let rects = '';
for (let x = 0; x < w; x += p) {
rects += ` <rect x="${x + slitW}" y="0" width="${barW}" height="${h}" fill="#000000" />\n`;
}
const svgContent = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}px" height="${h}px">
<desc>Scanimation Barrier Grid: N=${state.N}, w=${slitW}px, B=${barW}px, Pitch=${p}px</desc>
<!-- Transparent aperture slits of width ${slitW}px with opaque bars of width ${barW}px -->
<g id="barrier-bars">
${rects} </g>
</svg>`;
const blob = new Blob([svgContent], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.download = `scanimation-slit-grid-N${state.N}-w${slitW}.svg`;
link.href = url;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
showToast('Exported Vector Slit Grid (SVG for Laser Cut / Vinyl)');
}
// --- Event Listeners Binding ---
function initEventListeners() {
// Parameter Sliders
dom.paramN.addEventListener('input', (e) => {
state.N = parseInt(e.target.value, 10);
updateCalculatedParameters();
});
dom.paramW.addEventListener('input', (e) => {
state.w = parseInt(e.target.value, 10);
updateCalculatedParameters();
});
dom.motionMag.addEventListener('input', (e) => {
state.motionMagnitude = parseInt(e.target.value, 10);
dom.motionMagVal.textContent = `${state.motionMagnitude} px`;
synthesizeInterleavedBase();
});
dom.paramOpacity.addEventListener('input', (e) => {
state.maskOpacity = parseInt(e.target.value, 10) / 100;
dom.opacityVal.textContent = `${e.target.value}%`;
renderSlitOverlaySheet();
});
dom.invertColors.addEventListener('change', (e) => {
state.invertColors = e.target.checked;
synthesizeInterleavedBase();
});
dom.showTicks.addEventListener('change', (e) => {
state.showTicks = e.target.checked;
renderRegistrationTicks();
});
// Motion Type Buttons
dom.motionBtns.forEach((btn) => {
btn.addEventListener('click', () => {
dom.motionBtns.forEach((b) => {
b.classList.remove('bg-slate-800', 'text-emerald-400');
b.classList.add('text-slate-400');
});
btn.classList.add('bg-slate-800', 'text-emerald-400');
btn.classList.remove('text-slate-400');
state.motionType = btn.dataset.motion;
synthesizeInterleavedBase();
});
});
// Preset Pickers
dom.presetAggie.addEventListener('click', () => {
state.sourceImage = createAggieBandMarcherCanvas();
state.motionType = 'march';
highlightMotionBtn('march');
synthesizeInterleavedBase();
showToast('Loaded Preset: Texas A&M Band Marcher');
});
dom.presetHeart.addEventListener('click', () => {
state.sourceImage = createHeartCanvas();
state.motionType = 'pulse';
highlightMotionBtn('pulse');
synthesizeInterleavedBase();
showToast('Loaded Preset: Pulsing Heart');
});
dom.presetGear.addEventListener('click', () => {
state.sourceImage = createGearCanvas();
state.motionType = 'rotate';
highlightMotionBtn('rotate');
synthesizeInterleavedBase();
showToast('Loaded Preset: Rotating Gear');
});
dom.presetRunner.addEventListener('click', () => {
state.sourceImage = createRunnerCanvas();
state.motionType = 'wave';
highlightMotionBtn('wave');
synthesizeInterleavedBase();
showToast('Loaded Preset: Stride Runner');
});
function highlightMotionBtn(type) {
dom.motionBtns.forEach((b) => {
const match = b.dataset.motion === type;
b.classList.toggle('bg-slate-800', match);
b.classList.toggle('text-emerald-400', match);
b.classList.toggle('text-slate-400', !match);
});
}
// Custom Upload
dom.imageUpload.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
state.sourceImage = img;
synthesizeInterleavedBase();
showToast(`Loaded Custom Asset: ${file.name}`);
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
});
// Transport Controls
dom.btnPlay.addEventListener('click', () => {
state.isPlaying = !state.isPlaying;
dom.playIcon.classList.toggle('hidden', state.isPlaying);
dom.pauseIcon.classList.toggle('hidden', !state.isPlaying);
});
dom.scrubber.addEventListener('input', (e) => {
state.isPlaying = false;
dom.playIcon.classList.remove('hidden');
dom.pauseIcon.classList.add('hidden');
applySlitPosition(parseFloat(e.target.value));
});
dom.playbackSpeed.addEventListener('change', (e) => {
state.playbackFrequency = parseFloat(e.target.value);
});
dom.btnStepPrev.addEventListener('click', () => {
state.isPlaying = false;
dom.playIcon.classList.remove('hidden');
dom.pauseIcon.classList.add('hidden');
applySlitPosition(state.slitOffset - state.w);
});
dom.btnStepNext.addEventListener('click', () => {
state.isPlaying = false;
dom.playIcon.classList.remove('hidden');
dom.pauseIcon.classList.add('hidden');
applySlitPosition(state.slitOffset + state.w);
});
dom.btnResetPos.addEventListener('click', () => {
applySlitPosition(0);
});
// Exports
dom.btnExportBase.addEventListener('click', exportInterleavedBase);
dom.btnExportSlitPng.addEventListener('click', exportSlitPng);
dom.btnExportSlitSvg.addEventListener('click', exportSlitSvg);
}
// --- Application Bootloader ---
window.addEventListener('load', () => {
// 1. Initial Preset Graphic
state.sourceImage = createAggieBandMarcherCanvas();
// 2. Set Initial Geometries
updateCalculatedParameters();
// 3. Bind Drag & Controls
initDragControls();
initEventListeners();
// 4. Start Physics & Animation Render Loop
requestAnimationFrame(animationLoop);
});
</script>
</body>
</html>
Comments
Post a Comment