/** * 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
\n

Record a motion

\n

Perform the move on camera, or pick a video. Uthana turns it into an animation clip for\n this game's characters. Costs sparks.

\n
\n \n 0:00\n
3Get in frame
\n
Whole body in frame, one person, plain background, good light.\n 2–60 seconds.
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n \n \n \n \n \n
\n
\n
\n \n \n \n \n \n \n
\n

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n
\n \n \n
\n
\n \n \n
\n
\n
\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";