scet_v2 / assests /html_sample /index1.html
DevForML's picture
Upload 70 files
015a372 verified
Raw History Blame Contribute Delete
39.5 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 + Color Grade</title>
<style>
:root{
color-scheme: dark;
--bg:#090b11;
--panel:#131826f2;
--panel-2:#1b2231;
--line:#2a3446;
--text:#eef2fb;
--muted:#96a3b8;
--accent:#8d7eff;
--accent-2:#6358d6;
--radius:18px;
}
*{box-sizing:border-box}
body{
margin:0; min-height:100vh; background:var(--bg); color:var(--text);
font:14px/1.5 Inter,system-ui,Segoe UI,Roboto,sans-serif;
overflow-x:hidden;
}
body:before,body:after{
content:""; position:fixed; width:70vw; height:70vw; border-radius:50%; filter:blur(90px); opacity:.18; pointer-events:none; z-index:-1;
}
body:before{ left:-20vw; top:-20vw; background:radial-gradient(circle at 30% 30%, #8d7eff, transparent 60%)}
body:after{ right:-15vw; bottom:-10vw; background:radial-gradient(circle at 30% 30%, #3de4c8, #5b6cff 60%)}
.app{ width:min(1480px,100%); margin:auto; padding:22px 22px 28px}
.topbar{ display:flex; gap:16px; align-items:center; margin-bottom:18px}
.logo{
width:48px; height:48px; border-radius:14px; display:grid; place-items:center;
background:conic-gradient(from 180deg, #a99cff, #6e64ff, #48e6c2, #a99cff);
box-shadow:0 8px 30px #7a6dff55; font-size:22px; color:white; flex:0 0 auto
}
h1{ margin:0; font-size:clamp(20px,3vw,30px); letter-spacing:-.04em; line-height:1.1}
.subtitle{ margin:4px 0 0; color:var(--muted)}
.workspace{ display:grid; grid-template-columns:360px minmax(0,1fr); gap:18px; align-items:start}
.panel,.stage{ border:1px solid var(--line); border-radius:var(--radius); background:var(--panel); backdrop-filter: blur(18px)}
.panel{ padding:16px 16px 10px; max-height:calc(100vh - 96px); overflow:auto}
.stage{ padding:14px}
.section{ padding:14px 0 16px; border-top:1px solid #222c3b}
.section:first-child{ border-top:0; padding-top:4px}
h2{ margin:0 0 10px; font-size:13px; letter-spacing:.14em; text-transform:uppercase; color:#c8d0de}
.btn{
min-height:38px; padding:8px 12px; border:1px solid var(--line); border-radius:11px;
background:var(--panel-2); color:var(--text); cursor:pointer; transition:.15s; font-weight:600
}
.btn:hover:not(:disabled){ border-color:#7b72e0; background:#252f42}
.btn.primary{ background:var(--accent-2); border-color:var(--accent-2); color:white}
.btn.primary:hover{ background:#756ae8}
.btn:disabled{ opacity:.45; cursor:not-allowed}
.button-row{ display:flex; flex-wrap:wrap; gap:8px}
.small-note,.status,.icon-status{ color:var(--muted); font-size:12px; line-height:1.4}
.icon-status{ min-height:18px; margin:8px 0 0; overflow-wrap:anywhere}
.control{ margin:12px 0}
.control-label{ display:flex; justify-content:space-between; gap:10px; margin-bottom:5px; color:#d6deea; font-size:13px}
.control-label output{ color:#b8aaff; 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:10px; background:#101522; color:var(--text)}
input[type=color]{ width:46px; height:34px; padding:2px; border:1px solid var(--line); border-radius:8px; background:#101522}
.toggle-row{ display:flex; flex-wrap:wrap; gap:12px; margin:10px 0}
.toggle{ display:flex; gap:8px; align-items:center; color:#d6deea}
.toggle input{ accent-color:var(--accent)}
.color-row{ display:flex; justify-content:space-between; align-items:center; gap:10px}
.export-grid{ display:grid; grid-template-columns:1fr 1fr; gap:8px}
.export-grid .btn{ min-height:48px}
.preview{
position:relative; display:grid; place-items:center; min-height:360px; overflow:hidden;
border:1px solid #2d384a; border-radius:14px;
background-color:#10141c;
background-image: linear-gradient(45deg,#1a2130 25%,transparent 25%), linear-gradient(-45deg,#1a2130 25%,transparent 25%), linear-gradient(45deg,transparent 75%,#1a2130 75%), linear-gradient(-45deg,transparent 75%,#1a2130 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; text-align:center; color:var(--muted); pointer-events:none}
.timeline{ display:grid; grid-template-columns:auto 1fr auto; gap:10px; align-items:center; margin-top:12px}
.time-label{ min-width:78px; color:var(--muted); font-size:12px; font-variant-numeric:tabular-nums}
.hidden-input,#sourceVideo{ display:none}
.chip{ display:inline-flex; align-items:center; gap:6px; padding:4px 8px; border-radius:999px; background:#1e2635; border:1px solid #2a3446; font-size:11px; color:#b9c4d6}
@media(max-width:980px){ .workspace{ grid-template-columns:1fr} .panel{ max-height:none} .preview{ min-height:240px} }
</style>
</head>
<body>
<div class="app">
<header class="topbar">
<div class="logo">✦</div>
<div>
<h1>Brain Motion — SVG Video Studio</h1>
<p class="subtitle">Video → SVG cells with transparent export + full color grading. Works as a static site.</p>
</div>
<span class="chip">Fable Edition • Color Grade</span>
</header>
<div class="workspace">
<aside class="panel">
<section class="section">
<h2>1. Media</h2>
<div class="button-row">
<button class="btn primary" id="uploadVideoBtn" data-lock>Choose video / image</button>
<button class="btn" id="addSvgBtn" data-lock>Add SVGs</button>
<button class="btn" id="clearSvgBtn" data-lock>Clear</button>
</div>
<input class="hidden-input" id="videoInput" type="file" accept="video/*,image/*">
<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 — fallback shapes will be used.</p>
<p class="small-note">Drop any number of SVG files. Each cell cycles through them. You can also drop an image — it will be treated as a still.</p>
</section>
<section class="section">
<h2>2. Cells</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>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="sizeMode">Size follows</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 tint</label><select id="colorMode" data-lock><option value="sample">Tint to video color</option><option value="custom">Custom Color / Palette</option><option value="shadesBlack">Shades of Black</option><option value="white">White</option><option value="native">Keep SVG colors</option></select></div>
<div class="color-row" id="customColorRow" style="margin:8px 0; display:none"><label for="customColor">Tint color</label><input id="customColor" data-lock type="color" value="#050505"></div>
<div class="toggle-row"><label class="toggle"><input id="rotate" data-lock type="checkbox"> Rotate by luma</label></div>
</section>
<section class="section">
<h2>3. Color Grading</h2>
<div class="control"><label class="control-label" for="gradePreset">Preset</label><select id="gradePreset" data-lock>
<option value="neutral">Neutral</option>
<option value="warmFilm">Warm Film</option>
<option value="coolTech">Cool Tech</option>
<option value="cyberpunk">Cyberpunk</option>
<option value="fable">Fable Pastel</option>
<option value="sunset">Sunset</option>
<option value="bwHigh">B&W High Contrast</option>
</select></div>
<div class="toggle-row">
<label class="toggle"><input id="enableGrade" data-lock type="checkbox" checked> Enable grading</label>
<button class="btn" id="resetGrade" data-lock>Reset</button>
</div>
<div class="control"><label class="control-label" for="exposure"><span>Exposure</span><output id="exposureValue">0</output></label><input id="exposure" data-lock type="range" min="-100" max="100" value="0"></div>
<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="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="saturation"><span>Saturation</span><output id="saturationValue">100%</output></label><input id="saturation" data-lock type="range" min="0" max="200" value="100"></div>
<div class="control"><label class="control-label" for="hue"><span>Hue shift</span><output id="hueValue">0°</output></label><input id="hue" data-lock type="range" min="-180" max="180" value="0"></div>
<div class="control"><label class="control-label" for="temperature"><span>Temperature</span><output id="temperatureValue">0</output></label><input id="temperature" data-lock type="range" min="-100" max="100" value="0"></div>
<div class="control"><label class="control-label" for="tint"><span>Tint (G/M)</span><output id="tintValue">0</output></label><input id="tint" data-lock type="range" min="-100" max="100" value="0"></div>
<div class="control"><label class="control-label" for="vibrance"><span>Vibrance</span><output id="vibranceValue">0</output></label><input id="vibrance" data-lock type="range" min="-100" max="100" value="0"></div>
<div class="control"><label class="control-label" for="highlights"><span>Highlights</span><output id="highlightsValue">0</output></label><input id="highlights" data-lock type="range" min="-100" max="100" value="0"></div>
<div class="control"><label class="control-label" for="shadows"><span>Shadows</span><output id="shadowsValue">0</output></label><input id="shadows" data-lock type="range" min="-100" max="100" value="0"></div>
<p class="small-note">Grading is applied before cell sampling, so it affects tint + size logic.</p>
</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 bg</label></div>
<div class="color-row"><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">Cut out</label><select id="backgroundMode" data-lock><option value="dark">Dark pixels</option><option value="light">Light pixels</option><option value="none">None</option></select></div>
<div class="control"><label class="control-label" for="cutoff"><span>Cut 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="640"></div>
<div class="control"><label class="control-label" for="duration">Export 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:12px 0 8px"><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>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</button>
</div>
<p class="small-note">GIF = 1-bit transparency. MP4 = always opaque. WebM alpha = browser dependent.</p>
</section>
</aside>
<section class="stage">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<h2 style="margin:0">Preview</h2><span class="small-note">Checker = transparent</span>
</div>
<div class="preview" id="previewBox">
<canvas id="canvas" width="640" height="360"></canvas>
<div class="placeholder" id="placeholder">Choose a video or image.<br>Add SVG icons to use them as 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">Ready.</p>
</section>
</div>
</div>
<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, svgAssets=[], busy=false, recordingOpaque=false, lastRenderedTime=-1;
let sourceImage=null, isImageMode=false;
const GRADE_PRESETS={
neutral:{exposure:0,brightness:0,contrast:0,saturation:100,hue:0,temperature:0,tint:0,vibrance:0,highlights:0,shadows:0},
warmFilm:{exposure:5,brightness:4,contrast:8,saturation:110,hue:0,temperature:32,tint:-6,vibrance:18,highlights:-8,shadows:14},
coolTech:{exposure:0,brightness:0,contrast:14,saturation:92,hue:4,temperature:-38,tint:8,vibrance:6,highlights:0,shadows:8},
cyberpunk:{exposure:8,brightness:2,contrast:22,saturation:145,hue:-18,temperature:8,tint:-14,vibrance:32,highlights:-12,shadows:10},
fable:{exposure:6,brightness:8,contrast:-8,saturation:86,hue:2,temperature:14,tint:4,vibrance:-12,highlights:-14,shadows:12},
sunset:{exposure:10,brightness:6,contrast:12,saturation:128,hue:-8,temperature:58,tint:-8,vibrance:22,highlights:-10,shadows:16},
bwHigh:{exposure:4,brightness:2,contrast:38,saturation:0,hue:0,temperature:0,tint:0,vibrance:0,highlights:-6,shadows:10}
};
function setStatus(m){ $('status').textContent=m }
function clamp(v,a,b){ return Math.max(a,Math.min(b,v)) }
function clamp01(v){ return Math.max(0,Math.min(1,v)) }
function rgb01ToHsl(r,g,b){
const max=Math.max(r,g,b), min=Math.min(r,g,b);
let h=0,s=0,l=(max+min)/2;
if(max!==min){
const d=max-min;
s=l>0.5? d/(2-max-min): d/(max+min);
switch(max){
case r: h=(g-b)/d + (g<b?6:0); break;
case g: h=(b-r)/d +2; break;
case b: h=(r-g)/d +4; break;
}
h/=6;
}
return [h,s,l];
}
function hslToRgb01(h,s,l){
let r,g,b;
if(s===0){ r=g=b=l; }
else{
const hue2rgb=(p,q,t)=>{ if(t<0)t+=1; if(t>1)t-=1; if(t<1/6)return p+(q-p)*6*t; if(t<1/2)return q; if(t<2/3)return p+(q-p)*(2/3-t)*6; return p; };
const q=l<0.5? l*(1+s): l+s-l*s; const p=2*l-q;
r=hue2rgb(p,q,h+1/3); g=hue2rgb(p,q,h); b=hue2rgb(p,q,h-1/3);
}
return [r,g,b];
}
function applyGrading(r,g,b,o){
let R=r/255,G=g/255,B=b/255;
const expFactor=Math.pow(2,o.exposure/100);
R*=expFactor; G*=expFactor; B*=expFactor;
const bright=o.brightness/100*0.35; R+=bright; G+=bright; B+=bright;
const temp=o.temperature/100;
if(temp>0){ R+=temp*0.18; G+=temp*0.04; B-=temp*0.14; } else { R+=temp*0.10; B-=temp*0.18; }
const tint=o.tint/100; G+=tint*0.14; R-=tint*0.05; B-=tint*0.05;
const cf=(259*(o.contrast+255))/(255*(259-o.contrast));
R=cf*(R-0.5)+0.5; G=cf*(G-0.5)+0.5; B=cf*(B-0.5)+0.5;
const luma=0.299*R+0.587*G+0.114*B;
const sh=o.shadows/100, hi=o.highlights/100;
if(luma<0.5){ const f=(0.5-luma)*1.6; R+=sh*f*0.22; G+=sh*f*0.22; B+=sh*f*0.22; }
else { const f=(luma-0.5)*1.6; R+=hi*f*0.22; G+=hi*f*0.22; B+=hi*f*0.22; }
let hsl=rgb01ToHsl(clamp01(R),clamp01(G),clamp01(B));
let h=hsl[0], s=hsl[1], l=hsl[2];
h=(h+o.hue/360)%1; if(h<0)h+=1;
s*=(o.saturation/100);
const vib=o.vibrance/100;
if(vib!==0){ s+= (1-s)*vib*0.7; }
s=clamp01(s); l=clamp01(l);
const rgb=hslToRgb01(h,s,l);
return [clamp01(rgb[0])*255, clamp01(rgb[1])*255, clamp01(rgb[2])*255];
}
function getGradeOpts(){
return {
enabled:$('enableGrade').checked,
exposure:+$('exposure').value, brightness:+$('brightness').value, contrast:+$('contrast').value,
saturation:+$('saturation').value, hue:+$('hue').value, temperature:+$('temperature').value,
tint:+$('tint').value, vibrance:+$('vibrance').value, highlights:+$('highlights').value, shadows:+$('shadows').value
};
}
function applyPreset(name){
const p=GRADE_PRESETS[name]; if(!p)return;
for(const k in p){ const el=$(k); if(el) el.value=p[k]; }
updateLabels(); renderSafely();
}
function updateLabels(){
$('cellValue').textContent=`${$('cellSize').value} px`;
$('scaleValue').textContent=(+$('scale').value).toFixed(2);
$('gapValue').textContent=`${$('gap').value} px`;
$('cutoffValue').textContent=`${$('cutoff').value}%`;
$('exposureValue').textContent=`${$('exposure').value>0?'+':''}${$('exposure').value}`;
$('brightnessValue').textContent=`${$('brightness').value>0?'+':''}${$('brightness').value}`;
$('contrastValue').textContent=`${$('contrast').value>0?'+':''}${$('contrast').value}`;
$('saturationValue').textContent=`${$('saturation').value}%`;
$('hueValue').textContent=`${$('hue').value}°`;
$('temperatureValue').textContent=`${$('temperature').value>0?'+':''}${$('temperature').value}`;
$('tintValue').textContent=`${$('tint').value>0?'+':''}${$('tint').value}`;
$('vibranceValue').textContent=`${$('vibrance').value>0?'+':''}${$('vibrance').value}`;
$('highlightsValue').textContent=`${$('highlights').value>0?'+':''}${$('highlights').value}`;
$('shadowsValue').textContent=`${$('shadows').value>0?'+':''}${$('shadows').value}`;
}
function resizeCanvas(){
const w = isImageMode ? (sourceImage?.naturalWidth||640) : (video.videoWidth||640);
const h = isImageMode ? (sourceImage?.naturalHeight||360) : (video.videoHeight||360);
if(!w||!h)return;
const wantW=clamp(+$('outputWidth').value||640,240,1280);
const ratio=Math.min(wantW/w,1280/h);
const nw=Math.max(1,Math.round(w*ratio)), nh=Math.max(1,Math.round(h*ratio));
if(canvas.width!==nw||canvas.height!==nh){ canvas.width=nw; canvas.height=nh; sampleCanvas.width=nw; sampleCanvas.height=nh; }
}
function drawAssetCentered(tCtx, asset, box){
const iw=asset.width||asset.image.naturalWidth||100, ih=asset.height||asset.image.naturalHeight||100;
const s=Math.min(box/iw,box/ih); tCtx.drawImage(asset.image,-iw*s/2,-ih*s/2,iw*s,ih*s);
}
function drawBuiltin(tCtx,type,size,color){
const r=size/2; tCtx.fillStyle=color; tCtx.beginPath();
switch(type){
case 'square': tCtx.rect(-r,-r,size,size); break;
case 'triangle': tCtx.moveTo(0,-r); tCtx.lineTo(r,r); tCtx.lineTo(-r,r); tCtx.closePath(); break;
case 'star': for(let i=0;i<10;i++){ const a=-Math.PI/2+i*Math.PI/5, rad=i%2===0?r:r*0.43, x=Math.cos(a)*rad, y=Math.sin(a)*rad; i? tCtx.lineTo(x,y): tCtx.moveTo(x,y);} tCtx.closePath(); break;
case 'diamond': tCtx.moveTo(0,-r); tCtx.lineTo(r,0); tCtx.lineTo(0,r); tCtx.lineTo(-r,0); tCtx.closePath(); break;
case 'cross': tCtx.rect(-r*0.28,-r,r*0.56,size); tCtx.rect(-r,-r*0.28,size,r*0.56); break;
default: tCtx.arc(0,0,r,0,Math.PI*2);
} tCtx.fill();
}
function drawTintedAsset(asset,color,size){
tintCtx.clearRect(0,0,32,32); tintCtx.save(); tintCtx.translate(16,16); drawAssetCentered(tintCtx,asset,30); tintCtx.restore();
tintCtx.globalCompositeOperation='source-in'; tintCtx.fillStyle=color; tintCtx.fillRect(0,0,32,32); tintCtx.globalCompositeOperation='source-over';
ctx.drawImage(tintCanvas,-size/2,-size/2,size,size);
}
function syncButtons(){
const has=isImageMode||!!video.videoWidth;
$('playPause').disabled=busy||!has||isImageMode;
$('timelinePlay').disabled=busy||!has||isImageMode;
$('seek').disabled=busy||!has||isImageMode;
['exportSvg','exportGif','exportWebm','exportMp4'].forEach(id=>$(id).disabled=busy||!has);
}
function setBusy(v){ busy=v; document.querySelectorAll('[data-lock]').forEach(el=>el.disabled=v); syncButtons(); }
function renderFrame(forceOpaque=false){
if(isImageMode){
if(!sourceImage) return;
resizeCanvas(); const W=canvas.width,H=canvas.height;
sampleCtx.drawImage(sourceImage,0,0,W,H);
} else {
if(!video.videoWidth||video.readyState<2) return;
resizeCanvas();
sampleCtx.drawImage(video,0,0,canvas.width,canvas.height);
}
const W=canvas.width,H=canvas.height;
const pixels=sampleCtx.getImageData(0,0,W,H).data;
ctx.clearRect(0,0,W,H);
const isTrans=$('transparent').checked && !forceOpaque;
if(!isTrans){ ctx.fillStyle=$('bgColor').value; ctx.fillRect(0,0,W,H); }
const cell=+$('cellSize').value, scaleSet=+$('scale').value, gap=+$('gap').value;
const cutoff=+$('cutoff').value/100, cols=Math.ceil(W/cell), rows=Math.ceil(H/cell);
const sizeMode=$('sizeMode').value, bgMode=$('backgroundMode').value, colorMode=$('colorMode').value, rot=$('rotate').checked, fallback=$('fallbackShape').value;
const grade=getGradeOpts();
const contrastFactor=(259*(grade.contrast+255))/(255*(259-grade.contrast));
for(let r=0;r<rows;r++){
const y=r*cell;
for(let c=0;c<cols;c++){
const x=c*cell, sx=Math.min(W-1,Math.floor(x+cell/2)), sy=Math.min(H-1,Math.floor(y+cell/2)), idx=(sy*W+sx)*4;
const a=pixels[idx+3]/255; if(a<0.04) continue;
let R=pixels[idx], G=pixels[idx+1], B=pixels[idx+2];
if(grade.enabled){ [R,G,B]=applyGrading(R,G,B,grade); }
else { R=clamp(contrastFactor*(R-128)+128,0,255); G=clamp(contrastFactor*(G-128)+128,0,255); B=clamp(contrastFactor*(B-128)+128,0,255); }
const luma=(0.299*R+0.587*G+0.114*B)/255;
if(bgMode==='dark'&&luma<cutoff) continue;
if(bgMode==='light'&&luma>1-cutoff) continue;
const bright=sizeMode==='dark'?1-luma:luma;
const size=Math.min(cell*1.8, Math.max(0, cell*scaleSet*(0.12+0.88*bright)-gap));
const customTint = $('customColor').value || '#050505';
let col;
if(colorMode==='white') col = '#ffffff';
else if(colorMode==='custom') col = customTint;
else if(colorMode==='shadesBlack') {
const shade = Math.round(luma * 45);
col = `rgb(${shade},${shade},${shade})`;
} else col = `rgb(${Math.round(R)},${Math.round(G)},${Math.round(B)})`;
const asset=svgAssets.length? svgAssets[(r*cols+c)%svgAssets.length]: null;
ctx.save(); ctx.translate(x+cell/2,y+cell/2); ctx.globalAlpha=a;
if(rot) ctx.rotate((luma-0.5)*Math.PI);
if(asset){ if(colorMode==='native') drawAssetCentered(ctx,asset,size); else drawTintedAsset(asset,col,size); }
else drawBuiltin(ctx,fallback,size,col);
ctx.restore();
}
}
if(!isImageMode) lastRenderedTime=video.currentTime;
$('placeholder').style.display='none';
}
function renderSafely(){ try{ renderFrame(recordingOpaque); }catch(e){ console.error(e); setStatus('Preview error: '+e.message); } }
function sanitizeSvg(src){
const doc=new DOMParser().parseFromString(src,'image/svg+xml');
if(doc.querySelector('parsererror')) throw new Error('Invalid SVG');
const root=doc.documentElement; if(!root||root.localName.toLowerCase()!=='svg') throw new Error('No <svg>');
const blocked=new Set(['script','foreignobject','iframe','object','embed']);
for(const el of [...root.querySelectorAll('*')]){
if(blocked.has(el.localName.toLowerCase())){ el.remove(); continue; }
for(const at of [...el.attributes]){
const n=at.name.toLowerCase(), v=at.value.trim();
if(n.startsWith('on')) el.removeAttributeNode(at);
else if((n==='href'||n==='xlink:href'||n==='src')&&v&&!v.startsWith('#')&&!/^data:image\//i.test(v)) el.removeAttributeNode(at);
else if(/url\(\s*(['"]?)(?:https?:|\/\/|file:|javascript:)/i.test(v)) el.removeAttributeNode(at);
}
}
for(const st of [...root.querySelectorAll('style')]) if(/@import|url\(\s*(['"]?)(?:https?:|\/\/|file:)/i.test(st.textContent)) st.remove();
if(!root.hasAttribute('xmlns')) root.setAttribute('xmlns','http://www.w3.org/2000/svg');
let vb=root.getAttribute('viewBox')||''; let vals=vb.trim().split(/[\s,]+/).map(Number);
let vw=100,vh=100;
if(vals.length===4&&vals.every(Number.isFinite)&&vals[2]>0&&vals[3]>0){ vw=vals[2]; vh=vals[3]; }
else { vw=parseFloat(root.getAttribute('width'))||100; vh=parseFloat(root.getAttribute('height'))||100; root.setAttribute('viewBox',`0 0 ${vw} ${vh}`); }
root.setAttribute('width',String(vw)); root.setAttribute('height',String(vh));
return {text:new XMLSerializer().serializeToString(root), width:vw, height:vh};
}
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((res,rej)=>{ const img=new Image(); img.onload=()=>res({name:file.name,image:img,url,width:parsed.width,height:parsed.height}); img.onerror=()=>{ URL.revokeObjectURL(url); rej(new Error('Cannot load '+file.name)); }; img.src=url; });
}
function updateIconStatus(){
if(!svgAssets.length){ $('iconStatus').textContent='No SVGs — fallback shapes.'; return; }
$('iconStatus').textContent=`${svgAssets.length} SVG(s): ${svgAssets.map(a=>a.name).join(', ')}`;
}
function clearSvg(){ for(const a of svgAssets) URL.revokeObjectURL(a.url); svgAssets=[]; updateIconStatus(); renderSafely(); }
function formatTime(s){ if(!Number.isFinite(s)||s<0)s=0; const m=Math.floor(s/60), sec=Math.floor(s%60); return `${m}:${String(sec).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(sec){ return new Promise((res,rej)=>{ const d=video.duration, max=Number.isFinite(d)&&d>0? Math.max(0,d-0.01): Math.max(0,sec); const t=clamp(sec,0,max); if(!video.seeking&&Math.abs(video.currentTime-t)<0.001) return res(); let timer; const fin=()=>{ clearTimeout(timer); video.removeEventListener('seeked',fin); res(); }; video.addEventListener('seeked',fin); timer=setTimeout(fin,8000); try{ video.currentTime=t; }catch(e){ clearTimeout(timer); video.removeEventListener('seeked',fin); rej(e);} }); }
function exportSettings(){ const fps=clamp(Math.round(+$('fps').value||12),4,20), secs=clamp(+$('duration').value||4,0.5,15), fc=Math.max(1,Math.ceil(secs*fps)); return {fps,frameCount:fc,duration:fc/fps}; }
async function captureFrames(onFrame,label){
if(!video.videoWidth && !isImageMode) throw new Error('Choose media first');
const st=exportSettings(); const wasPlaying=!isImageMode&&!video.paused; const prev=video.currentTime; const md=Number.isFinite(video.duration)&&video.duration>0? video.duration:null;
if(!isImageMode) video.pause();
try{
for(let i=0;i<st.frameCount;i++){
if(!isImageMode){
const time=md? (i/st.fps)%md : i/st.fps; await seekTo(time); await new Promise(r=>requestAnimationFrame(r));
}
renderFrame(false); await onFrame(i,{...st,width:canvas.width,height:canvas.height}); setStatus(`${label}: frame ${i+1}/${st.frameCount}`);
}
return {...st,width:canvas.width,height:canvas.height};
} finally {
if(!isImageMode){ try{ await seekTo(prev);}catch(_){} if(wasPlaying) await video.play().catch(()=>{}); renderSafely(); updateTimeline(); }
}
}
function downloadBlob(blob,name){ const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url),60000); }
function numberText(v){ return Number(v.toFixed(6)).toString(); }
function makeAnimatedSvg(frames,w,h,fps){
const c=frames.length, dur=c/fps, durT=numberText(dur);
const els=frames.map((d,i)=>{
let anim=''; if(c>1){ let vals,kt; if(i===0){ vals='visible;hidden;hidden'; kt=`0;${numberText(1/c)};1`; } else if(i===c-1){ vals='hidden;visible;hidden'; kt=`0;${numberText(i/c)};1`; } else { vals='hidden;visible;hidden;hidden'; kt=`0;${numberText(i/c)};${numberText((i+1)/c)};1`; } anim=`<animate attributeName="visibility" calcMode="discrete" values="${vals}" keyTimes="${kt}" dur="${durT}s" repeatCount="indefinite"/>`; }
const vis=i===0?'visible':'hidden'; return `<image x="0" y="0" width="${w}" height="${h}" visibility="${vis}" href="${d}" xlink:href="${d}">${anim}</image>`;
}).join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>\n<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">\n${els}\n</svg>`;
}
$('uploadVideoBtn').addEventListener('click',()=>$('videoInput').click());
$('addSvgBtn').addEventListener('click',()=>$('svgInput').click());
$('videoInput').addEventListener('change',e=>{
const file=e.target.files&&e.target.files[0]; if(!file)return;
if(file.type.startsWith('image/')){
isImageMode=true; const url=URL.createObjectURL(file); const img=new Image();
img.onload=()=>{ sourceImage=img; resizeCanvas(); $('placeholder').style.display='none'; renderSafely(); syncButtons(); setStatus(`Image loaded: ${img.naturalWidth}×${img.naturalHeight}`); URL.revokeObjectURL(url); };
img.onerror=()=>setStatus('Cannot read image'); img.src=url;
} else {
isImageMode=false; sourceImage=null; 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}…`);
}
e.target.value='';
});
video.addEventListener('loadedmetadata',()=>{ resizeCanvas(); $('placeholder').style.display='none'; updateTimeline(); renderSafely(); syncButtons(); setStatus(`Video ${video.videoWidth}×${video.videoHeight}`); video.play().catch(()=>setStatus('Video loaded. Press Play.')); });
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(); });
$('svgInput').addEventListener('change',async e=>{
const files=[...(e.target.files||[])]; e.target.value=''; let added=0;
for(const f of files){ if(!f.name.toLowerCase().endsWith('.svg')&&f.type!=='image/svg+xml') continue; try{ const parsed=sanitizeSvg(await f.text()); const asset=await loadSvgImage(f,parsed); svgAssets.push(asset); added++; }catch(err){ setStatus(err.message); } }
updateIconStatus(); renderSafely(); if(added) setStatus(`Added ${added} SVG(s)`);
});
$('clearSvgBtn').addEventListener('click',clearSvg);
function togglePlay(){ if(isImageMode||!video.videoWidth) return; if(video.paused) video.play().catch(()=>setStatus('Cannot play')); else video.pause(); }
$('playPause').addEventListener('click',togglePlay); $('timelinePlay').addEventListener('click',togglePlay);
$('seek').addEventListener('input',e=>{ if(isImageMode||!video.videoWidth||!Number.isFinite(video.duration))return; video.pause(); video.currentTime=(+e.target.value/1000)*video.duration; });
const allControls=['cellSize','scale','gap','sizeMode','fallbackShape','colorMode','customColor','rotate','transparent','bgColor','backgroundMode','cutoff','outputWidth','exposure','brightness','contrast','saturation','hue','temperature','tint','vibrance','highlights','shadows','enableGrade'];
for(const id of allControls){ const el=$(id); if(!el)continue; el.addEventListener('input',()=>{ updateLabels(); renderSafely(); }); el.addEventListener('change',()=>{ updateLabels(); renderSafely(); }); }
$('colorMode').addEventListener('change', e => {
$('customColorRow').style.display = e.target.value === 'custom' ? 'flex' : 'none';
});
$('gradePreset').addEventListener('change',e=>applyPreset(e.target.value));
$('resetGrade').addEventListener('click',()=>applyPreset('neutral'));
$('saveFrame').addEventListener('click',async()=>{
if(!canvas.width){ setStatus('No frame'); return; }
const blob=await new Promise(r=>canvas.toBlob(r,'image/png')); if(!blob){ setStatus('PNG fail'); return; }
downloadBlob(blob,`brain-${Date.now()}.png`); setStatus('Saved PNG with transparency');
});
$('exportSvg').addEventListener('click',async()=>{
setBusy(true); setStatus('SVG…');
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-${Date.now()}.svg`);
setStatus('Animated SVG saved (PNG frames inside SVG)');
}catch(e){ setStatus('SVG fail: '+e.message); } finally{ setBusy(false); }
});
async function fetchGifWorker(){ const url='https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.worker.js'; const r=await fetch(url); if(!r.ok) throw new Error('GIF worker download failed'); const t=await r.text(); return URL.createObjectURL(new Blob([t],{type:'application/javascript'})); }
$('exportGif').addEventListener('click',async()=>{
if(!window.GIF){ setStatus('GIF lib not loaded'); return; }
setBusy(true); let wUrl=null;
try{
setStatus('GIF worker…'); wUrl=await fetchGifWorker();
const useTrans=$('transparent').checked; const tKey=0xff00ff;
const opts={workers:2,quality:10,repeat:0,width:canvas.width,height:canvas.height,workerScript:wUrl}; if(useTrans) opts.transparent=tKey;
const gif=new window.GIF(opts); const gc=document.createElement('canvas'); gc.width=canvas.width; gc.height=canvas.height; const gcCtx=gc.getContext('2d',{willReadFrequently:true});
const done=new Promise((res,rej)=>{ gif.on('finished',res); gif.on('error',rej); gif.on('progress',p=>setStatus(`GIF ${Math.round(p*100)}%`)); });
await captureFrames(async(i,meta)=>{
const fr=ctx.getImageData(0,0,canvas.width,canvas.height); const d=fr.data;
for(let j=0;j<d.length;j+=4){ if(useTrans&&d[j+3]<128){ d[j]=255; d[j+1]=0; d[j+2]=255; d[j+3]=255; } else { d[j+3]=255; if(useTrans&&d[j]===255&&d[j+1]===0&&d[j+2]===255) d[j+1]=1; } }
gcCtx.putImageData(fr,0,0); gif.addFrame(gc,{copy:true,delay:Math.max(20,Math.round(1000/meta.fps))}); setStatus(`GIF prep ${i+1}/${meta.frameCount}`);
},'GIF');
setStatus('Encoding GIF…'); gif.render(); const blob=await done; downloadBlob(blob,`brain-${Date.now()}.gif`); setStatus('GIF saved');
}catch(e){ console.error(e); setStatus('GIF fail: '+e.message); } finally{ if(wUrl) URL.revokeObjectURL(wUrl); setBusy(false); }
});
async function exportVideo(kind){
if(!video.videoWidth && !isImageMode){ setStatus('Choose media'); return; }
if(!window.MediaRecorder||!canvas.captureStream){ setStatus('Browser no MediaRecorder'); return; }
const cands=kind==='mp4'? ['video/mp4;codecs=avc1.42E01E','video/mp4;codecs=avc1','video/mp4']: ['video/webm;codecs=vp9','video/webm;codecs=vp8','video/webm'];
const mime=cands.find(t=>MediaRecorder.isTypeSupported(t)); if(!mime){ setStatus(kind+' not supported, try WebM'); return; }
setBusy(true); const st=exportSettings(); let stream=null, rec=null;
try{
if(!isImageMode){ video.pause(); await seekTo(0); }
recordingOpaque=kind==='mp4'; renderFrame(recordingOpaque);
stream=canvas.captureStream(st.fps); rec=new MediaRecorder(stream,{mimeType:mime,videoBitsPerSecond:5000000});
const chunks=[]; const fin=new Promise((res,rej)=>{ rec.addEventListener('dataavailable',e=>{ if(e.data&&e.data.size) chunks.push(e.data); }); rec.addEventListener('stop',()=>res(new Blob(chunks,{type:rec.mimeType||mime}))); rec.addEventListener('error',e=>rej(e.error||new Error('record fail'))); });
rec.start(200); if(!isImageMode) await video.play();
setStatus(`Recording ${kind.toUpperCase()} ${st.duration.toFixed(1)}s…`);
await new Promise(r=>setTimeout(r,st.duration*1000));
if(!isImageMode) video.pause(); if(rec.state!=='inactive') rec.stop();
const blob=await fin; downloadBlob(blob,`brain-${Date.now()}.${kind==='mp4'?'mp4':'webm'}`); setStatus(kind==='mp4'?'MP4 saved (opaque)':'WebM saved');
}catch(e){ setStatus('Video fail: '+e.message); } finally{
recordingOpaque=false; if(rec&&rec.state!=='inactive') try{rec.stop();}catch(_){}
if(stream) stream.getTracks().forEach(t=>t.stop()); setBusy(false); renderSafely();
}
}
$('exportWebm').addEventListener('click',()=>exportVideo('webm')); $('exportMp4').addEventListener('click',()=>exportVideo('mp4'));
function loop(){ if(!isImageMode&&video.videoWidth&&!video.paused&&video.readyState>=2&&video.currentTime!==lastRenderedTime) renderSafely(); requestAnimationFrame(loop); }
updateLabels(); updateIconStatus(); syncButtons(); requestAnimationFrame(loop);
})();
</script>
</body>
</html>