scet_v2 / assests /html_sample /index2.html
DevForML's picture
Upload 70 files
015a372 verified
Raw History Blame Contribute Delete
42.8 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Brain Motion — SVG Video Studio</title>
<style>
:root {
color-scheme: dark;
--bg: #0b0d12;
--panel: #131720;
--panel-2: #191f2b;
--line: #2b3443;
--text: #edf2fa;
--muted: #9aa6b7;
--accent: #8a7dff;
--accent-2: #5f54cf;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button, input, select { font: inherit; }
.app {
width: min(1440px, 100%);
min-height: 100vh;
margin: auto;
padding: 24px;
}
.topbar {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 22px;
}
.logo {
display: grid;
place-items: center;
width: 44px;
height: 44px;
flex: 0 0 auto;
border-radius: 14px;
background: linear-gradient(140deg, #a69cff, #6258d6);
color: white;
font-size: 22px;
}
h1 {
margin: 0;
font-size: clamp(21px, 3vw, 29px);
letter-spacing: -.035em;
}
.subtitle {
margin: 3px 0 0;
color: var(--muted);
}
.workspace {
display: grid;
grid-template-columns: 330px minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.panel, .stage {
border: 1px solid var(--line);
border-radius: 16px;
background: var(--panel);
}
.panel {
padding: 18px;
max-height: calc(100vh - 110px);
overflow: auto;
}
.section + .section {
margin-top: 20px;
padding-top: 18px;
border-top: 1px solid var(--line);
}
h2 {
margin: 0 0 12px;
font-size: 14px;
letter-spacing: .02em;
}
.small-note, .status, .icon-status {
color: var(--muted);
font-size: 12px;
}
.small-note { margin: 9px 0 0; }
.button-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.btn {
min-height: 39px;
padding: 9px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel-2);
color: var(--text);
cursor: pointer;
transition: background .15s, border-color .15s, transform .15s;
}
.btn:hover:not(:disabled) {
border-color: #7770d8;
background: #24283a;
}
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn.primary {
border-color: var(--accent-2);
background: var(--accent-2);
color: white;
}
.btn.primary:hover:not(:disabled) { background: #7167e2; }
.btn:disabled {
opacity: .48;
cursor: not-allowed;
}
.full { width: 100%; }
.icon-status {
min-height: 18px;
margin: 9px 0 0;
overflow-wrap: anywhere;
}
.control { margin: 14px 0; }
.control-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
color: #dce3ed;
}
.control-label output {
color: #b4aaff;
font-variant-numeric: tabular-nums;
}
input[type="range"] {
width: 100%;
accent-color: var(--accent);
}
select, input[type="number"] {
width: 100%;
min-height: 38px;
padding: 7px 10px;
border: 1px solid var(--line);
border-radius: 9px;
background: #10141c;
color: var(--text);
}
input[type="color"] {
width: 46px;
height: 34px;
padding: 2px;
border: 1px solid var(--line);
border-radius: 8px;
background: #10141c;
cursor: pointer;
}
.toggle-row {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin: 12px 0;
}
.toggle {
display: flex;
align-items: center;
gap: 8px;
color: #dce3ed;
}
.toggle input { accent-color: var(--accent); }
.color-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.export-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.export-grid .btn { min-height: 48px; }
.stage {
min-width: 0;
padding: 16px;
}
.stage-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.stage-title {
margin: 0;
font-size: 14px;
}
.stage-hint {
color: var(--muted);
font-size: 12px;
}
.preview {
position: relative;
display: grid;
place-items: center;
min-height: 320px;
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: 22px 22px;
background-position: 0 0, 0 11px, 11px -11px, -11px 0;
}
canvas {
display: block;
width: 100%;
height: auto;
max-height: 72vh;
object-fit: contain;
}
.placeholder {
position: absolute;
padding: 20px;
color: var(--muted);
text-align: center;
pointer-events: none;
}
.timeline {
display: grid;
grid-template-columns: auto minmax(100px, 1fr) auto;
gap: 10px;
align-items: center;
margin-top: 13px;
}
.time-label {
min-width: 76px;
color: var(--muted);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.timeline input { margin: 0; }
.status {
min-height: 20px;
margin: 13px 0 0;
}
.hidden-input, #sourceVideo { display: none; }
@media (max-width: 900px) {
.app { padding: 14px; }
.workspace { grid-template-columns: 1fr; }
.panel { max-height: none; }
.preview { min-height: 220px; }
}
</style>
</head>
<body>
<div class="app">
<header class="topbar">
<div class="logo" aria-hidden="true">✦</div>
<div>
<h1>Brain Motion — SVG Video Studio</h1>
<p class="subtitle">Turn video into animated SVG-cell artwork with a transparent background.</p>
</div>
</header>
<div class="workspace">
<aside class="panel">
<section class="section">
<h2>1. Video and SVGs</h2>
<div class="button-row">
<button class="btn primary" id="uploadVideoBtn" data-lock>Choose 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">Add multiple SVG files. Loaded SVGs cycle through the video cells.</p>
</section>
<section class="section">
<h2>2. Cell appearance</h2>
<div class="control">
<label class="control-label" for="cellSize">
<span>Cell 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>Cell size</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 follows</label>
<select id="sizeMode" data-lock>
<option value="bright">Bright pixels = larger</option>
<option value="dark">Dark pixels = 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</label>
<select id="colorMode" data-lock>
<option value="sample">Tint to video color</option>
<option value="native">Keep original SVG colors</option>
<option value="white">Tint white</option>
</select>
</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>3. Background and 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">
<label for="bgColor">Background color for opaque exports</label>
<input id="bgColor" data-lock type="color" value="#10141b">
</div>
<div class="control">
<label class="control-label" for="backgroundMode">Cut out pixels</label>
<select id="backgroundMode" data-lock>
<option value="dark">Dark pixels</option>
<option value="light">Light pixels</option>
<option value="none">Do not cut out</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">Export duration (seconds)</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 current PNG</button>
</div>
<div class="export-grid">
<button class="btn primary" id="exportSvg" disabled data-lock>Animated 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 (opaque)</button>
</div>
<p class="small-note">
GIF transparency is limited. MP4 is always opaque. WebM alpha depends on browser support.
Video exports contain no audio.
</p>
</section>
</aside>
<section class="stage">
<div class="stage-head">
<h2 class="stage-title">Preview</h2>
<span class="stage-hint">The checkerboard indicates transparent pixels.</span>
</div>
<div class="preview">
<canvas id="canvas" width="480" height="270"></canvas>
<div class="placeholder" id="placeholder">Choose a video to start.<br>Add one or more SVG files to use them as animated cells.</div>
</div>
<div class="timeline">
<button class="btn" id="timelinePlay" disabled data-lock>Play</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. Choose 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;
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}%`;
}
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);
}
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;
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;
const red = adjustChannel(pixels[index], contrastFactor);
const green = adjustChannel(pixels[index + 1], contrastFactor);
const blue = adjustChannel(pixels[index + 2], contrastFactor);
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;
const color = colorMode === 'white'
? '#ffffff'
: `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('*')]) {
if (blocked.has(element.localName.toLowerCase())) {
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);
continue;
}
if ((name === 'href' || name === 'xlink:href' || name === 'src') &&
value &&
!value.startsWith('#') &&
!/^data:image\//i.test(value)) {
element.removeAttributeNode(attribute);
continue;
}
if (/url\(\s*(['"]?)(?:https?:|\/\/|file:|javascript:)/i.test(value)) {
element.removeAttributeNode(attribute);
}
}
}
for (const style of [...root.querySelectorAll('style')]) {
if (/@import|url\(\s*(['"]?)(?:https?:|\/\/|file:)/i.test(style.textContent)) {
style.remove();
}
}
if (!root.hasAttribute('xmlns')) {
root.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
}
const viewBoxText = root.getAttribute('viewBox') || '';
const values = viewBoxText.trim().split(/[\s,]+/).map(Number);
let viewX = 0;
let viewY = 0;
let viewWidth = 100;
let viewHeight = 100;
if (values.length === 4 && values.every(Number.isFinite) && values[2] > 0 && values[3] > 0) {
[viewX, viewY, viewWidth, viewHeight] = values;
} else {
viewWidth = parseFloat(root.getAttribute('width')) || 100;
viewHeight = parseFloat(root.getAttribute('height')) || 100;
root.setAttribute('viewBox', `0 0 ${viewWidth} ${viewHeight}`);
}
root.setAttribute('width', String(viewWidth));
root.setAttribute('height', String(viewHeight));
return {
text: new XMLSerializer().serializeToString(root),
width: viewWidth,
height: viewHeight
};
}
function loadSvgImage(file, parsed) {
const blob = new Blob([parsed.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: parsed.width,
height: parsed.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 — circles will be used.';
return;
}
$('iconStatus').textContent =
`${svgAssets.length} SVG${svgAssets.length === 1 ? '' : 's'} loaded: ` +
svgAssets.map(asset => asset.name).join(', ');
}
function clearSvgAssets() {
for (const asset of svgAssets) URL.revokeObjectURL(asset.url);
svgAssets = [];
updateIconStatus();
renderSafely();
}
function formatTime(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) seconds = 0;
const minutes = Math.floor(seconds / 60);
const wholeSeconds = Math.floor(seconds % 60);
return `${minutes}:${String(wholeSeconds).padStart(2, '0')}`;
}
function updateTimeline() {
if (!video.videoWidth || !Number.isFinite(video.duration) || video.duration <= 0) {
$('timeLabel').textContent = '0:00 / 0:00';
return;
}
$('seek').value = String(Math.round(video.currentTime / video.duration * 1000));
$('timeLabel').textContent =
`${formatTime(video.currentTime)} / ${formatTime(video.duration)}`;
}
function seekTo(seconds) {
return new Promise((resolve, reject) => {
const duration = video.duration;
const maximum = Number.isFinite(duration) && duration > 0
? Math.max(0, duration - 0.01)
: Math.max(0, seconds);
const target = clamp(seconds, 0, maximum);
if (!video.seeking && Math.abs(video.currentTime - target) < 0.001) {
resolve();
return;
}
let timer;
const finish = () => {
clearTimeout(timer);
video.removeEventListener('seeked', finish);
resolve();
};
video.addEventListener('seeked', finish);
timer = setTimeout(finish, 8000);
try {
video.currentTime = target;
} catch (error) {
clearTimeout(timer);
video.removeEventListener('seeked', finish);
reject(error);
}
});
}
function exportSettings() {
const fps = clamp(Math.round(Number($('fps').value) || 12), 4, 20);
const seconds = clamp(Number($('duration').value) || 4, 0.5, 15);
const frameCount = Math.max(1, Math.ceil(seconds * fps));
return {
fps,
frameCount,
duration: frameCount / fps
};
}
async function captureFrames(onFrame, label) {
if (!video.videoWidth) throw new Error('Choose a video first.');
const settings = exportSettings();
const wasPlaying = !video.paused;
const previousTime = video.currentTime;
const mediaDuration = Number.isFinite(video.duration) && video.duration > 0
? video.duration
: null;
video.pause();
try {
for (let i = 0; i < settings.frameCount; i++) {
const time = mediaDuration
? (i / settings.fps) % mediaDuration
: i / settings.fps;
await seekTo(time);
await new Promise(resolve => requestAnimationFrame(resolve));
renderFrame(false);
await onFrame(i, {
...settings,
width: canvas.width,
height: canvas.height
});
setStatus(`${label}: rendering 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 numberText(value) {
return Number(value.toFixed(6)).toString();
}
function makeAnimatedSvg(frames, width, height, fps) {
const count = frames.length;
const duration = count / fps;
const durationText = numberText(duration);
const imageElements = frames.map((data, index) => {
let animation = '';
if (count > 1) {
let values;
let keyTimes;
if (index === 0) {
values = 'visible;hidden;hidden';
keyTimes = `0;${numberText(1 / count)};1`;
} else if (index === count - 1) {
values = `hidden;visible;hidden`;
keyTimes = `0;${numberText(index / count)};1`;
} else {
values = 'hidden;visible;hidden;hidden';
keyTimes =
`0;${numberText(index / count)};${numberText((index + 1) / count)};1`;
}
animation =
`<animate attributeName="visibility" calcMode="discrete" ` +
`values="${values}" keyTimes="${keyTimes}" ` +
`dur="${durationText}s" repeatCount="indefinite"/>`;
}
const initialVisibility = index === 0 ? 'visible' : 'hidden';
return `<image x="0" y="0" width="${width}" height="${height}" ` +
`visibility="${initialVisibility}" href="${data}" xlink:href="${data}">` +
`${animation}</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 = 'block';
setStatus(`Loading ${file.name}…`);
event.target.value = '';
});
video.addEventListener('loadedmetadata', () => {
resizeCanvasForVideo();
$('seek').disabled = false;
$('timelinePlay').disabled = false;
$('playPause').disabled = false;
$('placeholder').style.display = 'none';
updateTimeline();
renderSafely();
syncActionButtons();
setStatus(`Video loaded: ${video.videoWidth} × ${video.videoHeight}.`);
video.play().catch(() => {
setStatus('Video loaded. Press Play to start the preview.');
});
});
video.addEventListener('loadeddata', () => renderSafely());
video.addEventListener('seeked', () => {
renderSafely();
updateTimeline();
});
video.addEventListener('timeupdate', updateTimeline);
video.addEventListener('play', () => {
$('playPause').textContent = 'Pause';
$('timelinePlay').textContent = 'Pause';
});
video.addEventListener('pause', () => {
$('playPause').textContent = 'Play';
$('timelinePlay').textContent = 'Play';
renderSafely();
});
video.addEventListener('error', () => {
setStatus('Could not read that video. Try an MP4 or WebM file supported by your browser.');
});
$('svgInput').addEventListener('change', async event => {
const files = [...(event.target.files || [])];
event.target.value = '';
if (!files.length) return;
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',
'rotate', 'transparent', 'bgColor', 'outputWidth'
];
for (const id of renderControlIds) {
$(id).addEventListener('input', () => {
updateLabels();
renderSafely();
});
$(id).addEventListener('change', () => {
updateLabels();
renderSafely();
});
}
$('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>