/** * The Record-motion panel — a webcam recording (or a picked video) becomes an animation clip via * Uthana video-to-motion — composed into `shell-page.ts` the way `recording-panel.ts` is. * * Unlike the other panels this one also exports its BEHAVIOUR as a string: the recorder logic is * a screenful, and it has to sit inside the shell's IIFE (it needs `setStatus`, `start`, * `autoReload`, `banner`, `frame`). The same logic exists as a typed module for the Creator — * `creator/src/utils/videoRecorder.ts` is the source of truth; this is a hand-kept ES5 port, * because the shell has no bundler and cannot import it. * * Same template-literal rules as the host: **no backticks**, no regex literals starting with a * dot-escape, every colour a `--bm-*` token — `shell-page.test.ts` enforces all three. */ export declare const MOTION_PANEL_CSS = "\n /* Level with the other dialogs: each opens from its own control and they never stack. */\n #motion-overlay { z-index: 10; }\n #motion-dialog { width: min(520px, 94vw); padding: 20px; }\n #motion-dialog .bm-label { display: block; margin: 14px 0 6px; }\n #motion-stage { position: relative; aspect-ratio: 16 / 9; border-radius: 8px; overflow: hidden;\n background: var(--bm-surface-alt); border: 1px solid var(--bm-border-muted); }\n #motion-preview { width: 100%; height: 100%; object-fit: contain; background: #000000; display: block; }\n #motion-preview.mirror { transform: scaleX(-1); }\n #motion-timer { position: absolute; top: 10px; left: 10px; display: none; padding: 3px 10px;\n border-radius: 999px; background: var(--bm-pink); color: #ffffff; font-size: 12px;\n font-family: var(--bm-font-mono); }\n #motion-timer.show { display: inline-block; }\n #motion-countdown { position: absolute; inset: 0; display: none; flex-direction: column; align-items: center;\n justify-content: center; gap: 6px; background: var(--bm-scrim); pointer-events: none; }\n #motion-countdown.show { display: flex; }\n #motion-countdown-number { font-size: 96px; font-weight: 700; line-height: 1; color: #ffffff; }\n #motion-countdown-hint { font-size: 13px; color: #ffffff; opacity: 0.85; }\n #motion-hint { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;\n text-align: center; padding: 20px; font-size: 13px; color: var(--bm-text-muted); }\n #motion-hint.hidden { display: none; }\n #motion-trim { display: none; margin-top: 10px; }\n #motion-trim.show { display: block; }\n #motion-trimbar { position: relative; height: 14px; border-radius: 7px; background: var(--bm-surface-alt);\n touch-action: none; }\n #motion-trim-kept { position: absolute; top: 0; bottom: 0; border-radius: 7px; background: var(--bm-aqua); }\n #motion-trim-playhead { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--bm-pink);\n pointer-events: none; }\n .motion-trim-handle { position: absolute; top: 50%; width: 14px; height: 22px;\n transform: translate(-50%, -50%); border-radius: 5px; background: var(--bm-text);\n cursor: ew-resize; touch-action: none; }\n #motion-trim-row { display: flex; align-items: center; justify-content: space-between; gap: 8px;\n margin-top: 6px; min-height: 24px; }\n #motion-trim-note { font-size: 11px; color: var(--bm-text-muted); }\n #motion-trim-actions { display: flex; gap: 6px; }\n #motion-sources { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }\n #motion-file { display: none; }\n #motion-guidance { margin: 10px 0 0; font-size: 12px; color: var(--bm-text-muted); }\n #motion-options { display: grid; grid-template-columns: 1fr auto; gap: 10px 12px; align-items: end; }\n #motion-name, #motion-slot { width: 100%; }\n .motion-check { display: flex; align-items: center; gap: 8px; margin-top: 12px; font-size: 13px; cursor: pointer; }\n .motion-check input { accent-color: var(--bm-aqua); }\n #motion-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }\n #motion-note { display: none; margin-top: 12px; padding: 12px; border-radius: 6px; font-size: 12px;\n background: var(--bm-surface-alt); border: 1px solid var(--bm-border-muted);\n color: var(--bm-text-muted); }\n #motion-note.show { display: block; }\n #motion-note code { display: block; margin-top: 6px; color: var(--bm-aqua);\n font-family: var(--bm-font-mono); word-break: break-all; }\n"; export declare const MOTION_PANEL_HTML = "
\n"; /** * Behaviour, spliced into the shell's IIFE. Runs after the shell's own helpers are defined, so * `setStatus`, `start`, `autoReload`, `banner` and `frame` are in scope. */ export declare const MOTION_PANEL_JS = "\n // ---- Record motion: webcam or file -> Uthana video-to-motion -> assets[] ----------------------\n var motionToggle = document.getElementById('motion');\n var motionOverlay = document.getElementById('motion-overlay');\n var motionPreview = document.getElementById('motion-preview');\n var motionTimer = document.getElementById('motion-timer');\n var motionCountdown = document.getElementById('motion-countdown');\n var motionCountdownNumber = document.getElementById('motion-countdown-number');\n /** Seconds shown on the live preview before the recorder starts, so the first frames are the pose. */\n var MOTION_COUNTDOWN_S = 3;\n var motionCountdownToken = 0;\n var motionHint = document.getElementById('motion-hint');\n var motionRecordBtn = document.getElementById('motion-record');\n var motionStartBtn = document.getElementById('motion-start');\n var motionStopBtn = document.getElementById('motion-stop');\n var motionPickBtn = document.getElementById('motion-pick');\n var motionRetakeBtn = document.getElementById('motion-retake');\n var motionFile = document.getElementById('motion-file');\n var motionGuidance = document.getElementById('motion-guidance');\n var motionName = document.getElementById('motion-name');\n var motionSlot = document.getElementById('motion-slot');\n var motionLoop = document.getElementById('motion-loop');\n var motionSubmit = document.getElementById('motion-submit');\n var motionNote = document.getElementById('motion-note');\n var MOTION_MIN_S = 2;\n var MOTION_MAX_S = 60;\n var MOTION_MAX_BYTES = 200 * 1024 * 1024;\n var motionBlob = null;\n // Whether motionBlob came from the webcam recorder (auto-trim applies) or a picked file (it does not).\n var motionFromRecording = false;\n var motionSeconds = 0;\n var motionStream = null;\n // True while the camera is open for framing, before recording starts.\n var motionFraming = false;\n var motionRecorder = null;\n var motionChunks = [];\n var motionTickTimer = null;\n var motionStartedAt = 0;\n var motionObjectUrl = null;\n // ---- Trim window: seeded by the sidecar's trim analysis, adjusted on the handles ------------\n var motionTrimWrap = document.getElementById('motion-trim');\n var motionTrimBar = document.getElementById('motion-trimbar');\n var motionTrimKept = document.getElementById('motion-trim-kept');\n var motionTrimPlayhead = document.getElementById('motion-trim-playhead');\n var motionTrimStartHandle = document.getElementById('motion-trim-start');\n var motionTrimEndHandle = document.getElementById('motion-trim-end');\n var motionTrimNote = document.getElementById('motion-trim-note');\n var motionTrimFullBtn = document.getElementById('motion-trim-full');\n var motionTrimPreviewBtn = document.getElementById('motion-trim-preview');\n // True while \"Preview the kept range\" is playing trimStart -> trimEnd.\n var motionPreviewingTrim = false;\n var MOTION_TRIM_MIN_KEPT_S = 2;\n var motionTrimDuration = 0;\n var motionTrimStart = 0;\n var motionTrimEnd = 0;\n var motionPendingId = null;\n var motionAnalysisState = 'idle';\n var motionAnalysisToken = 0;\n var motionAnalyzePollTimer = null;\n\n function motionHasTrim() {\n return motionTrimDuration > 0 && motionTrimEnd > motionTrimStart\n && (motionTrimStart >= 0.1 || motionTrimDuration - motionTrimEnd >= 0.1);\n }\n\n function renderMotionTrim() {\n if (motionTrimDuration <= 0) { motionTrimWrap.classList.remove('show'); return; }\n motionTrimWrap.classList.add('show');\n var pct = function (seconds) {\n return Math.min(Math.max(seconds / motionTrimDuration, 0), 1) * 100 + '%';\n };\n motionTrimKept.style.left = pct(motionTrimStart);\n motionTrimKept.style.width = Math.max(0, (motionTrimEnd - motionTrimStart) / motionTrimDuration) * 100 + '%';\n motionTrimStartHandle.style.left = pct(motionTrimStart);\n motionTrimEndHandle.style.left = pct(motionTrimEnd);\n motionTrimFullBtn.hidden = !motionHasTrim();\n motionTrimPreviewBtn.textContent = motionPreviewingTrim\n ? '\u23F9 Stop'\n : (motionHasTrim() ? '\u25B6 Preview the kept range' : '\u25B6 Play');\n var note;\n if (motionAnalysisState === 'running') {\n note = 'Analyzing the clip for a good trim\u2026';\n } else if (!motionHasTrim()) {\n note = 'Using the full clip.';\n } else {\n var parts = [];\n if (motionTrimStart >= 0.1) parts.push(motionTrimStart.toFixed(1) + 's from the start');\n if (motionTrimDuration - motionTrimEnd >= 0.1) parts.push((motionTrimDuration - motionTrimEnd).toFixed(1) + 's from the end');\n note = 'Trimming ' + parts.join(' and ') + '.';\n }\n motionTrimNote.textContent = note;\n }\n\n function resetMotionTrim() {\n motionPreviewingTrim = false;\n motionAnalysisToken++;\n if (motionAnalyzePollTimer) { clearTimeout(motionAnalyzePollTimer); motionAnalyzePollTimer = null; }\n motionPendingId = null;\n motionAnalysisState = 'idle';\n motionTrimDuration = 0;\n motionTrimStart = 0;\n motionTrimEnd = 0;\n motionTrimWrap.classList.remove('show');\n }\n\n function seedMotionTrim(durationSeconds) {\n if (!(durationSeconds > 0)) return;\n motionTrimDuration = durationSeconds;\n motionTrimStart = 0;\n motionTrimEnd = durationSeconds;\n // Review playback is the trim: the Play/Preview button below drives the\n // video, and a native full-clip scrubber under a trim bar read as \"it\n // plays the whole video anyway\". Controls stay native only while the\n // duration is unknown and the bar cannot render.\n motionPreview.controls = false;\n renderMotionTrim();\n }\n\n function applyMotionSuggestion(suggestion) {\n if (!suggestion) { renderMotionTrim(); return; }\n if (suggestion.durationSeconds > 0) {\n motionTrimDuration = suggestion.durationSeconds;\n motionTrimStart = 0;\n motionTrimEnd = suggestion.durationSeconds;\n }\n if (suggestion.confidence === 'high'\n && typeof suggestion.suggestedStartSeconds === 'number'\n && typeof suggestion.suggestedEndSeconds === 'number'\n && suggestion.suggestedEndSeconds > suggestion.suggestedStartSeconds) {\n motionTrimStart = suggestion.suggestedStartSeconds;\n motionTrimEnd = Math.min(suggestion.suggestedEndSeconds, motionTrimDuration || suggestion.suggestedEndSeconds);\n }\n renderMotionTrim();\n }\n\n function beginMotionTrimDrag(which, event) {\n if (motionTrimDuration <= 0) return;\n var handle = event.currentTarget;\n handle.setPointerCapture(event.pointerId);\n var move = function (e) {\n var rect = motionTrimBar.getBoundingClientRect();\n if (rect.width === 0) return;\n var at = Math.min(Math.max((e.clientX - rect.left) / rect.width, 0), 1) * motionTrimDuration;\n if (which === 'start') motionTrimStart = Math.min(at, motionTrimEnd - MOTION_TRIM_MIN_KEPT_S);\n else motionTrimEnd = Math.max(at, motionTrimStart + MOTION_TRIM_MIN_KEPT_S);\n // Show the exact frame the cut lands on while the handle moves.\n motionPreview.pause();\n motionPreviewingTrim = false;\n motionPreview.currentTime = which === 'start' ? motionTrimStart : motionTrimEnd;\n renderMotionTrim();\n };\n var up = function () {\n handle.removeEventListener('pointermove', move);\n handle.removeEventListener('pointerup', up);\n handle.removeEventListener('pointercancel', up);\n };\n handle.addEventListener('pointermove', move);\n handle.addEventListener('pointerup', up);\n handle.addEventListener('pointercancel', up);\n event.preventDefault();\n }\n\n function pollMotionAnalysis(token) {\n var tick = async function () {\n if (token !== motionAnalysisToken) return;\n var payload;\n try {\n payload = await (await fetch('/api/editor/motion-analyze-status')).json();\n } catch (error) {\n motionAnalyzePollTimer = setTimeout(tick, 2000);\n return;\n }\n if (token !== motionAnalysisToken) return;\n if (payload && payload.status === 'done') {\n motionAnalysisState = 'done';\n applyMotionSuggestion(payload.suggestion);\n return;\n }\n if (payload && payload.status === 'running') {\n motionAnalyzePollTimer = setTimeout(tick, 2000);\n return;\n }\n // failed / unavailable / none: no suggestion, the full-range handles stand.\n motionAnalysisState = 'failed';\n renderMotionTrim();\n };\n motionAnalyzePollTimer = setTimeout(tick, 2000);\n }\n\n /**\n * Hand the clip to the sidecar ahead of submission: it uploads once and runs the unbilled trim\n * analysis while the creator reviews. Degraded, never blocking \u2014 any failure just leaves the\n * handles at full range, and Submit works throughout.\n */\n async function startMotionAnalysis(blob) {\n var token = ++motionAnalysisToken;\n motionPendingId = null;\n motionAnalysisState = 'running';\n renderMotionTrim();\n var result = null;\n try {\n var response = await fetch('/api/editor/motion-analyze', {\n method: 'POST',\n headers: { 'Content-Type': blob.type || 'video/webm' },\n body: blob\n });\n result = await response.json();\n } catch (error) { /* the sidecar said nothing; treat as unavailable */ }\n if (token !== motionAnalysisToken) return;\n if (!result || result.ok !== true || result.analysing !== true) {\n motionAnalysisState = 'idle';\n renderMotionTrim();\n return;\n }\n motionPendingId = result.pendingId || null;\n pollMotionAnalysis(token);\n }\n\n function motionSupported() {\n return !!(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia\n && typeof MediaRecorder !== 'undefined');\n }\n\n /** mp4 first (Safari), then the WebM the rest produce; asset-forger re-encodes either. */\n function pickMotionMime() {\n var candidates = ['video/mp4', 'video/webm;codecs=vp9', 'video/webm'];\n for (var i = 0; i < candidates.length; i++) {\n if (MediaRecorder.isTypeSupported(candidates[i])) return candidates[i];\n }\n return '';\n }\n\n function motionCameraError(error) {\n var name = error && error.name;\n if (name === 'NotAllowedError' || name === 'SecurityError') return 'Camera access was refused. Allow it in the address bar, or choose a video file.';\n if (name === 'NotFoundError' || name === 'OverconstrainedError') return 'No camera found. Choose a video file instead.';\n if (name === 'NotReadableError') return 'The camera is in use by another app. Close it, or choose a video file.';\n return 'Could not start the camera: ' + ((error && error.message) || 'unknown error');\n }\n\n function formatMotionClock(seconds) {\n var s = Math.max(0, Math.floor(seconds));\n var rest = s % 60;\n return Math.floor(s / 60) + ':' + (rest < 10 ? '0' : '') + rest;\n }\n\n function showMotionNote(text, command) {\n document.getElementById('motion-note-text').textContent = text;\n document.getElementById('motion-command').textContent = command || '';\n motionNote.classList.add('show');\n }\n\n function releaseMotionObjectUrl() {\n if (motionObjectUrl) { URL.revokeObjectURL(motionObjectUrl); motionObjectUrl = null; }\n }\n\n /** Stop the recorder if one is still live \u2014 it may already have stopped itself. */\n function discardMotionRecorder() {\n if (motionRecorder && motionRecorder.state !== 'inactive') {\n try { motionRecorder.stop(); } catch (error) { /* already stopped */ }\n }\n }\n\n function stopMotionStream() {\n if (motionStream) {\n motionStream.getTracks().forEach(function (track) { track.stop(); });\n motionStream = null;\n }\n if (motionTickTimer) { clearInterval(motionTickTimer); motionTickTimer = null; }\n motionCountdownToken++;\n motionCountdown.classList.remove('show');\n motionTimer.classList.remove('show');\n motionPreview.classList.remove('mirror');\n }\n\n function setMotionButtons(state) {\n motionRecordBtn.hidden = state !== 'idle';\n motionPickBtn.hidden = state !== 'idle';\n motionStartBtn.hidden = state !== 'framing';\n motionStopBtn.hidden = state !== 'recording' && state !== 'framing';\n motionStopBtn.textContent = state === 'framing' ? 'Cancel' : 'Stop';\n motionRetakeBtn.hidden = state !== 'ready';\n motionSubmit.disabled = state !== 'ready';\n }\n\n function resetMotionPanel() {\n motionFraming = false;\n resetMotionTrim();\n stopMotionStream();\n discardMotionRecorder();\n motionRecorder = null;\n motionChunks = [];\n motionBlob = null;\n motionFromRecording = false;\n motionSeconds = 0;\n releaseMotionObjectUrl();\n motionPreview.srcObject = null;\n motionPreview.removeAttribute('src');\n motionPreview.controls = false;\n motionPreview.muted = true;\n motionHint.classList.remove('hidden');\n var supported = motionSupported();\n motionGuidance.textContent = supported ? '' : 'Webcam recording needs a secure page with a camera; choose a video file instead.';\n motionRecordBtn.disabled = !supported;\n motionNote.classList.remove('show');\n setMotionButtons('idle');\n }\n\n function openMotionPanel() {\n resetMotionPanel();\n motionOverlay.classList.add('show');\n motionName.focus();\n }\n\n /** Returns whether it was open \u2014 the Escape handler's contract, like closeAssetDetail. */\n function closeMotionPanel() {\n if (!motionOverlay.classList.contains('show')) return false;\n stopMotionStream();\n discardMotionRecorder();\n motionOverlay.classList.remove('show');\n frame.focus();\n return true;\n }\n\n /** The clip is in hand: show it back, and say whether it is within Uthana's 2-60 s window. */\n function acceptMotionClip(blob, seconds) {\n motionBlob = blob;\n motionSeconds = seconds;\n releaseMotionObjectUrl();\n motionObjectUrl = URL.createObjectURL(blob);\n motionPreview.srcObject = null;\n motionPreview.src = motionObjectUrl;\n motionPreview.controls = true;\n motionPreview.muted = true;\n motionHint.classList.add('hidden');\n var note;\n if (blob.size > MOTION_MAX_BYTES) note = 'That video is over 200 MB. Trim it to the seconds that matter.';\n else if (seconds > 0 && seconds < MOTION_MIN_S) note = 'Too short: Uthana needs at least ' + MOTION_MIN_S + ' seconds.';\n else if (seconds > MOTION_MAX_S) note = 'Too long: Uthana takes at most ' + MOTION_MAX_S + ' seconds. Trim it, or record again.';\n var ok = !note;\n motionGuidance.textContent = ok\n ? (seconds > 0 ? formatMotionClock(seconds) + ' of video ready.' : 'Video ready.')\n : note;\n setMotionButtons('ready');\n motionSubmit.disabled = !ok;\n resetMotionTrim();\n if (ok) {\n seedMotionTrim(seconds);\n // A fresh WebM often reports no duration; the preview's metadata is the fallback scale.\n motionPreview.onloadedmetadata = function () {\n if (motionTrimDuration === 0 && isFinite(motionPreview.duration) && motionPreview.duration > 0) {\n seedMotionTrim(motionPreview.duration);\n }\n };\n void startMotionAnalysis(blob);\n }\n }\n\n /**\n * Open the camera without recording \u2014 the framing step: the creator sees\n * themselves and gets their whole body in frame for as long as they need.\n * Start then runs the countdown and records.\n */\n async function openMotionCamera() {\n if (!motionSupported()) return;\n motionNote.classList.remove('show');\n try {\n motionStream = await navigator.mediaDevices.getUserMedia({\n video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' }, audio: false\n });\n } catch (error) {\n motionGuidance.textContent = motionCameraError(error);\n return;\n }\n motionFraming = true;\n releaseMotionObjectUrl();\n motionPreview.removeAttribute('src');\n motionPreview.controls = false;\n motionPreview.srcObject = motionStream;\n motionPreview.classList.add('mirror');\n motionHint.classList.add('hidden');\n try { await motionPreview.play(); } catch (error) { /* autoplay refused; the stream still shows on interaction */ }\n motionGuidance.textContent = 'Get your whole body in frame, then press Start recording.';\n setMotionButtons('framing');\n }\n\n async function startMotionRecording() {\n if (!motionStream || !motionFraming) return;\n motionFraming = false;\n var mime = pickMotionMime();\n try {\n motionRecorder = mime ? new MediaRecorder(motionStream, { mimeType: mime }) : new MediaRecorder(motionStream);\n } catch (error) {\n stopMotionStream();\n motionGuidance.textContent = 'This browser cannot record video. Choose a video file instead.';\n return;\n }\n // Count down on the live preview; Stop during the count aborts without recording.\n var token = ++motionCountdownToken;\n setMotionButtons('recording');\n motionGuidance.textContent = 'Get in frame \u2014 recording starts after the count.';\n motionCountdown.classList.add('show');\n for (var left = MOTION_COUNTDOWN_S; left > 0; left--) {\n motionCountdownNumber.textContent = String(left);\n await new Promise(function (resolve) { setTimeout(resolve, 1000); });\n if (token !== motionCountdownToken) return;\n }\n motionCountdown.classList.remove('show');\n motionChunks = [];\n motionRecorder.ondataavailable = function (event) { if (event.data && event.data.size > 0) motionChunks.push(event.data); };\n motionRecorder.onstop = function () {\n var seconds = (Date.now() - motionStartedAt) / 1000;\n var type = motionRecorder.mimeType || mime || 'video/webm';\n stopMotionStream();\n motionFromRecording = true;\n acceptMotionClip(new Blob(motionChunks, { type: type }), seconds);\n };\n motionStartedAt = Date.now();\n motionRecorder.start(1000);\n motionTimer.textContent = '0:00';\n motionTimer.classList.add('show');\n motionGuidance.textContent = 'Recording. Stop when the move is done (auto-stops at ' + MOTION_MAX_S + ' s).';\n motionTickTimer = setInterval(function () {\n var elapsed = (Date.now() - motionStartedAt) / 1000;\n motionTimer.textContent = formatMotionClock(elapsed);\n if (elapsed >= MOTION_MAX_S) stopMotionRecording();\n }, 250);\n }\n\n function stopMotionRecording() {\n if (motionRecorder && motionRecorder.state === 'recording') { motionRecorder.stop(); return; }\n // Cancel during framing, or Stop during the countdown: nothing was\n // recorded, back to the start.\n if (motionFraming || motionCountdown.classList.contains('show')) resetMotionPanel();\n }\n\n function pickMotionFile(file) {\n if (!file) return;\n // Read the duration off the file itself; a picked video knows its length, a fresh WebM often\n // does not (Infinity), so 0 means \"unknown\" and the server's ffprobe is the judge.\n var probe = document.createElement('video');\n probe.preload = 'metadata';\n var url = URL.createObjectURL(file);\n var settled = false;\n var finish = function (seconds) {\n if (settled) return;\n settled = true;\n URL.revokeObjectURL(url);\n motionFromRecording = false;\n acceptMotionClip(file, seconds);\n };\n probe.onloadedmetadata = function () { finish(isFinite(probe.duration) ? probe.duration : 0); };\n probe.onerror = function () { finish(0); };\n probe.src = url;\n setTimeout(function () { finish(0); }, 3000);\n }\n\n var motionPollTimer = null;\n function pollMotionJob() {\n if (motionPollTimer) clearTimeout(motionPollTimer);\n var tick = async function () {\n var job;\n try {\n job = (await (await fetch('/api/editor/motion-status')).json()).job;\n } catch (error) {\n motionPollTimer = setTimeout(tick, 3000);\n return;\n }\n if (!job) return;\n if (job.status === 'running') {\n showMotionNote(job.message, '');\n setStatus('saving', 'Making the animation\u2026');\n motionPollTimer = setTimeout(tick, 3000);\n return;\n }\n if (job.status === 'failed') {\n showMotionNote('Could not make the animation: ' + job.message, '');\n setStatus('error', 'Animation failed');\n setMotionButtons('ready');\n return;\n }\n showMotionNote(job.message + ' Reloading to show it.', '');\n setStatus('saved', 'Animation added');\n setMotionButtons('ready');\n if (autoReload && !voxelSessionActive && !voxelSessionPending) start();\n else banner.classList.add('show');\n };\n void tick();\n }\n\n async function submitMotion() {\n if (!motionBlob) return;\n var name = motionName.value.trim();\n if (name === '') { motionName.focus(); return; }\n // Whatever the handles show is what gets made; a still-running analysis must not move them.\n motionAnalysisToken++;\n if (motionAnalyzePollTimer) { clearTimeout(motionAnalyzePollTimer); motionAnalyzePollTimer = null; }\n var query = '?name=' + encodeURIComponent(name)\n + '&slot=' + encodeURIComponent(motionSlot.value)\n + '&loop=' + (motionLoop.checked ? '1' : '0');\n if (motionHasTrim()) {\n query += '&trimStart=' + encodeURIComponent(String(Math.round(motionTrimStart * 100) / 100))\n + '&trimEnd=' + encodeURIComponent(String(Math.round(motionTrimEnd * 100) / 100));\n }\n if (motionPendingId) {\n query += '&pendingId=' + encodeURIComponent(motionPendingId);\n }\n motionSubmit.disabled = true;\n showMotionNote('Uploading the video\u2026', '');\n var response, result;\n try {\n response = await fetch('/api/editor/motion-video' + query, {\n method: 'POST',\n headers: { 'Content-Type': motionBlob.type || 'video/webm' },\n body: motionBlob\n });\n result = await response.json();\n } catch (error) {\n showMotionNote('Could not send the video to the sidecar.', '');\n motionSubmit.disabled = false;\n return;\n }\n if (!response.ok || result.ok !== true) {\n showMotionNote(result.error || 'The sidecar refused the video.', '');\n motionSubmit.disabled = false;\n return;\n }\n if (result.running !== true) {\n // The one case the sidecar cannot run itself: nobody is logged in.\n showMotionNote(result.reason || '', result.command || '');\n setStatus('saved', 'Video saved');\n return;\n }\n showMotionNote(result.note || 'Making the animation \u2014 this takes a few minutes. You can keep editing.', '');\n setStatus('saving', 'Making the animation\u2026');\n pollMotionJob();\n }\n\n motionToggle.addEventListener('click', openMotionPanel);\n document.getElementById('motion-close').addEventListener('click', function () { closeMotionPanel(); });\n motionOverlay.addEventListener('click', function (event) {\n if (event.target === motionOverlay) closeMotionPanel();\n });\n motionRecordBtn.addEventListener('click', function () { void openMotionCamera(); });\n motionStartBtn.addEventListener('click', function () { void startMotionRecording(); });\n motionStopBtn.addEventListener('click', stopMotionRecording);\n motionRetakeBtn.addEventListener('click', resetMotionPanel);\n motionPickBtn.addEventListener('click', function () { motionFile.value = ''; motionFile.click(); });\n motionFile.addEventListener('change', function () { pickMotionFile(motionFile.files && motionFile.files[0]); });\n motionSubmit.addEventListener('click', function () { void submitMotion(); });\n motionTrimStartHandle.addEventListener('pointerdown', function (event) { beginMotionTrimDrag('start', event); });\n motionTrimEndHandle.addEventListener('pointerdown', function (event) { beginMotionTrimDrag('end', event); });\n motionTrimFullBtn.addEventListener('click', function () {\n motionTrimStart = 0;\n motionTrimEnd = motionTrimDuration;\n renderMotionTrim();\n });\n // \"Preview the kept range\" plays exactly trimStart -> trimEnd once and stops\n // on the last kept frame: what was just watched IS what the animation is\n // made from.\n motionTrimPreviewBtn.addEventListener('click', function () {\n if (motionPreviewingTrim) {\n motionPreview.pause();\n motionPreviewingTrim = false;\n renderMotionTrim();\n return;\n }\n motionPreview.currentTime = motionTrimStart;\n motionPreviewingTrim = true;\n renderMotionTrim();\n var played = motionPreview.play();\n if (played && played.catch) {\n played.catch(function () { motionPreviewingTrim = false; renderMotionTrim(); });\n }\n });\n motionPreview.addEventListener('pause', function () {\n if (motionPreviewingTrim) { motionPreviewingTrim = false; renderMotionTrim(); }\n });\n motionPreview.addEventListener('ended', function () {\n if (motionPreviewingTrim) { motionPreviewingTrim = false; renderMotionTrim(); }\n });\n motionPreview.addEventListener('timeupdate', function () {\n if (motionTrimDuration > 0) {\n motionTrimPlayhead.style.left = Math.min(Math.max(motionPreview.currentTime / motionTrimDuration, 0), 1) * 100 + '%';\n }\n if (motionPreviewingTrim && motionPreview.currentTime >= motionTrimEnd) {\n motionPreview.pause();\n motionPreview.currentTime = motionTrimEnd;\n motionPreviewingTrim = false;\n renderMotionTrim();\n }\n });\n";