scet_v2 / assests /html_sample /index4.html
DevForML's picture
Upload 70 files
015a372 verified
Raw History Blame Contribute Delete
48.4 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Brain Motion Studio v4 — Custom Palette & Shades</title>
<style>
:root {
color-scheme: dark;
--bg: #0b0d12;
--panel: #14171f;
--panel-2: #1a1e28;
--line: #272c3a;
--text: #edf2fa;
--muted: #8d97a8;
--accent: #8a7dff;
--accent-2: #6258cf;
--radius: 14px;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at top left, #151929, var(--bg) 60%);
color: var(--text);
font: 13px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button, input, select { font: inherit; }
.app {
width: min(1480px, 100%);
min-height: 100vh;
margin: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 20px;
}
.topbar {
display: flex;
align-items: center;
gap: 16px;
}
.logo {
display: grid;
place-items: center;
width: 46px;
height: 46px;
flex: 0 0 auto;
border-radius: 14px;
background: linear-gradient(140deg, #a69cff, #5f54cf);
color: white;
font-size: 24px;
box-shadow: 0 4px 12px rgba(98, 88, 207, 0.3);
}
h1 {
margin: 0;
font-size: clamp(20px, 3vw, 26px);
font-weight: 600;
letter-spacing: -.03em;
}
.subtitle {
margin: 2px 0 0;
color: var(--muted);
font-size: 13px;
}
.badge {
margin-left: 8px;
padding: 2px 8px;
border-radius: 999px;
background: #252b3d;
border: 1px solid var(--accent);
color: #c4beff;
font-size: 11px;
font-weight: 600;
vertical-align: middle;
}
.workspace {
display: grid;
grid-template-columns: 340px minmax(0, 1fr);
gap: 20px;
align-items: start;
flex: 1;
}
.panel, .stage {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
}
.panel {
padding: 16px;
max-height: calc(100vh - 100px);
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--line) transparent;
}
.panel::-webkit-scrollbar { width: 6px; }
.panel::-webkit-scrollbar-thumb { background: var(--line); border-radius: 3px; }
.section + .section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--line);
}
h2 {
margin: 0 0 12px;
font-size: 13px;
font-weight: 600;
letter-spacing: .03em;
text-transform: uppercase;
color: #bcc5d6;
}
.small-note, .status, .icon-status {
color: var(--muted);
font-size: 11px;
}
.small-note { margin: 8px 0 0; line-height: 1.4; }
.button-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.btn {
min-height: 36px;
padding: 8px 14px;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--panel-2);
color: var(--text);
cursor: pointer;
transition: all .15s ease;
font-weight: 500;
}
.btn:hover:not(:disabled) {
border-color: #7770d8;
background: #24283a;
}
.btn:active:not(:disabled) { transform: scale(0.97); }
.btn.primary {
border-color: var(--accent-2);
background: var(--accent-2);
color: white;
}
.btn.primary:hover:not(:disabled) { background: #7167e2; }
.btn:disabled {
opacity: .4;
cursor: not-allowed;
}
.full { width: 100%; }
.icon-status {
min-height: 16px;
margin: 8px 0 0;
overflow-wrap: anywhere;
}
.control { margin: 12px 0; }
.control-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 5px;
color: #dce3ed;
font-size: 12px;
}
.control-label output {
color: #b4aaff;
font-variant-numeric: tabular-nums;
font-size: 11px;
}
input[type="range"] {
width: 100%;
accent-color: var(--accent);
height: 6px;
}
select, input[type="number"] {
width: 100%;
min-height: 34px;
padding: 6px 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: #10141c;
color: var(--text);
}
input[type="color"] {
width: 40px;
height: 30px;
padding: 2px;
border: 1px solid var(--line);
border-radius: 6px;
background: #10141c;
cursor: pointer;
}
.toggle-row {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin: 10px 0;
}
.toggle {
display: flex;
align-items: center;
gap: 7px;
color: #dce3ed;
font-size: 12px;
}
.toggle input { accent-color: var(--accent); }
.color-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 12px;
}
.palette-swatches {
display: flex;
gap: 6px;
margin-top: 8px;
align-items: center;
}
.swatch-btn {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid #2a3344;
cursor: pointer;
transition: transform .15s, border-color .15s;
padding: 0;
}
.swatch-btn:hover {
transform: scale(1.15);
border-color: #a49aff;
}
.export-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.export-grid .btn { min-height: 42px; font-size: 12px; }
.stage {
min-width: 0;
padding: 16px;
display: flex;
flex-direction: column;
}
.stage-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.stage-title {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.stage-hint {
color: var(--muted);
font-size: 11px;
}
.preview {
position: relative;
display: grid;
place-items: center;
min-height: 400px;
overflow: hidden;
border: 1px solid #303846;
border-radius: 12px;
background-color: #10141b;
background-image:
linear-gradient(45deg, #1a202a 25%, transparent 25%),
linear-gradient(-45deg, #1a202a 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #1a202a 75%),
linear-gradient(-45deg, transparent 75%, #1a202a 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
flex: 1;
}
canvas {
display: block;
width: 100%;
height: auto;
max-height: 70vh;
object-fit: contain;
}
.placeholder {
position: absolute;
padding: 30px;
color: var(--muted);
text-align: center;
pointer-events: none;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.placeholder-icon {
font-size: 42px;
opacity: 0.4;
color: var(--accent);
}
.timeline {
display: grid;
grid-template-columns: auto minmax(100px, 1fr) auto;
gap: 12px;
align-items: center;
margin-top: 14px;
}
.time-label {
min-width: 80px;
color: var(--muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
text-align: right;
}
.timeline input { margin: 0; }
.status {
min-height: 18px;
margin: 12px 0 0;
font-size: 11px;
}
.hidden-input, #sourceVideo { display: none; }
@media (max-width: 900px) {
.app { padding: 12px; }
.workspace { grid-template-columns: 1fr; }
.panel { max-height: none; }
.preview { min-height: 280px; }
}
</style>
</head>
<body>
<div class="app">
<header class="topbar">
<div class="logo" aria-hidden="true">✦</div>
<div>
<h1>Brain Motion Studio <span class="badge">v4</span></h1>
<p class="subtitle">Transform video into dynamic SVG-cell artwork with custom color palette & shades of black.</p>
</div>
</header>
<div class="workspace">
<aside class="panel">
<section class="section">
<h2>1. Source Media</h2>
<div class="button-row">
<button class="btn primary" id="uploadVideoBtn" data-lock>Video</button>
<button class="btn" id="addSvgBtn" data-lock>Add SVGs</button>
<button class="btn" id="clearSvgBtn" data-lock>Clear SVGs</button>
</div>
<input class="hidden-input" id="videoInput" type="file" accept="video/*">
<input class="hidden-input" id="svgInput" type="file" accept=".svg,image/svg+xml" multiple>
<video id="sourceVideo" playsinline muted></video>
<p class="icon-status" id="iconStatus">No SVGs loaded — circles will be used.</p>
<p class="small-note">Upload a video, then add any number of SVG files. They will automatically cycle through the cells.</p>
</section>
<section class="section">
<h2>2. Color Grading</h2>
<div class="control">
<label class="control-label" for="brightness">
<span>Brightness</span><output id="brightnessValue">0</output>
</label>
<input id="brightness" data-lock type="range" min="-100" max="100" value="0">
</div>
<div class="control">
<label class="control-label" for="saturation">
<span>Saturation</span><output id="saturationValue">0</output>
</label>
<input id="saturation" data-lock type="range" min="-100" max="100" value="0">
</div>
<div class="color-row" style="margin: 12px 0">
<label for="tintColor">Tint / Overlay</label>
<input id="tintColor" data-lock type="color" value="#8a7dff">
</div>
<div class="control">
<label class="control-label" for="tintOpacity">
<span>Tint Intensity</span><output id="tintOpacityValue">0%</output>
</label>
<input id="tintOpacity" data-lock type="range" min="0" max="100" value="0">
</div>
</section>
<section class="section">
<h2>3. Cell Appearance</h2>
<div class="control">
<label class="control-label" for="cellSize">
<span>Spacing</span><output id="cellValue">14 px</output>
</label>
<input id="cellSize" data-lock type="range" min="6" max="40" value="14">
</div>
<div class="control">
<label class="control-label" for="scale">
<span>Scale</span><output id="scaleValue">0.95</output>
</label>
<input id="scale" data-lock type="range" min="0.25" max="1.8" step="0.05" value="0.95">
</div>
<div class="control">
<label class="control-label" for="gap">
<span>Gap</span><output id="gapValue">1 px</output>
</label>
<input id="gap" data-lock type="range" min="0" max="12" step="0.5" value="1">
</div>
<div class="control">
<label class="control-label" for="contrast">
<span>Contrast</span><output id="contrastValue">0</output>
</label>
<input id="contrast" data-lock type="range" min="-100" max="100" value="0">
</div>
<div class="control">
<label class="control-label" for="sizeMode">Size Logic</label>
<select id="sizeMode" data-lock>
<option value="bright">Bright = Larger</option>
<option value="dark">Dark = Larger</option>
</select>
</div>
<div class="control">
<label class="control-label" for="fallbackShape">Fallback Shape</label>
<select id="fallbackShape" data-lock>
<option value="circle">Circle</option>
<option value="square">Square</option>
<option value="triangle">Triangle</option>
<option value="star">Star</option>
<option value="diamond">Diamond</option>
<option value="cross">Cross</option>
</select>
</div>
<div class="control">
<label class="control-label" for="colorMode">SVG Color Mode</label>
<select id="colorMode" data-lock>
<option value="sample">Tint to Video Color</option>
<option value="shadesBlack">Shades of Black (Charcoal)</option>
<option value="custom">Custom Color / Palette</option>
<option value="white">Tint White</option>
<option value="native">Keep Original SVG</option>
</select>
</div>
<!-- Custom Color Palette Controls -->
<div id="customColorContainer" style="margin: 10px 0; display: none;">
<div class="color-row">
<label for="customColor">Color Palette</label>
<input id="customColor" data-lock type="color" value="#0a0a0a">
</div>
<div class="palette-swatches">
<span style="font-size:10px; color:var(--muted)">Presets:</span>
<button class="swatch-btn" style="background:#000000" title="Pitch Black (#000000)" onclick="setPaletteColor('#000000')"></button>
<button class="swatch-btn" style="background:#1a1e24" title="Dark Charcoal (#1a1e24)" onclick="setPaletteColor('#1a1e24')"></button>
<button class="swatch-btn" style="background:#2d3748" title="Slate Gray (#2d3748)" onclick="setPaletteColor('#2d3748')"></button>
<button class="swatch-btn" style="background:#102a43" title="Midnight Navy (#102a43)" onclick="setPaletteColor('#102a43')"></button>
<button class="swatch-btn" style="background:#8a7dff" title="Accent Violet (#8a7dff)" onclick="setPaletteColor('#8a7dff')"></button>
</div>
</div>
<div class="toggle-row">
<label class="toggle"><input id="rotate" data-lock type="checkbox"> Rotate by Brightness</label>
</div>
</section>
<section class="section">
<h2>4. Background & Export</h2>
<div class="toggle-row">
<label class="toggle"><input id="transparent" data-lock type="checkbox" checked> Transparent Background</label>
</div>
<div class="color-row" style="margin: 10px 0">
<label for="bgColor">Opaque Bg Color</label>
<input id="bgColor" data-lock type="color" value="#10141b">
</div>
<div class="control">
<label class="control-label" for="backgroundMode">Cutout Logic</label>
<select id="backgroundMode" data-lock>
<option value="dark">Remove Dark Pixels</option>
<option value="light">Remove Light Pixels</option>
<option value="none">Keep All</option>
</select>
</div>
<div class="control">
<label class="control-label" for="cutoff">
<span>Cutout Threshold</span><output id="cutoffValue">5%</output>
</label>
<input id="cutoff" data-lock type="range" min="0" max="95" value="5">
</div>
<div class="control">
<label class="control-label" for="outputWidth">Output Width</label>
<input id="outputWidth" data-lock type="number" min="240" max="1280" step="80" value="480">
</div>
<div class="control">
<label class="control-label" for="duration">Duration (sec)</label>
<input id="duration" data-lock type="number" min="0.5" max="15" step="0.5" value="4">
</div>
<div class="control">
<label class="control-label" for="fps">Export FPS</label>
<input id="fps" data-lock type="number" min="4" max="20" step="1" value="12">
</div>
<div class="button-row" style="margin: 14px 0 9px">
<button class="btn" id="playPause" disabled data-lock>Play</button>
<button class="btn" id="saveFrame" data-lock>Save PNG</button>
</div>
<div class="export-grid">
<button class="btn primary" id="exportSvg" disabled data-lock>SVG</button>
<button class="btn" id="exportGif" disabled data-lock>GIF</button>
<button class="btn" id="exportWebm" disabled data-lock>WebM</button>
<button class="btn" id="exportMp4" disabled data-lock>MP4</button>
</div>
<p class="small-note">
WebM/MP4 export contains no audio. MP4 is always opaque. GIF transparency is 1-bit (on/off).
</p>
</section>
</aside>
<section class="stage">
<div class="stage-head">
<h2 class="stage-title">Preview</h2>
<span class="stage-hint">Checkerboard = Transparency</span>
</div>
<div class="preview">
<canvas id="canvas" width="480" height="270"></canvas>
<div class="placeholder" id="placeholder">
<div class="placeholder-icon">✦</div>
<div>Choose a video to start.<br>Add SVGs to use them as animated cells.</div>
</div>
</div>
<div class="timeline">
<button class="btn" id="timelinePlay" disabled data-lock style="min-height: 32px; padding: 6px 12px;">▶</button>
<input id="seek" type="range" min="0" max="1000" value="0" disabled data-lock>
<span class="time-label" id="timeLabel">0:00 / 0:00</span>
</div>
<p class="status" id="status" aria-live="polite">Ready. Load a video to begin.</p>
</section>
</div>
</div>
<!-- GIF encoding dependency. Other exports do not require this library. -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.js"></script>
<script>
(() => {
'use strict';
const $ = id => document.getElementById(id);
const video = $('sourceVideo');
const canvas = $('canvas');
const ctx = canvas.getContext('2d', { alpha: true });
const sampleCanvas = document.createElement('canvas');
const sampleCtx = sampleCanvas.getContext('2d', { willReadFrequently: true });
const tintCanvas = document.createElement('canvas');
tintCanvas.width = 32;
tintCanvas.height = 32;
const tintCtx = tintCanvas.getContext('2d');
let videoUrl = null;
let svgAssets = [];
let busy = false;
let recordingOpaque = false;
let lastRenderedTime = -1;
window.setPaletteColor = function(colorHex) {
const picker = $('customColor');
if (picker) {
picker.value = colorHex;
renderSafely();
}
};
const exportButtons = [
$('exportSvg'), $('exportGif'), $('exportWebm'), $('exportMp4')
];
function setStatus(message) {
$('status').textContent = message;
}
function syncActionButtons() {
const hasVideo = !!video.videoWidth;
$('playPause').disabled = busy || !hasVideo;
$('timelinePlay').disabled = busy || !hasVideo;
$('seek').disabled = busy || !hasVideo;
for (const button of exportButtons) {
button.disabled = busy || !hasVideo;
}
}
function setBusy(value) {
busy = value;
document.querySelectorAll('[data-lock]').forEach(el => {
el.disabled = value;
});
syncActionButtons();
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function updateLabels() {
$('cellValue').textContent = `${$('cellSize').value} px`;
$('scaleValue').textContent = Number($('scale').value).toFixed(2);
$('gapValue').textContent = `${$('gap').value} px`;
const contrast = Number($('contrast').value);
$('contrastValue').textContent = `${contrast > 0 ? '+' : ''}${contrast}`;
$('cutoffValue').textContent = `${$('cutoff').value}%`;
const brightness = Number($('brightness').value);
$('brightnessValue').textContent = `${brightness > 0 ? '+' : ''}${brightness}`;
const saturation = Number($('saturation').value);
$('saturationValue').textContent = `${saturation > 0 ? '+' : ''}${saturation}`;
$('tintOpacityValue').textContent = `${$('tintOpacity').value}%`;
}
function resizeCanvasForVideo() {
if (!video.videoWidth || !video.videoHeight) return;
const wantedWidth = clamp(Number($('outputWidth').value) || 480, 240, 1280);
const maxHeight = 1280;
const ratio = Math.min(
wantedWidth / video.videoWidth,
maxHeight / video.videoHeight
);
const width = Math.max(1, Math.round(video.videoWidth * ratio));
const height = Math.max(1, Math.round(video.videoHeight * ratio));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
sampleCanvas.width = width;
sampleCanvas.height = height;
}
}
function adjustChannel(value, factor) {
return clamp(factor * (value - 128) + 128, 0, 255);
}
// Color Grading Logic
function gradeColor(r, g, b) {
// Brightness adjustment
const brightness = Number($('brightness').value);
if (brightness !== 0) {
const adj = brightness * 2.55;
r += adj;
g += adj;
b += adj;
}
// Saturation adjustment
const saturation = Number($('saturation').value);
if (saturation !== 0) {
const luma = 0.299 * r + 0.587 * g + 0.114 * b;
const factor = 1 + (saturation / 100);
r = luma + (r - luma) * factor;
g = luma + (g - luma) * factor;
b = luma + (b - luma) * factor;
}
// Tint overlay
const tintOpacity = Number($('tintOpacity').value) / 100;
if (tintOpacity > 0) {
const hex = $('tintColor').value.replace(/^#/, '');
const tR = parseInt(hex.substring(0, 2), 16);
const tG = parseInt(hex.substring(2, 4), 16);
const tB = parseInt(hex.substring(4, 6), 16);
r = r * (1 - tintOpacity) + tR * tintOpacity;
g = g * (1 - tintOpacity) + tG * tintOpacity;
b = b * (1 - tintOpacity) + tB * tintOpacity;
}
return [
clamp(Math.round(r), 0, 255),
clamp(Math.round(g), 0, 255),
clamp(Math.round(b), 0, 255)
];
}
function drawAssetCentered(targetCtx, asset, boxSize) {
const iw = asset.width || asset.image.naturalWidth || 100;
const ih = asset.height || asset.image.naturalHeight || 100;
const scale = Math.min(boxSize / iw, boxSize / ih);
targetCtx.drawImage(
asset.image,
-iw * scale / 2,
-ih * scale / 2,
iw * scale,
ih * scale
);
}
function drawBuiltin(targetCtx, type, size, color) {
const r = size / 2;
targetCtx.fillStyle = color;
targetCtx.beginPath();
switch (type) {
case 'square':
targetCtx.rect(-r, -r, size, size);
break;
case 'triangle':
targetCtx.moveTo(0, -r);
targetCtx.lineTo(r, r);
targetCtx.lineTo(-r, r);
targetCtx.closePath();
break;
case 'star':
for (let i = 0; i < 10; i++) {
const angle = -Math.PI / 2 + i * Math.PI / 5;
const radius = i % 2 === 0 ? r : r * 0.43;
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
if (i === 0) targetCtx.moveTo(x, y);
else targetCtx.lineTo(x, y);
}
targetCtx.closePath();
break;
case 'diamond':
targetCtx.moveTo(0, -r);
targetCtx.lineTo(r, 0);
targetCtx.lineTo(0, r);
targetCtx.lineTo(-r, 0);
targetCtx.closePath();
break;
case 'cross':
targetCtx.rect(-r * 0.28, -r, r * 0.56, size);
targetCtx.rect(-r, -r * 0.28, size, r * 0.56);
break;
default:
targetCtx.arc(0, 0, r, 0, Math.PI * 2);
break;
}
targetCtx.fill();
}
function drawTintedAsset(asset, color, size) {
tintCtx.clearRect(0, 0, tintCanvas.width, tintCanvas.height);
tintCtx.save();
tintCtx.translate(tintCanvas.width / 2, tintCanvas.height / 2);
drawAssetCentered(tintCtx, asset, tintCanvas.width - 2);
tintCtx.restore();
tintCtx.globalCompositeOperation = 'source-in';
tintCtx.fillStyle = color;
tintCtx.fillRect(0, 0, tintCanvas.width, tintCanvas.height);
tintCtx.globalCompositeOperation = 'source-over';
ctx.drawImage(
tintCanvas,
-size / 2,
-size / 2,
size,
size
);
}
function renderFrame(forceOpaque = false) {
if (!video.videoWidth || video.readyState < 2) return;
resizeCanvasForVideo();
const width = canvas.width;
const height = canvas.height;
sampleCtx.drawImage(video, 0, 0, width, height);
const pixels = sampleCtx.getImageData(0, 0, width, height).data;
ctx.clearRect(0, 0, width, height);
const isTransparent = $('transparent').checked && !forceOpaque;
if (!isTransparent) {
ctx.fillStyle = $('bgColor').value;
ctx.fillRect(0, 0, width, height);
}
const cell = Number($('cellSize').value);
const scaleSetting = Number($('scale').value);
const gap = Number($('gap').value);
const cutoff = Number($('cutoff').value) / 100;
const contrast = Number($('contrast').value);
const contrastFactor = (259 * (contrast + 255)) / (255 * (259 - contrast));
const columns = Math.ceil(width / cell);
const rows = Math.ceil(height / cell);
const sizeMode = $('sizeMode').value;
const backgroundMode = $('backgroundMode').value;
const colorMode = $('colorMode').value;
const rotate = $('rotate').checked;
const fallbackShape = $('fallbackShape').value;
const customColorVal = $('customColor').value || '#0a0a0a';
for (let row = 0; row < rows; row++) {
const y = row * cell;
for (let column = 0; column < columns; column++) {
const x = column * cell;
const sampleX = Math.min(width - 1, Math.floor(x + cell / 2));
const sampleY = Math.min(height - 1, Math.floor(y + cell / 2));
const index = (sampleY * width + sampleX) * 4;
const alpha = pixels[index + 3] / 255;
if (alpha < 0.04) continue;
let red = adjustChannel(pixels[index], contrastFactor);
let green = adjustChannel(pixels[index + 1], contrastFactor);
let blue = adjustChannel(pixels[index + 2], contrastFactor);
// Apply Color Grading
[red, green, blue] = gradeColor(red, green, blue);
const luma = (0.299 * red + 0.587 * green + 0.114 * blue) / 255;
if (backgroundMode === 'dark' && luma < cutoff) continue;
if (backgroundMode === 'light' && luma > 1 - cutoff) continue;
const brightness = sizeMode === 'dark' ? 1 - luma : luma;
const size = Math.min(
cell * 1.8,
Math.max(0, cell * scaleSetting * (0.12 + 0.88 * brightness) - gap)
);
if (size < 1) continue;
let color;
if (colorMode === 'white') {
color = '#ffffff';
} else if (colorMode === 'custom') {
color = customColorVal;
} else if (colorMode === 'shadesBlack') {
// Map brightness to deep charcoal & black shades (0 to 45)
const shade = Math.round(luma * 45);
color = `rgb(${shade},${shade},${shade})`;
} else {
color = `rgb(${Math.round(red)},${Math.round(green)},${Math.round(blue)})`;
}
const asset = svgAssets.length
? svgAssets[(row * columns + column) % svgAssets.length]
: null;
ctx.save();
ctx.translate(x + cell / 2, y + cell / 2);
ctx.globalAlpha = alpha;
if (rotate) {
ctx.rotate((luma - 0.5) * Math.PI);
}
if (asset) {
if (colorMode === 'native') {
drawAssetCentered(ctx, asset, size);
} else {
drawTintedAsset(asset, color, size);
}
} else {
drawBuiltin(ctx, fallbackShape, size, color);
}
ctx.restore();
}
}
lastRenderedTime = video.currentTime;
$('placeholder').style.display = 'none';
}
function renderSafely() {
try {
renderFrame(recordingOpaque);
} catch (error) {
console.error(error);
setStatus(`Preview error: ${error.message}`);
}
}
function sanitizeSvg(source) {
const doc = new DOMParser().parseFromString(source, 'image/svg+xml');
if (doc.querySelector('parsererror')) {
throw new Error('The selected file is not valid SVG.');
}
const root = doc.documentElement;
if (!root || root.localName.toLowerCase() !== 'svg') {
throw new Error('No <svg> element was found in that file.');
}
const blocked = new Set(['script', 'foreignobject', 'iframe', 'object', 'embed']);
for (const element of [...root.querySelectorAll('*')]) {
const tag = element.localName.toLowerCase();
if (blocked.has(tag)) {
element.remove();
continue;
}
for (const attribute of [...element.attributes]) {
const name = attribute.name.toLowerCase();
const value = attribute.value.trim();
if (name.startsWith('on')) {
element.removeAttributeNode(attribute);
} else if ((name === 'href' || name === 'xlink:href' || name === 'src') &&
value &&
!value.startsWith('#') &&
!/^data:image\//i.test(value)) {
element.removeAttributeNode(attribute);
} else if (/url\(\s*(['"]?)(?:https?:|\/\/|file:|javascript:)/i.test(value)) {
element.removeAttributeNode(attribute);
}
}
}
for (const styleElement of [...root.querySelectorAll('style')]) {
const content = styleElement.textContent;
if (/@import/i.test(content) || /url\(\s*(['"]?)(?:https?:|\/\/|file:)/i.test(content)) {
styleElement.remove();
}
}
if (!root.hasAttribute('xmlns')) {
root.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
}
let viewBox = root.getAttribute('viewBox') || '';
let parsedValues = viewBox.trim().split(/[\s,]+/).map(Number);
let width = 100;
let height = 100;
if (parsedValues.length === 4 &&
parsedValues.every(Number.isFinite) &&
parsedValues[2] > 0 &&
parsedValues[3] > 0) {
width = parsedValues[2];
height = parsedValues[3];
} else {
const widthAttr = parseFloat(root.getAttribute('width'));
const heightAttr = parseFloat(root.getAttribute('height'));
if (Number.isFinite(widthAttr) && widthAttr > 0 &&
Number.isFinite(heightAttr) && heightAttr > 0) {
width = widthAttr;
height = heightAttr;
}
root.setAttribute('viewBox', `0 0 ${width} ${height}`);
}
root.setAttribute('width', String(width));
root.setAttribute('height', String(height));
return {
text: new XMLSerializer().serializeToString(root),
width,
height
};
}
function loadSvgImage(file, parsedSvg) {
const blob = new Blob([parsedSvg.text], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => {
resolve({
name: file.name,
image,
url,
width: parsedSvg.width,
height: parsedSvg.height
});
};
image.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error(`Could not load ${file.name}.`));
};
image.src = url;
});
}
function updateIconStatus() {
if (!svgAssets.length) {
$('iconStatus').textContent = 'No SVGs loaded — fallback shape will be used.';
return;
}
const names = svgAssets.map(asset => asset.name).join(', ');
$('iconStatus').textContent = `${svgAssets.length} SVG${svgAssets.length === 1 ? '' : 's'} loaded: ${names}`;
}
function clearSvgAssets() {
for (const asset of svgAssets) {
URL.revokeObjectURL(asset.url);
}
svgAssets = [];
updateIconStatus();
renderSafely();
setStatus('Cleared all SVGs. Cells now draw the chosen fallback shape.');
}
function formatTime(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) seconds = 0;
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${String(secs).padStart(2, '0')}`;
}
function updateTimeline() {
if (!video.videoWidth || !Number.isFinite(video.duration) || video.duration <= 0) {
$('timeLabel').textContent = '0:00 / 0:00';
return;
}
const fraction = video.currentTime / video.duration;
$('seek').value = String(Math.round(fraction * 1000));
$('timeLabel').textContent = `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`;
}
function seekTo(targetSeconds) {
return new Promise((resolve, reject) => {
const duration = video.duration;
const maxTime = Number.isFinite(duration) && duration > 0
? Math.max(0, duration - 0.01)
: Math.max(0, targetSeconds);
const boundedTarget = clamp(targetSeconds, 0, maxTime);
if (!video.seeking && Math.abs(video.currentTime - boundedTarget) < 0.001) {
resolve();
return;
}
let timeoutId = null;
const handleSeeked = () => {
clearTimeout(timeoutId);
video.removeEventListener('seeked', handleSeeked);
resolve();
};
video.addEventListener('seeked', handleSeeked);
timeoutId = setTimeout(() => {
video.removeEventListener('seeked', handleSeeked);
reject(new Error('Seeking took too long.'));
}, 8000);
try {
video.currentTime = boundedTarget;
} catch (error) {
clearTimeout(timeoutId);
video.removeEventListener('seeked', handleSeeked);
reject(error);
}
});
}
function exportSettings() {
const fps = clamp(Math.round(Number($('fps').value) || 12), 4, 20);
const requestedSeconds = clamp(Number($('duration').value) || 4, 0.5, 15);
const frameCount = Math.max(1, Math.ceil(requestedSeconds * fps));
return {
fps,
frameCount,
duration: frameCount / fps
};
}
async function captureFrames(onFrame, taskLabel) {
if (!video.videoWidth) {
throw new Error('Choose a video first.');
}
const settings = exportSettings();
const wasPlaying = !video.paused;
const previousTime = video.currentTime;
const maxDuration = Number.isFinite(video.duration) && video.duration > 0
? video.duration
: null;
video.pause();
try {
for (let i = 0; i < settings.frameCount; i++) {
const targetTime = maxDuration
? (i / settings.fps) % maxDuration
: i / settings.fps;
await seekTo(targetTime);
await new Promise(resolve => requestAnimationFrame(resolve));
renderFrame(false);
await onFrame(i, {
...settings,
width: canvas.width,
height: canvas.height
});
setStatus(`${taskLabel}: rendered frame ${i + 1} of ${settings.frameCount}…`);
}
return {
...settings,
width: canvas.width,
height: canvas.height
};
} finally {
try {
await seekTo(previousTime);
} catch (_) {}
if (wasPlaying) {
await video.play().catch(() => {});
}
renderSafely();
updateTimeline();
}
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 60000);
}
function formatNumber(value) {
return Number(value.toFixed(6)).toString();
}
function makeAnimatedSvg(frames, width, height, fps) {
const frameCount = frames.length;
const totalDuration = frameCount / fps;
const totalDurationText = formatNumber(totalDuration);
const imageElements = frames.map((dataUrl, index) => {
let animationTag = '';
if (frameCount > 1) {
let valuesAttribute;
let keyTimesAttribute;
if (index === 0) {
valuesAttribute = 'visible;hidden;hidden';
keyTimesAttribute = `0;${formatNumber(1 / frameCount)};1`;
} else if (index === frameCount - 1) {
valuesAttribute = 'hidden;visible;hidden';
keyTimesAttribute = `0;${formatNumber(index / frameCount)};1`;
} else {
valuesAttribute = 'hidden;visible;hidden;hidden';
keyTimesAttribute = `0;${formatNumber(index / frameCount)};${formatNumber((index + 1) / frameCount)};1`;
}
animationTag = `<animate attributeName="visibility" calcMode="discrete" values="${valuesAttribute}" keyTimes="${keyTimesAttribute}" dur="${totalDurationText}s" repeatCount="indefinite"/>`;
}
const initialVisibility = index === 0 ? 'visible' : 'hidden';
return `<image x="0" y="0" width="${width}" height="${height}" visibility="${initialVisibility}" href="${dataUrl}" xlink:href="${dataUrl}">${animationTag}</image>`;
}).join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
${imageElements}
</svg>`;
}
$('uploadVideoBtn').addEventListener('click', () => $('videoInput').click());
$('addSvgBtn').addEventListener('click', () => $('svgInput').click());
$('videoInput').addEventListener('change', event => {
const file = event.target.files && event.target.files[0];
if (!file) return;
video.pause();
if (videoUrl) {
URL.revokeObjectURL(videoUrl);
}
videoUrl = URL.createObjectURL(file);
video.src = videoUrl;
video.loop = true;
video.muted = true;
video.load();
$('placeholder').style.display = 'flex';
setStatus(`Loading video: ${file.name}…`);
event.target.value = '';
});
video.addEventListener('loadedmetadata', () => {
resizeCanvasForVideo();
$('placeholder').style.display = 'none';
updateTimeline();
renderSafely();
syncActionButtons();
setStatus(`Loaded video (${video.videoWidth} × ${video.videoHeight}px). Ready.`);
video.play().catch(() => {
setStatus('Video ready. Press Play to start preview playback.');
});
});
video.addEventListener('loadeddata', renderSafely);
video.addEventListener('seeked', () => {
renderSafely();
updateTimeline();
});
video.addEventListener('timeupdate', updateTimeline);
video.addEventListener('play', () => {
$('playPause').textContent = 'Pause';
$('timelinePlay').textContent = '⏸';
});
video.addEventListener('pause', () => {
$('playPause').textContent = 'Play';
$('timelinePlay').textContent = '▶';
renderSafely();
});
$('svgInput').addEventListener('change', async event => {
const files = [...(event.target.files || [])];
event.target.value = '';
let added = 0;
for (const file of files) {
if (!file.name.toLowerCase().endsWith('.svg') && file.type !== 'image/svg+xml') {
continue;
}
try {
const parsed = sanitizeSvg(await file.text());
const asset = await loadSvgImage(file, parsed);
svgAssets.push(asset);
added++;
} catch (error) {
console.warn(error);
setStatus(error.message);
}
}
updateIconStatus();
renderSafely();
if (added) {
setStatus(`Added ${added} SVG${added === 1 ? '' : 's'}. All loaded SVGs cycle across the cells.`);
}
});
$('clearSvgBtn').addEventListener('click', clearSvgAssets);
function togglePlayback() {
if (!video.videoWidth) return;
if (video.paused) {
video.play().catch(() => setStatus('The video could not be played.'));
} else {
video.pause();
}
}
$('playPause').addEventListener('click', togglePlayback);
$('timelinePlay').addEventListener('click', togglePlayback);
$('seek').addEventListener('input', event => {
if (!video.videoWidth || !Number.isFinite(video.duration)) return;
video.pause();
const fraction = Number(event.target.value) / 1000;
video.currentTime = fraction * video.duration;
});
const renderControlIds = [
'cellSize', 'scale', 'gap', 'contrast', 'sizeMode',
'backgroundMode', 'cutoff', 'fallbackShape', 'colorMode', 'customColor',
'rotate', 'transparent', 'bgColor', 'outputWidth',
'brightness', 'saturation', 'tintColor', 'tintOpacity'
];
for (const id of renderControlIds) {
const el = $(id);
if (!el) continue;
el.addEventListener('input', () => {
updateLabels();
renderSafely();
});
el.addEventListener('change', () => {
updateLabels();
renderSafely();
});
}
$('colorMode').addEventListener('change', e => {
$('customColorContainer').style.display = e.target.value === 'custom' ? 'block' : 'none';
});
$('saveFrame').addEventListener('click', async () => {
if (!video.videoWidth) {
setStatus('Choose a video first.');
return;
}
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
if (!blob) {
setStatus('Could not create the PNG.');
return;
}
downloadBlob(blob, `brain-motion-${Date.now()}.png`);
setStatus('Saved the current frame as a transparent PNG.');
});
$('exportSvg').addEventListener('click', async () => {
setBusy(true);
setStatus('Preparing animated SVG frames…');
try {
const frames = [];
const meta = await captureFrames(async () => {
frames.push(canvas.toDataURL('image/png'));
}, 'SVG');
const svg = makeAnimatedSvg(frames, meta.width, meta.height, meta.fps);
downloadBlob(
new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }),
`brain-motion-${Date.now()}.svg`
);
setStatus('Saved animated SVG. It embeds transparent PNG frames inside an SVG animation.');
} catch (error) {
setStatus(`SVG export failed: ${error.message}`);
} finally {
setBusy(false);
}
});
async function fetchGifWorker() {
const url = 'https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.worker.js';
const response = await fetch(url);
if (!response.ok) {
throw new Error('Could not download the GIF encoder worker. Run this page from localhost and check your internet connection.');
}
const workerSource = await response.text();
return URL.createObjectURL(
new Blob([workerSource], { type: 'application/javascript' })
);
}
$('exportGif').addEventListener('click', async () => {
if (!window.GIF) {
setStatus('GIF encoder did not load. Check your internet connection and reload the page.');
return;
}
setBusy(true);
let workerUrl = null;
try {
setStatus('Loading GIF encoder…');
workerUrl = await fetchGifWorker();
const options = {
workers: 2,
quality: 10,
repeat: 0,
width: canvas.width,
height: canvas.height,
workerScript: workerUrl
};
const usesTransparency = $('transparent').checked;
const transparentKey = 0xff00ff;
if (usesTransparency) options.transparent = transparentKey;
const gif = new window.GIF(options);
const gifCanvas = document.createElement('canvas');
gifCanvas.width = canvas.width;
gifCanvas.height = canvas.height;
const gifCtx = gifCanvas.getContext('2d', { willReadFrequently: true });
const finished = new Promise((resolve, reject) => {
gif.on('finished', resolve);
gif.on('error', reject);
gif.on('progress', progress => {
setStatus(`Encoding GIF: ${Math.round(progress * 100)}%…`);
});
});
await captureFrames(async (index, meta) => {
const frame = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = frame.data;
for (let i = 0; i < data.length; i += 4) {
if (usesTransparency && data[i + 3] < 128) {
data[i] = 255;
data[i + 1] = 0;
data[i + 2] = 255;
data[i + 3] = 255;
} else {
data[i + 3] = 255;
// Keep actual magenta artwork from matching the transparent key.
if (usesTransparency && data[i] === 255 && data[i + 1] === 0 && data[i + 2] === 255) {
data[i + 1] = 1;
}
}
}
gifCtx.putImageData(frame, 0, 0);
gif.addFrame(gifCanvas, {
copy: true,
delay: Math.max(20, Math.round(1000 / meta.fps))
});
setStatus(`GIF: preparing frame ${index + 1} of ${meta.frameCount}…`);
}, 'GIF');
setStatus('Encoding GIF…');
gif.render();
const blob = await finished;
downloadBlob(blob, `brain-motion-${Date.now()}.gif`);
setStatus('Saved GIF. GIF transparency is limited to transparent/opaque pixels.');
} catch (error) {
console.error(error);
setStatus(`GIF export failed: ${error.message || 'Check that the page is served from localhost.'}`);
} finally {
if (workerUrl) URL.revokeObjectURL(workerUrl);
setBusy(false);
}
});
async function exportRecordedVideo(kind) {
if (!video.videoWidth) {
setStatus('Choose a video first.');
return;
}
if (!window.MediaRecorder || !canvas.captureStream) {
setStatus('This browser does not support canvas video recording. Try a current version of Chrome or Edge.');
return;
}
const mimeCandidates = kind === 'mp4'
? ['video/mp4;codecs=avc1.42E01E', 'video/mp4;codecs=avc1', 'video/mp4']
: ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'];
const mimeType = mimeCandidates.find(type => MediaRecorder.isTypeSupported(type));
if (!mimeType) {
setStatus(kind === 'mp4'
? 'MP4 recording is not supported by this browser. MP4 support is browser-dependent; use WebM instead.'
: 'WebM recording is not supported by this browser.');
return;
}
setBusy(true);
const settings = exportSettings();
const wasPlaying = !video.paused;
const previousTime = video.currentTime;
let stream = null;
let recorder = null;
try {
video.pause();
await seekTo(0);
recordingOpaque = kind === 'mp4';
renderFrame(recordingOpaque);
stream = canvas.captureStream(settings.fps);
recorder = new MediaRecorder(stream, {
mimeType,
videoBitsPerSecond: 5_000_000
});
const chunks = [];
const recordingFinished = new Promise((resolve, reject) => {
recorder.addEventListener('dataavailable', event => {
if (event.data && event.data.size) chunks.push(event.data);
});
recorder.addEventListener('stop', () => {
resolve(new Blob(chunks, { type: recorder.mimeType || mimeType }));
});
recorder.addEventListener('error', event => {
reject(event.error || new Error('Video recording failed.'));
});
});
recorder.start(200);
await video.play();
setStatus(`Recording ${kind.toUpperCase()} for ${settings.duration.toFixed(1)} seconds…`);
await new Promise(resolve => setTimeout(resolve, settings.duration * 1000));
video.pause();
if (recorder.state !== 'inactive') recorder.stop();
const blob = await recordingFinished;
const extension = kind === 'mp4' ? 'mp4' : 'webm';
downloadBlob(blob, `brain-motion-${Date.now()}.${extension}`);
setStatus(kind === 'mp4'
? 'Saved MP4 with an opaque background. MP4 does not support transparency.'
: 'Saved WebM. Transparent alpha depends on browser and codec support.');
} catch (error) {
console.error(error);
setStatus(`Video export failed: ${error.message}`);
} finally {
recordingOpaque = false;
if (recorder && recorder.state !== 'inactive') {
try { recorder.stop(); } catch (_) {}
}
if (stream) stream.getTracks().forEach(track => track.stop());
try {
await seekTo(previousTime);
} catch (_) {}
if (wasPlaying) await video.play().catch(() => {});
renderSafely();
setBusy(false);
}
}
$('exportWebm').addEventListener('click', () => exportRecordedVideo('webm'));
$('exportMp4').addEventListener('click', () => exportRecordedVideo('mp4'));
function previewLoop() {
if (video.videoWidth &&
!video.paused &&
video.readyState >= 2 &&
video.currentTime !== lastRenderedTime) {
renderSafely();
}
requestAnimationFrame(previewLoop);
}
updateLabels();
updateIconStatus();
syncActionButtons();
requestAnimationFrame(previewLoop);
})();
</script>
</body>
</html>