Download assests/html_sample/index1.html from DevForML/scet_v2: direct link, hf CLI and curl.
- Browser
- Download file 39.5 kB
-
https://huggingface.co/spaces/DevForML/scet_v2/resolve/main/assests/html_sample/index1.html
- Command line
-
hf download hf://spaces/DevForML/scet_v2/assests/html_sample/index1.html
-
curl -L -o index1.html https://huggingface.co/spaces/DevForML/scet_v2/resolve/main/assests/html_sample/index1.html
39.5 kB
| <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> | |
| (() => { | |
| ; | |
| 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> | |