reel recorder
1080 by 1920, straight off the canvas
back to manymoats

This records a scene directly from the canvas instead of through screen capture. Nothing gets scaled by the display compositor, nothing gets re-encoded twice, and you choose the bitrate. That is the whole reason the lasers band and the text goes soft when you use the macOS screen recorder. Pick a scene, add a track, press record.

starting up

This is the exact frame that gets written to the file.

Scene loading the list

Which manymoats scene fills the frame. Switching reloads it, which takes a second.

Picture quality

These are the levers that decide whether a laser looks like a laser or like a stack of stripes.

How much data each second of video gets. This is the single biggest lever against the banding you see on lasers and bright thin lines. Higher looks better and makes a bigger file. 16 is a good place to sit for Instagram.

Extra sharp renders the scene at 2160 by 3840 and shrinks it into the frame, which smooths the jagged stair steps on thin bright lines. It costs about four times the drawing work, so drop to Standard if the frame counter below starts sagging.

Length

Record until you press stop, or stop by itself after a set time.

Sound

Drop an audio file here, or use the button. The file plays out loud while it records and is written into the video at the same time, off the same clock, so the picture and the sound cannot drift apart. A browser cannot pick up your Mac's system audio without installing a driver, so this is the honest way to get a real track onto a reel.

No track loaded. You can still record, the video will simply be silent.

Record

Keep this tab in front while it records. If you switch tabs or apps, the browser stops drawing the scene and you get frozen frames.

0:00

If something looks wrong diagnostics

Recording format
checking
Scene drawing
waiting
Scene surface
waiting
' ].join(''); return head; } function teardownScene() { tickAlive = false; sceneReady = false; srcCanvas = null; if (frame) { try { if (frameWin && frameWin.MMSCENE && frameWin.MMSCENE.dispose) frameWin.MMSCENE.dispose(); } catch (e) {} try { frame.parentNode && frame.parentNode.removeChild(frame); } catch (e2) {} } frame = null; frameWin = null; } function fitFrame() { if (!frame) return; var s = stageEl.clientWidth / W; if (!isFinite(s) || s <= 0) s = 0.3; frame.style.transform = 'scale(' + s + ')'; } function paintHold(text) { recCtx.save(); recCtx.setTransform(1, 0, 0, 1, 0, 0); recCtx.globalCompositeOperation = 'source-over'; recCtx.globalAlpha = 1; recCtx.fillStyle = '#090D1A'; recCtx.fillRect(0, 0, W, H); if (text) { recCtx.fillStyle = '#8B93A7'; recCtx.font = '400 44px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; recCtx.textAlign = 'center'; recCtx.textBaseline = 'middle'; recCtx.fillText(text, W / 2, H / 2); } recCtx.restore(); } /* Find the scene's canvas. The contract says position:fixed at z-index -1, but some scenes swap surfaces at runtime (the splat scenes fall back to a 2D canvas if the GPU watchdog trips), so this re resolves rather than caching forever. */ function resolveSceneCanvas() { if (srcCanvas && srcCanvas.isConnected && srcCanvas.width > 0) return srcCanvas; if (!frameWin) return null; var doc; try { doc = frameWin.document; } catch (e) { return null; } if (!doc) return null; var list = doc.getElementsByTagName('canvas'); var best = null, bestArea = -1; for (var i = 0; i < list.length; i++) { var c = list[i]; if (!c.width || !c.height) continue; var area = c.width * c.height; var fixed = false; try { fixed = frameWin.getComputedStyle(c).position === 'fixed'; } catch (e2) {} // A fixed, full bleed canvas is the contract, so weight it above anything else. var score = area * (fixed ? 4 : 1); if (score > bestArea) { bestArea = score; best = c; } } srcCanvas = best; if (best) { dSurface.textContent = best.width + ' by ' + best.height + ' backing pixels, drawn down into 1080 by 1920'; } return best; } function drawFrame() { var src = resolveSceneCanvas(); if (!src || !src.width || !src.height) return false; try { recCtx.save(); recCtx.setTransform(1, 0, 0, 1, 0, 0); recCtx.globalCompositeOperation = 'source-over'; recCtx.globalAlpha = 1; recCtx.imageSmoothingEnabled = true; if (recCtx.imageSmoothingQuality) recCtx.imageSmoothingQuality = 'high'; recCtx.drawImage(src, 0, 0, src.width, src.height, 0, 0, W, H); recCtx.restore(); } catch (e) { return false; } if (ditherEl.checked) applyDither(); return true; } /* The copy callback, registered on the IFRAME's clock. See the header comment for why this must not live on the parent's clock. */ function tick(ts) { if (!tickAlive || !frameWin) return; try { frameWin.requestAnimationFrame(tick); } catch (e) { tickAlive = false; return; } var interval = 1000 / currentFps(); if (!nextDue) nextDue = ts; if (ts + 1 < nextDue) return; nextDue += interval; if (nextDue < ts) nextDue = ts + interval; var ok = drawFrame(); if (ok && recording && useRequestFrame && videoTrack) { try { videoTrack.requestFrame(); } catch (e2) {} } } function currentFps() { var v = parseInt(fpsEl.value, 10); return (v === 60) ? 60 : 30; } function mountScene(id) { var token = ++loadToken; teardownScene(); paintHold('loading ' + id); setStatus('loading the scene', ''); recBtn.disabled = true; dSurface.textContent = 'waiting'; dLive.textContent = 'waiting'; frame = document.createElement('iframe'); frame.className = 'sceneframe'; frame.setAttribute('title', 'scene render surface'); frame.setAttribute('aria-hidden', 'true'); frame.setAttribute('tabindex', '-1'); frame.setAttribute('scrolling', 'no'); var allowMotion = !motionRow.classList.contains('hidden') && forceMotion.checked; // srcdoc is set BEFORE the iframe enters the document. Appending an iframe // with nothing in it creates an initial about:blank document first, which // fires its own load event, and that would run the handler below twice. frame.srcdoc = buildDoc(id, ssEl.value === '1' ? 1 : 2, allowMotion); var settled = false; var pollId = 0; function fail(title, body, next) { if (settled || token !== loadToken) return; settled = true; setStatus('the scene did not load', ''); paintHold('scene did not load'); say(recMsg, 'bad', title, body, next); recBtn.disabled = true; } frame.addEventListener('load', function () { if (token !== loadToken || settled) return; if (pollId) { clearInterval(pollId); pollId = 0; } // belt and braces try { frameWin = frame.contentWindow; } catch (e) { frameWin = null; } if (!frameWin) { fail('The scene surface could not be reached.', 'The recorder needs to read pixels out of the frame it renders the scene in, and the browser blocked that.', 'Reload the page. If it keeps happening, open reel.html on the manymoats site rather than through a proxy or a different domain.'); return; } // Give the scene a beat to boot and get its own animation frame into the // queue. Registering our copy callback only AFTER that is what guarantees // it runs after the scene's each frame. var waited = 0; pollId = setInterval(function () { if (token !== loadToken) { clearInterval(pollId); pollId = 0; return; } waited += 120; var hasScene = false; try { hasScene = !!(frameWin && frameWin.MMSCENE); } catch (e) {} var c = resolveSceneCanvas(); if (hasScene && c) { clearInterval(pollId); pollId = 0; settled = true; clearMsg(recMsg); try { if (frameWin.MMSCENE.start) frameWin.MMSCENE.start(); } catch (e2) {} nextDue = 0; lastFrameCount = frameLiveness(); lastFrameStamp = performance.now(); // Guarded: the copy callback is registered exactly once per mount, or // two of them would fight over the pacing accumulator. if (!tickAlive) { tickAlive = true; try { frameWin.requestAnimationFrame(tick); } catch (e3) { tickAlive = false; } } sceneReady = true; recBtn.disabled = !!fatalEl.textContent; setStatus('ready', 'ok'); return; } if (waited >= 4000) { clearInterval(pollId); pollId = 0; fail('The scene "' + id + '" did not start.', hasScene ? 'The scene file loaded but never produced a canvas to read.' : 'The scene file at /api/scenes/' + id + '.js did not load or did not run.', 'Pick a different scene from the list. If every scene fails, the API is not reachable from this page.'); } }, 120); }); // Now it goes in, with srcdoc already set and the listener already attached. stageEl.insertBefore(frame, stageEl.firstChild); fitFrame(); } function frameLiveness() { try { return (frameWin && typeof frameWin.__mmFrames === 'number') ? frameWin.__mmFrames : -1; } catch (e) { return -1; } } // ========================================================================= // 3. the scene list. // ========================================================================= function fillScenes(list, live) { sceneSel.innerHTML = ''; for (var i = 0; i < list.length; i++) { var o = document.createElement('option'); o.value = list[i][0]; o.textContent = list[i][1] || list[i][0]; if (list[i][2]) o.setAttribute('data-d', list[i][2]); sceneSel.appendChild(o); } sceneSrc.textContent = live ? 'list loaded from the live gallery' : 'built in list, the live gallery did not answer'; var want = 'downpour'; for (var j = 0; j < list.length; j++) if (list[j][0] === want) { sceneSel.value = want; break; } if (!sceneSel.value && list.length) sceneSel.value = list[0][0]; showDesc(); } function showDesc() { var o = sceneSel.options[sceneSel.selectedIndex]; sceneDesc.textContent = (o && o.getAttribute('data-d')) || ''; } function loadScenes() { var done = false; function fallback() { if (done) return; done = true; fillScenes(FALLBACK_SCENES, false); mountScene(sceneSel.value); } if (typeof fetch !== 'function') { fallback(); return; } var bail = setTimeout(fallback, 6000); fetch('/api/scenes', { credentials: 'omit' }) .then(function (r) { return r.ok ? r.text() : Promise.reject(new Error('status ' + r.status)); }) .then(function (html) { clearTimeout(bail); if (done) return; // The gallery keeps its catalogue as an inline array of // {id:'x', t:'title', d:'description'}. Pull the ids and titles out of it. var out = []; var seen = {}; var re = /\{\s*id\s*:\s*'([^']+)'\s*,\s*t\s*:\s*'([^']*)'\s*,\s*d\s*:\s*'([^']*)'/g; var m; while ((m = re.exec(html)) !== null) { var id = m[1]; if (!SCENE_ID_RE.test(id) || seen[id]) continue; seen[id] = 1; out.push([id, m[2] || id, m[3] || '']); } if (out.length < 5) { fallback(); return; } done = true; out.sort(function (a, b) { return a[1] < b[1] ? -1 : (a[1] > b[1] ? 1 : 0); }); fillScenes(out, true); mountScene(sceneSel.value); }) .catch(function () { clearTimeout(bail); fallback(); }); } // ========================================================================= // 4. codec choice. Detected, never assumed. // Preference order: MP4 with H.264 first because Instagram takes it as is, // then WebM VP9 because it holds thin bright lines on near black far // better than VP8, then VP8, then whatever WebM the browser will give. // Within each family the version carrying an audio codec is tried first. // ========================================================================= function pickMime(withAudio) { var families = withAudio ? [ 'video/mp4;codecs="avc1.42E01E,mp4a.40.2"', 'video/mp4;codecs=avc1', 'video/mp4', 'video/webm;codecs="vp9,opus"', 'video/webm;codecs=vp9', 'video/webm;codecs="vp8,opus"', 'video/webm;codecs=vp8', 'video/webm' ] : [ 'video/mp4;codecs=avc1.42E01E', 'video/mp4;codecs=avc1', 'video/mp4', 'video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm' ]; for (var i = 0; i < families.length; i++) { try { if (window.MediaRecorder.isTypeSupported && window.MediaRecorder.isTypeSupported(families[i])) return families[i]; } catch (e) {} } return ''; } function isMp4(mime) { return mime.indexOf('video/mp4') === 0; } function extFor(mime) { return isMp4(mime) ? 'mp4' : 'webm'; } function describeMime(mime) { if (!mime) return 'none available'; if (isMp4(mime)) return 'MP4 with H.264, which Instagram takes as it is'; if (mime.indexOf('vp9') >= 0) return 'WebM with VP9, which Instagram will not take without converting first'; if (mime.indexOf('vp8') >= 0) return 'WebM with VP8, which Instagram will not take without converting first'; return 'WebM, which Instagram will not take without converting first'; } function refreshMimeReadout() { var m = pickMime(!!audioBuffer); dMime.textContent = m ? (m + ' (' + describeMime(m) + ')') : 'no format this browser can record'; } // ========================================================================= // 5. audio. // ========================================================================= function ensureAudioCtx() { if (audioCtx) return audioCtx; var AC = window.AudioContext || window.webkitAudioContext; if (!AC) return null; try { audioCtx = new AC(); } catch (e) { audioCtx = null; } return audioCtx; } function decode(arrayBuffer) { return new Promise(function (resolve, reject) { var ctx = ensureAudioCtx(); if (!ctx) { reject(new Error('no-audio-context')); return; } var maybe; try { maybe = ctx.decodeAudioData(arrayBuffer, resolve, reject); } catch (e) { reject(e); return; } if (maybe && typeof maybe.then === 'function') maybe.then(resolve, reject); }); } function loadAudio(file) { if (!file) return; clearMsg(audioMsg); audioState.textContent = 'reading ' + file.name; if (!ensureAudioCtx()) { say(audioMsg, 'bad', 'This browser has no Web Audio support.', 'Without it the track cannot be decoded or mixed into the recording.', 'Record silent for now, or use Chrome, Edge or Safari and try again.'); audioState.textContent = 'No track loaded. You can still record, the video will simply be silent.'; return; } file.arrayBuffer() .then(decode) .then(function (buf) { audioBuffer = buf; audioName = file.name; audioState.textContent = audioName + ', ' + mmss(buf.duration) + ' long, ' + buf.numberOfChannels + (buf.numberOfChannels === 1 ? ' channel' : ' channels'); audioClear.classList.remove('hidden'); refreshMimeReadout(); say(audioMsg, 'ok', 'Track ready.', 'It will play out loud and be written into the video at the same time.', 'If your recording runs longer than ' + mmss(buf.duration) + ', the rest of the video is silent.'); }) .catch(function (err) { audioBuffer = null; audioName = ''; audioClear.classList.add('hidden'); audioState.textContent = 'No track loaded. You can still record, the video will simply be silent.'; refreshMimeReadout(); if (err && err.message === 'no-audio-context') return; say(audioMsg, 'bad', 'That file could not be decoded.', 'The browser did not recognise the audio inside "' + file.name + '", or the file is damaged.', 'MP3, M4A and AAC, WAV, and FLAC all work. Export or convert to one of those and drop it in again.'); }); } // ========================================================================= // 6. recording. // ========================================================================= function lockControls(on) { var list = [sceneSel, ssEl, fpsEl, bitrateEl, ditherEl, audioBtn, audioClear, customLen]; for (var i = 0; i < list.length; i++) if (list[i]) list[i].disabled = on; var radios = lenChips.querySelectorAll('input[name=len]'); for (var j = 0; j < radios.length; j++) radios[j].disabled = on; if (forceMotion) forceMotion.disabled = on; } function lengthLimitSeconds() { var picked = lenChips.querySelector('input[name=len]:checked'); var v = picked ? picked.value : '0'; if (v === 'custom') { var n = parseInt(customLen.value, 10); if (!isFinite(n) || n < 1) n = 1; if (n > 600) n = 600; return n; } var num = parseInt(v, 10); return isFinite(num) ? num : 0; } function startRecording() { if (recording) return; clearMsg(recMsg); clearMsg(resultMsg); if (!sceneReady) { say(recMsg, 'bad', 'The scene is not running yet.', 'There is nothing to record until the scene has drawn its first frame.', 'Wait for the badge on the frame to say ready, or pick a different scene.'); return; } var withAudio = !!audioBuffer; chosenMime = pickMime(withAudio); if (!chosenMime) { say(recMsg, 'bad', 'This browser will not record any video format.', 'MediaRecorder is present but reported that it supports neither MP4 nor WebM here.', 'Use Chrome or Edge. Both record MP4 directly, which is what Instagram wants.'); return; } var fps = currentFps(); // Feed the recorder. requestFrame gives one recorded frame per drawn frame, // which is exact. Where it is missing, ask the canvas to sample at our rate // instead. Detected on the real track, not guessed from the browser name. var stream = null; useRequestFrame = false; try { stream = recCanvas.captureStream(0); var vt0 = stream.getVideoTracks()[0]; if (vt0 && typeof vt0.requestFrame === 'function') useRequestFrame = true; else { try { stream.getTracks().forEach(function (t) { t.stop(); }); } catch (e) {} stream = recCanvas.captureStream(fps); } } catch (e2) { try { stream = recCanvas.captureStream(fps); } catch (e3) { stream = null; } } if (!stream || !stream.getVideoTracks().length) { say(recMsg, 'bad', 'The canvas would not hand over a video stream.', 'captureStream returned nothing, so there are no frames to record.', 'Reload the page and try again. If it repeats, use Chrome.'); return; } videoTrack = stream.getVideoTracks()[0]; activeStream = stream; // Audio, if there is any. One AudioContext feeds the speakers and the // recorder from the same graph, so the two cannot drift. activeSource = null; activeDest = null; if (withAudio) { var ctx = ensureAudioCtx(); try { if (ctx.state === 'suspended') ctx.resume(); activeDest = ctx.createMediaStreamDestination(); activeSource = ctx.createBufferSource(); activeSource.buffer = audioBuffer; var gain = ctx.createGain(); gain.gain.value = 1; activeSource.connect(gain); gain.connect(ctx.destination); // so he hears it gain.connect(activeDest); // so it records var at = activeDest.stream.getAudioTracks()[0]; if (at) stream.addTrack(at); } catch (e4) { activeSource = null; activeDest = null; say(recMsg, 'warn', 'The track could not be wired into this recording.', 'The video will still record, it will just be silent.', 'Remove the track and add it again, then start a new recording.'); chosenMime = pickMime(false); } } var opts = { mimeType: chosenMime, videoBitsPerSecond: parseInt(bitrateEl.value, 10) * 1000000 }; if (stream.getAudioTracks().length) opts.audioBitsPerSecond = 192000; try { recorder = new window.MediaRecorder(stream, opts); } catch (e5) { try { recorder = new window.MediaRecorder(stream, { mimeType: chosenMime }); } catch (e6) { say(recMsg, 'bad', 'The recorder refused to start.', 'MediaRecorder would not accept ' + chosenMime + ' on this browser.', 'Try Chrome, or lower the video quality slider and start again.'); cleanupStream(); return; } } chunks = []; hiddenDuringRec = false; stalledDuringRec = false; recorder.ondataavailable = function (ev) { if (ev.data && ev.data.size) chunks.push(ev.data); }; recorder.onerror = function () { say(recMsg, 'bad', 'The recorder stopped with an error.', 'Whatever had been captured up to that point is still saved below if it is usable.', 'Lower the video quality slider, or switch the detail level to Standard, and record again.'); // Do not leave the page stuck in the recording state with a running timer. if (recording) stopRecording(); }; recorder.onstop = finishRecording; // Same synchronous block: recorder first so nothing at the head of the // track is lost, then the audio. Both ride one clock from here. try { recorder.start(); } catch (e7) { say(recMsg, 'bad', 'The recorder would not start.', 'MediaRecorder threw when told to begin.', 'Reload the page and try again.'); cleanupStream(); return; } if (activeSource) { try { activeSource.start(); } catch (e8) {} } recording = true; recStart = performance.now(); lastFrameCount = frameLiveness(); lastFrameStamp = recStart; recBtn.classList.add('hidden'); stopBtn.classList.remove('hidden'); stopBtn.focus(); setStatus('recording', 'live'); // Locked while it runs. Bitrate is fixed the moment the recorder is built, // so leaving the slider live would let the summary report a number the file // does not actually have. lockControls(true); var limit = lengthLimitSeconds(); tickTimer = setInterval(function () { var el = (performance.now() - recStart) / 1000; timerEl.textContent = mmss(el) + (limit ? ' of ' + mmss(limit) : ''); watchLiveness(); if (limit && el >= limit) stopRecording(); }, 100); } function watchLiveness() { var now = performance.now(); var n = frameLiveness(); if (n < 0) return; if (n !== lastFrameCount) { var dt = (now - lastFrameStamp) / 1000; if (dt > 0.4) { measuredFps = (n - lastFrameCount) / dt; dLive.textContent = measuredFps.toFixed(0) + ' frames a second inside the scene'; lastFrameCount = n; lastFrameStamp = now; } } else if (now - lastFrameStamp > 1500) { dLive.textContent = 'the scene has stopped drawing'; if (recording) stalledDuringRec = true; } } function stopRecording() { if (!recording) return; recording = false; clearInterval(tickTimer); tickTimer = 0; stopBtn.classList.add('hidden'); recBtn.classList.remove('hidden'); setStatus('ready', 'ok'); lockControls(false); if (activeSource) { try { activeSource.stop(); } catch (e) {} } try { recorder.stop(); } catch (e2) { finishRecording(); } } function cleanupStream() { try { if (activeStream) activeStream.getTracks().forEach(function (t) { t.stop(); }); } catch (e) {} activeStream = null; videoTrack = null; try { if (activeSource) activeSource.disconnect(); } catch (e2) {} try { if (activeDest) activeDest.disconnect(); } catch (e3) {} activeSource = null; activeDest = null; } function finishRecording() { var seconds = Math.max(0, (performance.now() - recStart) / 1000); cleanupStream(); var blob = null; try { blob = new Blob(chunks, { type: isMp4(chosenMime) ? 'video/mp4' : 'video/webm' }); } catch (e) { blob = null; } chunks = []; resultCard.classList.remove('hidden'); if (!blob || blob.size === 0) { resultKv.innerHTML = ''; dlLink.classList.add('hidden'); preview.classList.add('hidden'); say(resultMsg, 'bad', 'The recording came back empty.', 'The recorder produced a zero byte file, so there is nothing to download and offering one would just waste your time.', 'This usually means the tab lost focus or the scene stopped drawing. Keep this tab in front, check the scene drawing line in the diagnostics below, then record again.'); return; } dlLink.classList.remove('hidden'); preview.classList.remove('hidden'); var ext = extFor(chosenMime); var name = 'reel-' + sceneSel.value + '-1080x1920-' + Math.round(seconds) + 's.' + ext; if (lastUrl) { try { URL.revokeObjectURL(lastUrl); } catch (e2) {} } lastUrl = URL.createObjectURL(blob); dlLink.href = lastUrl; dlLink.setAttribute('download', name); dlLink.innerHTML = 'Download ' + esc(name) + ''; preview.src = lastUrl; resultKv.innerHTML = '
File
' + esc(name) + '
' + '
Size
' + niceBytes(blob.size) + '
' + '
Length
' + seconds.toFixed(1) + ' seconds
' + '
Frame
1080 by 1920, ' + currentFps() + ' frames a second
' + '
Quality
' + bitrateEl.value + ' Mbps requested' + (ditherEl.checked ? ', film grain on' : ', film grain off') + '
' + '
Sound
' + (audioName ? esc(audioName) : 'silent, no track was loaded') + '
' + '
Format
' + esc(chosenMime) + '
'; var notes = ''; if (!isMp4(chosenMime)) { notes += '
This is a WebM file, and Instagram wants MP4.' + 'Your browser told us it cannot record MP4, so the file above will need converting before you upload it. ' + 'The file itself is fine, it is only the container and codec Instagram will not take.' + 'Either record again in Chrome or Edge, which write MP4 directly, or convert it:' + '
ffmpeg -i ' + esc('reel.' + extFor(chosenMime)) +
        ' -c:v libx264 -crf 16 -preset slow -pix_fmt yuv420p -c:a aac -b:a 192k reel.mp4
'; } if (hiddenDuringRec) { notes += '
This tab went to the background while it was recording.' + 'Browsers stop drawing pages that are not in front, so any stretch where that happened is frozen frames.' + 'Record it again with this tab in front and nothing covering it.
'; } if (stalledDuringRec && !hiddenDuringRec) { notes += '
The scene stopped drawing part way through.' + 'Some of the recording is likely to be frozen.' + 'Switch the detail level to Standard, or pick a lighter scene, and record again.
'; } if (!notes) { notes = '
Recorded clean.Check it in the preview below before you upload.
'; } resultMsg.innerHTML = notes; } preview.addEventListener('error', function () { if (!lastUrl) return; say(resultMsg, 'warn', 'This browser cannot play the file back here.', 'That is a playback limitation, not a problem with the file. The download above is still good.', 'Download it and open it in QuickTime or VLC to check it.'); }); // ========================================================================= // 7. wiring. // ========================================================================= bitrateEl.addEventListener('input', function () { bitrateOut.textContent = bitrateEl.value + ' Mbps'; }); fpsEl.addEventListener('change', function () { nextDue = 0; }); ssEl.addEventListener('change', function () { mountScene(sceneSel.value); }); sceneSel.addEventListener('change', function () { showDesc(); mountScene(sceneSel.value); }); forceMotion.addEventListener('change', function () { mountScene(sceneSel.value); }); rawToggle.addEventListener('change', function () { stageEl.classList.toggle('raw', rawToggle.checked); }); lenChips.addEventListener('change', function () { var chips = lenChips.querySelectorAll('.chip'); for (var i = 0; i < chips.length; i++) { var inp = chips[i].querySelector('input'); chips[i].classList.toggle('on', !!(inp && inp.checked)); } var picked = lenChips.querySelector('input[name=len]:checked'); customWrap.classList.toggle('hidden', !(picked && picked.value === 'custom')); }); // A real button, so Enter and Space are handled natively. Nothing wraps the // input in a label, so the picker can never be opened twice by one click. audioBtn.addEventListener('click', function () { audioFile.click(); }); audioFile.addEventListener('change', function () { if (audioFile.files && audioFile.files[0]) loadAudio(audioFile.files[0]); }); audioClear.addEventListener('click', function () { audioBuffer = null; audioName = ''; audioFile.value = ''; audioClear.classList.add('hidden'); audioState.textContent = 'No track loaded. You can still record, the video will simply be silent.'; clearMsg(audioMsg); refreshMimeReadout(); }); // Drag and drop anywhere on the page. Swallowing the document level dragover // matters: without it a missed drop makes the browser navigate away from the // page and the session is gone. var dragDepth = 0; document.addEventListener('dragenter', function (ev) { ev.preventDefault(); dragDepth++; dropEl.classList.add('over'); }); document.addEventListener('dragover', function (ev) { ev.preventDefault(); }); document.addEventListener('dragleave', function () { dragDepth = Math.max(0, dragDepth - 1); if (!dragDepth) dropEl.classList.remove('over'); }); document.addEventListener('drop', function (ev) { ev.preventDefault(); dragDepth = 0; dropEl.classList.remove('over'); var f = ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files[0]; if (!f) return; if (f.type && f.type.indexOf('audio') !== 0 && !/\.(mp3|m4a|wav|aac|flac|ogg|opus|aiff?)$/i.test(f.name)) { say(audioMsg, 'warn', 'That does not look like an audio file.', '"' + f.name + '" was dropped, and the recorder only takes sound here.', 'Drop an MP3, M4A, WAV or FLAC instead.'); return; } loadAudio(f); }); recBtn.addEventListener('click', startRecording); stopBtn.addEventListener('click', stopRecording); document.addEventListener('visibilitychange', function () { if (document.hidden && recording) hiddenDuringRec = true; }); window.addEventListener('resize', fitFrame); if (typeof ResizeObserver === 'function') { try { new ResizeObserver(fitFrame).observe(stageEl); } catch (e) {} } window.addEventListener('beforeunload', function () { if (lastUrl) { try { URL.revokeObjectURL(lastUrl); } catch (e) {} } }); // Idle liveness readout, so the diagnostics are honest before you press record. setInterval(function () { if (!recording) watchLiveness(); }, 500); // ========================================================================= // 8. boot. // ========================================================================= buildNoise(); paintHold(''); refreshMimeReadout(); bitrateOut.textContent = bitrateEl.value + ' Mbps'; try { if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { motionRow.classList.remove('hidden'); } } catch (e) {} if (!fatalEl.classList.contains('hidden')) { setStatus('cannot record here', ''); } else { loadScenes(); } })();