Download assests/html_sample/index3.html from DevForML/scet_v2: direct link, hf CLI and curl.
- Browser
- Download file 46.7 kB
-
https://huggingface.co/spaces/DevForML/scet_v2/resolve/main/assests/html_sample/index3.html
- Command line
-
hf download hf://spaces/DevForML/scet_v2/assests/html_sample/index3.html
-
curl -L -o index3.html https://huggingface.co/spaces/DevForML/scet_v2/resolve/main/assests/html_sample/index3.html
46.7 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</title> | |
| <style> | |
| :root { | |
| color-scheme: dark; | |
| --bg: #0b0d12; | |
| --panel: #14171f; | |
| --panel-2: #1a1e28; | |
| --line: #272c3a; | |
| --text: #edf2fa; | |
| --muted: #8d97a8; | |
| --accent: #8a7dff; | |
| --accent-2: #6258cf; | |
| --radius: 14px; | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| min-height: 100vh; | |
| background: radial-gradient(circle at top left, #151929, var(--bg) 60%); | |
| color: var(--text); | |
| font: 13px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| } | |
| button, input, select { font: inherit; } | |
| .app { | |
| width: min(1480px, 100%); | |
| min-height: 100vh; | |
| margin: auto; | |
| padding: 20px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 20px; | |
| } | |
| .topbar { | |
| display: flex; | |
| align-items: center; | |
| gap: 16px; | |
| } | |
| .logo { | |
| display: grid; | |
| place-items: center; | |
| width: 46px; | |
| height: 46px; | |
| flex: 0 0 auto; | |
| border-radius: 14px; | |
| background: linear-gradient(140deg, #a69cff, #5f54cf); | |
| color: white; | |
| font-size: 24px; | |
| box-shadow: 0 4px 12px rgba(98, 88, 207, 0.3); | |
| } | |
| h1 { | |
| margin: 0; | |
| font-size: clamp(20px, 3vw, 26px); | |
| font-weight: 600; | |
| letter-spacing: -.03em; | |
| } | |
| .subtitle { | |
| margin: 2px 0 0; | |
| color: var(--muted); | |
| font-size: 13px; | |
| } | |
| .workspace { | |
| display: grid; | |
| grid-template-columns: 320px minmax(0, 1fr); | |
| gap: 20px; | |
| align-items: start; | |
| flex: 1; | |
| } | |
| .panel, .stage { | |
| border: 1px solid var(--line); | |
| border-radius: var(--radius); | |
| background: var(--panel); | |
| } | |
| .panel { | |
| padding: 16px; | |
| max-height: calc(100vh - 100px); | |
| overflow-y: auto; | |
| scrollbar-width: thin; | |
| scrollbar-color: var(--line) transparent; | |
| } | |
| .panel::-webkit-scrollbar { width: 6px; } | |
| .panel::-webkit-scrollbar-thumb { background: var(--line); border-radius: 3px; } | |
| .section + .section { | |
| margin-top: 16px; | |
| padding-top: 16px; | |
| border-top: 1px solid var(--line); | |
| } | |
| h2 { | |
| margin: 0 0 12px; | |
| font-size: 13px; | |
| font-weight: 600; | |
| letter-spacing: .03em; | |
| text-transform: uppercase; | |
| color: #bcc5d6; | |
| } | |
| .small-note, .status, .icon-status { | |
| color: var(--muted); | |
| font-size: 11px; | |
| } | |
| .small-note { margin: 8px 0 0; line-height: 1.4; } | |
| .button-row { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| } | |
| .btn { | |
| min-height: 36px; | |
| padding: 8px 14px; | |
| border: 1px solid var(--line); | |
| border-radius: 9px; | |
| background: var(--panel-2); | |
| color: var(--text); | |
| cursor: pointer; | |
| transition: all .15s ease; | |
| font-weight: 500; | |
| } | |
| .btn:hover:not(:disabled) { | |
| border-color: #7770d8; | |
| background: #24283a; | |
| } | |
| .btn:active:not(:disabled) { transform: scale(0.97); } | |
| .btn.primary { | |
| border-color: var(--accent-2); | |
| background: var(--accent-2); | |
| color: white; | |
| } | |
| .btn.primary:hover:not(:disabled) { background: #7167e2; } | |
| .btn:disabled { | |
| opacity: .4; | |
| cursor: not-allowed; | |
| } | |
| .full { width: 100%; } | |
| .icon-status { | |
| min-height: 16px; | |
| margin: 8px 0 0; | |
| overflow-wrap: anywhere; | |
| } | |
| .control { margin: 12px 0; } | |
| .control-label { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 10px; | |
| margin-bottom: 5px; | |
| color: #dce3ed; | |
| font-size: 12px; | |
| } | |
| .control-label output { | |
| color: #b4aaff; | |
| font-variant-numeric: tabular-nums; | |
| font-size: 11px; | |
| } | |
| input[type="range"] { | |
| width: 100%; | |
| accent-color: var(--accent); | |
| height: 6px; | |
| } | |
| select, input[type="number"] { | |
| width: 100%; | |
| min-height: 34px; | |
| padding: 6px 10px; | |
| border: 1px solid var(--line); | |
| border-radius: 8px; | |
| background: #10141c; | |
| color: var(--text); | |
| } | |
| input[type="color"] { | |
| width: 40px; | |
| height: 30px; | |
| padding: 2px; | |
| border: 1px solid var(--line); | |
| border-radius: 6px; | |
| background: #10141c; | |
| cursor: pointer; | |
| } | |
| .toggle-row { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 14px; | |
| margin: 10px 0; | |
| } | |
| .toggle { | |
| display: flex; | |
| align-items: center; | |
| gap: 7px; | |
| color: #dce3ed; | |
| font-size: 12px; | |
| } | |
| .toggle input { accent-color: var(--accent); } | |
| .color-row { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 10px; | |
| font-size: 12px; | |
| } | |
| .export-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 8px; | |
| } | |
| .export-grid .btn { min-height: 42px; font-size: 12px; } | |
| .stage { | |
| min-width: 0; | |
| padding: 16px; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .stage-head { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| gap: 12px; | |
| margin-bottom: 12px; | |
| } | |
| .stage-title { | |
| margin: 0; | |
| font-size: 14px; | |
| font-weight: 600; | |
| } | |
| .stage-hint { | |
| color: var(--muted); | |
| font-size: 11px; | |
| } | |
| .preview { | |
| position: relative; | |
| display: grid; | |
| place-items: center; | |
| min-height: 400px; | |
| overflow: hidden; | |
| border: 1px solid #303846; | |
| border-radius: 12px; | |
| background-color: #10141b; | |
| background-image: | |
| linear-gradient(45deg, #1a202a 25%, transparent 25%), | |
| linear-gradient(-45deg, #1a202a 25%, transparent 25%), | |
| linear-gradient(45deg, transparent 75%, #1a202a 75%), | |
| linear-gradient(-45deg, transparent 75%, #1a202a 75%); | |
| background-size: 20px 20px; | |
| background-position: 0 0, 0 10px, 10px -10px, -10px 0px; | |
| flex: 1; | |
| } | |
| canvas { | |
| display: block; | |
| width: 100%; | |
| height: auto; | |
| max-height: 70vh; | |
| object-fit: contain; | |
| } | |
| .placeholder { | |
| position: absolute; | |
| padding: 30px; | |
| color: var(--muted); | |
| text-align: center; | |
| pointer-events: none; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 12px; | |
| } | |
| .placeholder-icon { | |
| font-size: 42px; | |
| opacity: 0.4; | |
| color: var(--accent); | |
| } | |
| .timeline { | |
| display: grid; | |
| grid-template-columns: auto minmax(100px, 1fr) auto; | |
| gap: 12px; | |
| align-items: center; | |
| margin-top: 14px; | |
| } | |
| .time-label { | |
| min-width: 80px; | |
| color: var(--muted); | |
| font-size: 11px; | |
| font-variant-numeric: tabular-nums; | |
| text-align: right; | |
| } | |
| .timeline input { margin: 0; } | |
| .status { | |
| min-height: 18px; | |
| margin: 12px 0 0; | |
| font-size: 11px; | |
| } | |
| .hidden-input, #sourceVideo { display: none; } | |
| @media (max-width: 900px) { | |
| .app { padding: 12px; } | |
| .workspace { grid-template-columns: 1fr; } | |
| .panel { max-height: none; } | |
| .preview { min-height: 280px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app"> | |
| <header class="topbar"> | |
| <div class="logo" aria-hidden="true">✦</div> | |
| <div> | |
| <h1>Brain Motion Studio</h1> | |
| <p class="subtitle">Transform video into dynamic SVG-cell artwork with transparency & color grading.</p> | |
| </div> | |
| </header> | |
| <div class="workspace"> | |
| <aside class="panel"> | |
| <section class="section"> | |
| <h2>1. Source Media</h2> | |
| <div class="button-row"> | |
| <button class="btn primary" id="uploadVideoBtn" data-lock>Video</button> | |
| <button class="btn" id="addSvgBtn" data-lock>Add SVGs</button> | |
| <button class="btn" id="clearSvgBtn" data-lock>Clear SVGs</button> | |
| </div> | |
| <input class="hidden-input" id="videoInput" type="file" accept="video/*"> | |
| <input class="hidden-input" id="svgInput" type="file" accept=".svg,image/svg+xml" multiple> | |
| <video id="sourceVideo" playsinline muted></video> | |
| <p class="icon-status" id="iconStatus">No SVGs loaded — circles will be used.</p> | |
| <p class="small-note">Upload a video, then add any number of SVG files. They will automatically cycle through the cells.</p> | |
| </section> | |
| <section class="section"> | |
| <h2>2. Color Grading</h2> | |
| <div class="control"> | |
| <label class="control-label" for="brightness"> | |
| <span>Brightness</span><output id="brightnessValue">0</output> | |
| </label> | |
| <input id="brightness" data-lock type="range" min="-100" max="100" value="0"> | |
| </div> | |
| <div class="control"> | |
| <label class="control-label" for="saturation"> | |
| <span>Saturation</span><output id="saturationValue">0</output> | |
| </label> | |
| <input id="saturation" data-lock type="range" min="-100" max="100" value="0"> | |
| </div> | |
| <div class="color-row" style="margin: 12px 0"> | |
| <label for="tintColor">Tint / Overlay</label> | |
| <input id="tintColor" data-lock type="color" value="#8a7dff"> | |
| </div> | |
| <div class="control"> | |
| <label class="control-label" for="tintOpacity"> | |
| <span>Tint Intensity</span><output id="tintOpacityValue">0%</output> | |
| </label> | |
| <input id="tintOpacity" data-lock type="range" min="0" max="100" value="0"> | |
| </div> | |
| </section> | |
| <section class="section"> | |
| <h2>3. Cell Appearance</h2> | |
| <div class="control"> | |
| <label class="control-label" for="cellSize"> | |
| <span>Spacing</span><output id="cellValue">14 px</output> | |
| </label> | |
| <input id="cellSize" data-lock type="range" min="6" max="40" value="14"> | |
| </div> | |
| <div class="control"> | |
| <label class="control-label" for="scale"> | |
| <span>Scale</span><output id="scaleValue">0.95</output> | |
| </label> | |
| <input id="scale" data-lock type="range" min="0.25" max="1.8" step="0.05" value="0.95"> | |
| </div> | |
| <div class="control"> | |
| <label class="control-label" for="gap"> | |
| <span>Gap</span><output id="gapValue">1 px</output> | |
| </label> | |
| <input id="gap" data-lock type="range" min="0" max="12" step="0.5" value="1"> | |
| </div> | |
| <div class="control"> | |
| <label class="control-label" for="contrast"> | |
| <span>Contrast</span><output id="contrastValue">0</output> | |
| </label> | |
| <input id="contrast" data-lock type="range" min="-100" max="100" value="0"> | |
| </div> | |
| <div class="control"> | |
| <label class="control-label" for="sizeMode">Size Logic</label> | |
| <select id="sizeMode" data-lock> | |
| <option value="bright">Bright = Larger</option> | |
| <option value="dark">Dark = Larger</option> | |
| </select> | |
| </div> | |
| <div class="control"> | |
| <label class="control-label" for="fallbackShape">Fallback Shape</label> | |
| <select id="fallbackShape" data-lock> | |
| <option value="circle">Circle</option> | |
| <option value="square">Square</option> | |
| <option value="triangle">Triangle</option> | |
| <option value="star">Star</option> | |
| <option value="diamond">Diamond</option> | |
| <option value="cross">Cross</option> | |
| </select> | |
| </div> | |
| <div class="control"> | |
| <label class="control-label" for="colorMode">SVG Color</label> | |
| <select id="colorMode" data-lock> | |
| <option value="sample">Tint to Video Color</option> | |
| <option value="native">Keep Original SVG</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>4. Background & Export</h2> | |
| <div class="toggle-row"> | |
| <label class="toggle"><input id="transparent" data-lock type="checkbox" checked> Transparent Background</label> | |
| </div> | |
| <div class="color-row" style="margin: 10px 0"> | |
| <label for="bgColor">Opaque Bg Color</label> | |
| <input id="bgColor" data-lock type="color" value="#10141b"> | |
| </div> | |
| <div class="control"> | |
| <label class="control-label" for="backgroundMode">Cutout Logic</label> | |
| <select id="backgroundMode" data-lock> | |
| <option value="dark">Remove Dark Pixels</option> | |
| <option value="light">Remove Light Pixels</option> | |
| <option value="none">Keep All</option> | |
| </select> | |
| </div> | |
| <div class="control"> | |
| <label class="control-label" for="cutoff"> | |
| <span>Cutout Threshold</span><output id="cutoffValue">5%</output> | |
| </label> | |
| <input id="cutoff" data-lock type="range" min="0" max="95" value="5"> | |
| </div> | |
| <div class="control"> | |
| <label class="control-label" for="outputWidth">Output Width</label> | |
| <input id="outputWidth" data-lock type="number" min="240" max="1280" step="80" value="480"> | |
| </div> | |
| <div class="control"> | |
| <label class="control-label" for="duration">Duration (sec)</label> | |
| <input id="duration" data-lock type="number" min="0.5" max="15" step="0.5" value="4"> | |
| </div> | |
| <div class="control"> | |
| <label class="control-label" for="fps">Export FPS</label> | |
| <input id="fps" data-lock type="number" min="4" max="20" step="1" value="12"> | |
| </div> | |
| <div class="button-row" style="margin: 14px 0 9px"> | |
| <button class="btn" id="playPause" disabled data-lock>Play</button> | |
| <button class="btn" id="saveFrame" data-lock>Save PNG</button> | |
| </div> | |
| <div class="export-grid"> | |
| <button class="btn primary" id="exportSvg" disabled data-lock>SVG</button> | |
| <button class="btn" id="exportGif" disabled data-lock>GIF</button> | |
| <button class="btn" id="exportWebm" disabled data-lock>WebM</button> | |
| <button class="btn" id="exportMp4" disabled data-lock>MP4</button> | |
| </div> | |
| <p class="small-note"> | |
| WebM/MP4 export contains no audio. MP4 is always opaque. GIF transparency is 1-bit (on/off). | |
| </p> | |
| </section> | |
| </aside> | |
| <section class="stage"> | |
| <div class="stage-head"> | |
| <h2 class="stage-title">Preview</h2> | |
| <span class="stage-hint">Checkerboard = Transparency</span> | |
| </div> | |
| <div class="preview"> | |
| <canvas id="canvas" width="480" height="270"></canvas> | |
| <div class="placeholder" id="placeholder"> | |
| <div class="placeholder-icon">✦</div> | |
| <div>Choose a video to start.<br>Add SVGs to use them as animated cells.</div> | |
| </div> | |
| </div> | |
| <div class="timeline"> | |
| <button class="btn" id="timelinePlay" disabled data-lock style="min-height: 32px; padding: 6px 12px;">▶</button> | |
| <input id="seek" type="range" min="0" max="1000" value="0" disabled data-lock> | |
| <span class="time-label" id="timeLabel">0:00 / 0:00</span> | |
| </div> | |
| <p class="status" id="status" aria-live="polite">Ready. Load a video to begin.</p> | |
| </section> | |
| </div> | |
| </div> | |
| <!-- GIF encoding dependency. Other exports do not require this library. --> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.js"></script> | |
| <script> | |
| (() => { | |
| 'use strict'; | |
| const $ = id => document.getElementById(id); | |
| const video = $('sourceVideo'); | |
| const canvas = $('canvas'); | |
| const ctx = canvas.getContext('2d', { alpha: true }); | |
| const sampleCanvas = document.createElement('canvas'); | |
| const sampleCtx = sampleCanvas.getContext('2d', { willReadFrequently: true }); | |
| const tintCanvas = document.createElement('canvas'); | |
| tintCanvas.width = 32; | |
| tintCanvas.height = 32; | |
| const tintCtx = tintCanvas.getContext('2d'); | |
| let videoUrl = null; | |
| let svgAssets = []; | |
| let busy = false; | |
| let recordingOpaque = false; | |
| let lastRenderedTime = -1; | |
| const exportButtons = [ | |
| $('exportSvg'), $('exportGif'), $('exportWebm'), $('exportMp4') | |
| ]; | |
| function setStatus(message) { | |
| $('status').textContent = message; | |
| } | |
| function syncActionButtons() { | |
| const hasVideo = !!video.videoWidth; | |
| $('playPause').disabled = busy || !hasVideo; | |
| $('timelinePlay').disabled = busy || !hasVideo; | |
| $('seek').disabled = busy || !hasVideo; | |
| for (const button of exportButtons) { | |
| button.disabled = busy || !hasVideo; | |
| } | |
| } | |
| function setBusy(value) { | |
| busy = value; | |
| document.querySelectorAll('[data-lock]').forEach(el => { | |
| el.disabled = value; | |
| }); | |
| syncActionButtons(); | |
| } | |
| function clamp(value, min, max) { | |
| return Math.max(min, Math.min(max, value)); | |
| } | |
| function updateLabels() { | |
| $('cellValue').textContent = `${$('cellSize').value} px`; | |
| $('scaleValue').textContent = Number($('scale').value).toFixed(2); | |
| $('gapValue').textContent = `${$('gap').value} px`; | |
| const contrast = Number($('contrast').value); | |
| $('contrastValue').textContent = `${contrast > 0 ? '+' : ''}${contrast}`; | |
| $('cutoffValue').textContent = `${$('cutoff').value}%`; | |
| const brightness = Number($('brightness').value); | |
| $('brightnessValue').textContent = `${brightness > 0 ? '+' : ''}${brightness}`; | |
| const saturation = Number($('saturation').value); | |
| $('saturationValue').textContent = `${saturation > 0 ? '+' : ''}${saturation}`; | |
| $('tintOpacityValue').textContent = `${$('tintOpacity').value}%`; | |
| } | |
| function resizeCanvasForVideo() { | |
| if (!video.videoWidth || !video.videoHeight) return; | |
| const wantedWidth = clamp(Number($('outputWidth').value) || 480, 240, 1280); | |
| const maxHeight = 1280; | |
| const ratio = Math.min( | |
| wantedWidth / video.videoWidth, | |
| maxHeight / video.videoHeight | |
| ); | |
| const width = Math.max(1, Math.round(video.videoWidth * ratio)); | |
| const height = Math.max(1, Math.round(video.videoHeight * ratio)); | |
| if (canvas.width !== width || canvas.height !== height) { | |
| canvas.width = width; | |
| canvas.height = height; | |
| sampleCanvas.width = width; | |
| sampleCanvas.height = height; | |
| } | |
| } | |
| function adjustChannel(value, factor) { | |
| return clamp(factor * (value - 128) + 128, 0, 255); | |
| } | |
| // Color Grading Logic | |
| function gradeColor(r, g, b) { | |
| // Brightness adjustment | |
| const brightness = Number($('brightness').value); | |
| if (brightness !== 0) { | |
| const adj = brightness * 2.55; | |
| r += adj; | |
| g += adj; | |
| b += adj; | |
| } | |
| // Saturation adjustment | |
| const saturation = Number($('saturation').value); | |
| if (saturation !== 0) { | |
| const luma = 0.299 * r + 0.587 * g + 0.114 * b; | |
| const factor = 1 + (saturation / 100); | |
| r = luma + (r - luma) * factor; | |
| g = luma + (g - luma) * factor; | |
| b = luma + (b - luma) * factor; | |
| } | |
| // Tint overlay | |
| const tintOpacity = Number($('tintOpacity').value) / 100; | |
| if (tintOpacity > 0) { | |
| const hex = $('tintColor').value.replace(/^#/, ''); | |
| const tR = parseInt(hex.substring(0, 2), 16); | |
| const tG = parseInt(hex.substring(2, 4), 16); | |
| const tB = parseInt(hex.substring(4, 6), 16); | |
| r = r * (1 - tintOpacity) + tR * tintOpacity; | |
| g = g * (1 - tintOpacity) + tG * tintOpacity; | |
| b = b * (1 - tintOpacity) + tB * tintOpacity; | |
| } | |
| return [ | |
| clamp(Math.round(r), 0, 255), | |
| clamp(Math.round(g), 0, 255), | |
| clamp(Math.round(b), 0, 255) | |
| ]; | |
| } | |
| function drawAssetCentered(targetCtx, asset, boxSize) { | |
| const iw = asset.width || asset.image.naturalWidth || 100; | |
| const ih = asset.height || asset.image.naturalHeight || 100; | |
| const scale = Math.min(boxSize / iw, boxSize / ih); | |
| targetCtx.drawImage( | |
| asset.image, | |
| -iw * scale / 2, | |
| -ih * scale / 2, | |
| iw * scale, | |
| ih * scale | |
| ); | |
| } | |
| function drawBuiltin(targetCtx, type, size, color) { | |
| const r = size / 2; | |
| targetCtx.fillStyle = color; | |
| targetCtx.beginPath(); | |
| switch (type) { | |
| case 'square': | |
| targetCtx.rect(-r, -r, size, size); | |
| break; | |
| case 'triangle': | |
| targetCtx.moveTo(0, -r); | |
| targetCtx.lineTo(r, r); | |
| targetCtx.lineTo(-r, r); | |
| targetCtx.closePath(); | |
| break; | |
| case 'star': | |
| for (let i = 0; i < 10; i++) { | |
| const angle = -Math.PI / 2 + i * Math.PI / 5; | |
| const radius = i % 2 === 0 ? r : r * 0.43; | |
| const x = Math.cos(angle) * radius; | |
| const y = Math.sin(angle) * radius; | |
| if (i === 0) targetCtx.moveTo(x, y); | |
| else targetCtx.lineTo(x, y); | |
| } | |
| targetCtx.closePath(); | |
| break; | |
| case 'diamond': | |
| targetCtx.moveTo(0, -r); | |
| targetCtx.lineTo(r, 0); | |
| targetCtx.lineTo(0, r); | |
| targetCtx.lineTo(-r, 0); | |
| targetCtx.closePath(); | |
| break; | |
| case 'cross': | |
| targetCtx.rect(-r * 0.28, -r, r * 0.56, size); | |
| targetCtx.rect(-r, -r * 0.28, size, r * 0.56); | |
| break; | |
| default: | |
| targetCtx.arc(0, 0, r, 0, Math.PI * 2); | |
| break; | |
| } | |
| targetCtx.fill(); | |
| } | |
| function drawTintedAsset(asset, color, size) { | |
| tintCtx.clearRect(0, 0, tintCanvas.width, tintCanvas.height); | |
| tintCtx.save(); | |
| tintCtx.translate(tintCanvas.width / 2, tintCanvas.height / 2); | |
| drawAssetCentered(tintCtx, asset, tintCanvas.width - 2); | |
| tintCtx.restore(); | |
| tintCtx.globalCompositeOperation = 'source-in'; | |
| tintCtx.fillStyle = color; | |
| tintCtx.fillRect(0, 0, tintCanvas.width, tintCanvas.height); | |
| tintCtx.globalCompositeOperation = 'source-over'; | |
| ctx.drawImage( | |
| tintCanvas, | |
| -size / 2, | |
| -size / 2, | |
| size, | |
| size | |
| ); | |
| } | |
| function renderFrame(forceOpaque = false) { | |
| if (!video.videoWidth || video.readyState < 2) return; | |
| resizeCanvasForVideo(); | |
| const width = canvas.width; | |
| const height = canvas.height; | |
| sampleCtx.drawImage(video, 0, 0, width, height); | |
| const pixels = sampleCtx.getImageData(0, 0, width, height).data; | |
| ctx.clearRect(0, 0, width, height); | |
| const isTransparent = $('transparent').checked && !forceOpaque; | |
| if (!isTransparent) { | |
| ctx.fillStyle = $('bgColor').value; | |
| ctx.fillRect(0, 0, width, height); | |
| } | |
| const cell = Number($('cellSize').value); | |
| const scaleSetting = Number($('scale').value); | |
| const gap = Number($('gap').value); | |
| const cutoff = Number($('cutoff').value) / 100; | |
| const contrast = Number($('contrast').value); | |
| const contrastFactor = (259 * (contrast + 255)) / (255 * (259 - contrast)); | |
| const columns = Math.ceil(width / cell); | |
| const rows = Math.ceil(height / cell); | |
| const sizeMode = $('sizeMode').value; | |
| const backgroundMode = $('backgroundMode').value; | |
| const colorMode = $('colorMode').value; | |
| const rotate = $('rotate').checked; | |
| const fallbackShape = $('fallbackShape').value; | |
| for (let row = 0; row < rows; row++) { | |
| const y = row * cell; | |
| for (let column = 0; column < columns; column++) { | |
| const x = column * cell; | |
| const sampleX = Math.min(width - 1, Math.floor(x + cell / 2)); | |
| const sampleY = Math.min(height - 1, Math.floor(y + cell / 2)); | |
| const index = (sampleY * width + sampleX) * 4; | |
| const alpha = pixels[index + 3] / 255; | |
| if (alpha < 0.04) continue; | |
| let red = adjustChannel(pixels[index], contrastFactor); | |
| let green = adjustChannel(pixels[index + 1], contrastFactor); | |
| let blue = adjustChannel(pixels[index + 2], contrastFactor); | |
| // Apply Color Grading | |
| [red, green, blue] = gradeColor(red, green, blue); | |
| const luma = (0.299 * red + 0.587 * green + 0.114 * blue) / 255; | |
| if (backgroundMode === 'dark' && luma < cutoff) continue; | |
| if (backgroundMode === 'light' && luma > 1 - cutoff) continue; | |
| const brightness = sizeMode === 'dark' ? 1 - luma : luma; | |
| const size = Math.min( | |
| cell * 1.8, | |
| Math.max(0, cell * scaleSetting * (0.12 + 0.88 * brightness) - gap) | |
| ); | |
| if (size < 1) continue; | |
| 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 = '⏸'; | |
| }); | |
| video.addEventListener('pause', () => { | |
| $('playPause').textContent = 'Play'; | |
| $('timelinePlay').textContent = '▶'; | |
| 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', | |
| 'brightness', 'saturation', 'tintColor', 'tintOpacity' | |
| ]; | |
| 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> |