@bitmagic/cli 0.1.59-dev.5 → 0.1.59-dev.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -1
- package/dist/assets/file-kind.d.ts +8 -0
- package/dist/assets/file-kind.js +24 -0
- package/dist/assets/file-kind.js.map +1 -1
- package/dist/commands/generate.d.ts +24 -0
- package/dist/commands/generate.js +98 -0
- package/dist/commands/generate.js.map +1 -1
- package/dist/editor/journal.d.ts +25 -0
- package/dist/editor/journal.js +8 -0
- package/dist/editor/journal.js.map +1 -1
- package/dist/editor/motion-panel.d.ts +20 -0
- package/dist/editor/motion-panel.js +419 -0
- package/dist/editor/motion-panel.js.map +1 -0
- package/dist/editor/motion-sink.d.ts +33 -0
- package/dist/editor/motion-sink.js +66 -0
- package/dist/editor/motion-sink.js.map +1 -0
- package/dist/editor/server.js +119 -0
- package/dist/editor/server.js.map +1 -1
- package/dist/editor/shell-page.js +6 -1
- package/dist/editor/shell-page.js.map +1 -1
- package/dist/generate/animation-from-video.d.ts +48 -0
- package/dist/generate/animation-from-video.js +149 -0
- package/dist/generate/animation-from-video.js.map +1 -0
- package/dist/project/jobs.d.ts +1 -1
- package/dist/project/jobs.js.map +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Record-motion panel — a webcam recording (or a picked video) becomes an animation clip via
|
|
3
|
+
* Uthana video-to-motion — composed into `shell-page.ts` the way `recording-panel.ts` is.
|
|
4
|
+
*
|
|
5
|
+
* Unlike the other panels this one also exports its BEHAVIOUR as a string: the recorder logic is
|
|
6
|
+
* a screenful, and it has to sit inside the shell's IIFE (it needs `setStatus`, `start`,
|
|
7
|
+
* `autoReload`, `banner`, `frame`). The same logic exists as a typed module for the Creator —
|
|
8
|
+
* `creator/src/utils/videoRecorder.ts` is the source of truth; this is a hand-kept ES5 port,
|
|
9
|
+
* because the shell has no bundler and cannot import it.
|
|
10
|
+
*
|
|
11
|
+
* Same template-literal rules as the host: **no backticks**, no regex literals starting with a
|
|
12
|
+
* dot-escape, every colour a `--bm-*` token — `shell-page.test.ts` enforces all three.
|
|
13
|
+
*/
|
|
14
|
+
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-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 { width: 100%; }\n #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";
|
|
15
|
+
export declare const MOTION_PANEL_HTML = " <div id=\"motion-overlay\" class=\"bm-overlay\">\n <div id=\"motion-dialog\" class=\"bm-dialog\">\n <h2>Record a motion</h2>\n <p>Perform the move on camera, or pick a video. Uthana turns it into an animation clip for\n this game's characters. Costs sparks.</p>\n <div id=\"motion-stage\">\n <video id=\"motion-preview\" playsinline muted></video>\n <span id=\"motion-timer\">0:00</span>\n <div id=\"motion-countdown\"><span id=\"motion-countdown-number\">3</span><span id=\"motion-countdown-hint\">Get in frame</span></div>\n <div id=\"motion-hint\">Whole body in frame, one person, plain background, good light.\n 2–60 seconds.</div>\n </div>\n <div id=\"motion-sources\">\n <button id=\"motion-record\" class=\"bm-pill bm-pill--primary\" type=\"button\">Record with webcam</button>\n <button id=\"motion-stop\" class=\"bm-pill\" type=\"button\" hidden>Stop</button>\n <button id=\"motion-pick\" class=\"bm-pill\" type=\"button\">Choose a video…</button>\n <button id=\"motion-retake\" class=\"bm-pill bm-pill--ghost\" type=\"button\" hidden>Start over</button>\n <input id=\"motion-file\" type=\"file\" accept=\".mp4,.mov,.avi,.webm,video/mp4,video/quicktime,video/webm\">\n </div>\n <p id=\"motion-guidance\"></p>\n <div id=\"motion-options\">\n <div>\n <label class=\"bm-label\" for=\"motion-name\">Name</label>\n <input id=\"motion-name\" class=\"bm-input\" type=\"text\" placeholder=\"e.g. Victory dance\" maxlength=\"80\">\n </div>\n <div>\n <label class=\"bm-label\" for=\"motion-slot\">Use as</label>\n <select id=\"motion-slot\" class=\"bm-input\">\n <option value=\"\">One-shot clip</option>\n <option value=\"idle\">Idle</option>\n <option value=\"walk\">Walk</option>\n <option value=\"run\">Run</option>\n </select>\n </div>\n </div>\n <label class=\"motion-check\"><input id=\"motion-loop\" type=\"checkbox\"> Loops</label>\n <div id=\"motion-actions\">\n <button id=\"motion-close\" class=\"bm-pill bm-pill--ghost\" type=\"button\">Close</button>\n <button id=\"motion-submit\" class=\"bm-pill bm-pill--primary\" type=\"button\" disabled>Make the animation</button>\n </div>\n <div id=\"motion-note\">\n <span id=\"motion-note-text\"></span>\n <code id=\"motion-command\"></code>\n </div>\n </div>\n </div>\n";
|
|
16
|
+
/**
|
|
17
|
+
* Behaviour, spliced into the shell's IIFE. Runs after the shell's own helpers are defined, so
|
|
18
|
+
* `setStatus`, `start`, `autoReload`, `banner` and `frame` are in scope.
|
|
19
|
+
*/
|
|
20
|
+
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 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 var motionSeconds = 0;\n var motionStream = null;\n var motionRecorder = null;\n var motionChunks = [];\n var motionTickTimer = null;\n var motionStartedAt = 0;\n var motionObjectUrl = null;\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 return Math.floor(s / 60) + ':' + (s % 60 < 10 ? '0' : '') + (s % 60);\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 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 motionStopBtn.hidden = state !== 'recording';\n motionRetakeBtn.hidden = state !== 'ready';\n motionSubmit.disabled = state !== 'ready';\n }\n\n function resetMotionPanel() {\n stopMotionStream();\n if (motionRecorder && motionRecorder.state !== 'inactive') { try { motionRecorder.stop(); } catch (error) { /* already stopped */ } }\n motionRecorder = null;\n motionChunks = [];\n motionBlob = null;\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 motionGuidance.textContent = motionSupported() ? '' : 'Webcam recording needs a secure page with a camera; choose a video file instead.';\n motionRecordBtn.disabled = !motionSupported();\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 if (motionRecorder && motionRecorder.state !== 'inactive') { try { motionRecorder.stop(); } catch (error) { /* already stopped */ } }\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 }\n\n async function startMotionRecording() {\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 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 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 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 setMotionButtons('recording');\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 // Stop pressed during the countdown: nothing was recorded, back to the start.\n if (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 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 var query = '?name=' + encodeURIComponent(name)\n + '&slot=' + encodeURIComponent(motionSlot.value)\n + '&loop=' + (motionLoop.checked ? '1' : '0');\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 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";
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Record-motion panel — a webcam recording (or a picked video) becomes an animation clip via
|
|
3
|
+
* Uthana video-to-motion — composed into `shell-page.ts` the way `recording-panel.ts` is.
|
|
4
|
+
*
|
|
5
|
+
* Unlike the other panels this one also exports its BEHAVIOUR as a string: the recorder logic is
|
|
6
|
+
* a screenful, and it has to sit inside the shell's IIFE (it needs `setStatus`, `start`,
|
|
7
|
+
* `autoReload`, `banner`, `frame`). The same logic exists as a typed module for the Creator —
|
|
8
|
+
* `creator/src/utils/videoRecorder.ts` is the source of truth; this is a hand-kept ES5 port,
|
|
9
|
+
* because the shell has no bundler and cannot import it.
|
|
10
|
+
*
|
|
11
|
+
* Same template-literal rules as the host: **no backticks**, no regex literals starting with a
|
|
12
|
+
* dot-escape, every colour a `--bm-*` token — `shell-page.test.ts` enforces all three.
|
|
13
|
+
*/
|
|
14
|
+
export const MOTION_PANEL_CSS = `
|
|
15
|
+
/* Level with the other dialogs: each opens from its own control and they never stack. */
|
|
16
|
+
#motion-overlay { z-index: 10; }
|
|
17
|
+
#motion-dialog { width: min(520px, 94vw); padding: 20px; }
|
|
18
|
+
#motion-dialog .bm-label { display: block; margin: 14px 0 6px; }
|
|
19
|
+
#motion-stage { position: relative; aspect-ratio: 16 / 9; border-radius: 8px; overflow: hidden;
|
|
20
|
+
background: var(--bm-surface-alt); border: 1px solid var(--bm-border-muted); }
|
|
21
|
+
#motion-preview { width: 100%; height: 100%; object-fit: contain; background: #000000; display: block; }
|
|
22
|
+
#motion-preview.mirror { transform: scaleX(-1); }
|
|
23
|
+
#motion-timer { position: absolute; top: 10px; left: 10px; display: none; padding: 3px 10px;
|
|
24
|
+
border-radius: 999px; background: var(--bm-pink); color: #ffffff; font-size: 12px;
|
|
25
|
+
font-family: var(--bm-font-mono); }
|
|
26
|
+
#motion-timer.show { display: inline-block; }
|
|
27
|
+
#motion-countdown { position: absolute; inset: 0; display: none; flex-direction: column; align-items: center;
|
|
28
|
+
justify-content: center; gap: 6px; background: var(--bm-scrim); pointer-events: none; }
|
|
29
|
+
#motion-countdown.show { display: flex; }
|
|
30
|
+
#motion-countdown-number { font-size: 96px; font-weight: 700; line-height: 1; color: #ffffff; }
|
|
31
|
+
#motion-countdown-hint { font-size: 13px; color: #ffffff; opacity: 0.85; }
|
|
32
|
+
#motion-hint { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
|
33
|
+
text-align: center; padding: 20px; font-size: 13px; color: var(--bm-text-muted); }
|
|
34
|
+
#motion-hint.hidden { display: none; }
|
|
35
|
+
#motion-sources { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
|
36
|
+
#motion-file { display: none; }
|
|
37
|
+
#motion-guidance { margin: 10px 0 0; font-size: 12px; color: var(--bm-text-muted); }
|
|
38
|
+
#motion-options { display: grid; grid-template-columns: 1fr auto; gap: 10px 12px; align-items: end; }
|
|
39
|
+
#motion-name { width: 100%; }
|
|
40
|
+
#motion-slot { width: 100%; }
|
|
41
|
+
.motion-check { display: flex; align-items: center; gap: 8px; margin-top: 12px; font-size: 13px; cursor: pointer; }
|
|
42
|
+
.motion-check input { accent-color: var(--bm-aqua); }
|
|
43
|
+
#motion-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
|
44
|
+
#motion-note { display: none; margin-top: 12px; padding: 12px; border-radius: 6px; font-size: 12px;
|
|
45
|
+
background: var(--bm-surface-alt); border: 1px solid var(--bm-border-muted);
|
|
46
|
+
color: var(--bm-text-muted); }
|
|
47
|
+
#motion-note.show { display: block; }
|
|
48
|
+
#motion-note code { display: block; margin-top: 6px; color: var(--bm-aqua);
|
|
49
|
+
font-family: var(--bm-font-mono); word-break: break-all; }
|
|
50
|
+
`;
|
|
51
|
+
export const MOTION_PANEL_HTML = ` <div id="motion-overlay" class="bm-overlay">
|
|
52
|
+
<div id="motion-dialog" class="bm-dialog">
|
|
53
|
+
<h2>Record a motion</h2>
|
|
54
|
+
<p>Perform the move on camera, or pick a video. Uthana turns it into an animation clip for
|
|
55
|
+
this game's characters. Costs sparks.</p>
|
|
56
|
+
<div id="motion-stage">
|
|
57
|
+
<video id="motion-preview" playsinline muted></video>
|
|
58
|
+
<span id="motion-timer">0:00</span>
|
|
59
|
+
<div id="motion-countdown"><span id="motion-countdown-number">3</span><span id="motion-countdown-hint">Get in frame</span></div>
|
|
60
|
+
<div id="motion-hint">Whole body in frame, one person, plain background, good light.
|
|
61
|
+
2–60 seconds.</div>
|
|
62
|
+
</div>
|
|
63
|
+
<div id="motion-sources">
|
|
64
|
+
<button id="motion-record" class="bm-pill bm-pill--primary" type="button">Record with webcam</button>
|
|
65
|
+
<button id="motion-stop" class="bm-pill" type="button" hidden>Stop</button>
|
|
66
|
+
<button id="motion-pick" class="bm-pill" type="button">Choose a video…</button>
|
|
67
|
+
<button id="motion-retake" class="bm-pill bm-pill--ghost" type="button" hidden>Start over</button>
|
|
68
|
+
<input id="motion-file" type="file" accept=".mp4,.mov,.avi,.webm,video/mp4,video/quicktime,video/webm">
|
|
69
|
+
</div>
|
|
70
|
+
<p id="motion-guidance"></p>
|
|
71
|
+
<div id="motion-options">
|
|
72
|
+
<div>
|
|
73
|
+
<label class="bm-label" for="motion-name">Name</label>
|
|
74
|
+
<input id="motion-name" class="bm-input" type="text" placeholder="e.g. Victory dance" maxlength="80">
|
|
75
|
+
</div>
|
|
76
|
+
<div>
|
|
77
|
+
<label class="bm-label" for="motion-slot">Use as</label>
|
|
78
|
+
<select id="motion-slot" class="bm-input">
|
|
79
|
+
<option value="">One-shot clip</option>
|
|
80
|
+
<option value="idle">Idle</option>
|
|
81
|
+
<option value="walk">Walk</option>
|
|
82
|
+
<option value="run">Run</option>
|
|
83
|
+
</select>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
<label class="motion-check"><input id="motion-loop" type="checkbox"> Loops</label>
|
|
87
|
+
<div id="motion-actions">
|
|
88
|
+
<button id="motion-close" class="bm-pill bm-pill--ghost" type="button">Close</button>
|
|
89
|
+
<button id="motion-submit" class="bm-pill bm-pill--primary" type="button" disabled>Make the animation</button>
|
|
90
|
+
</div>
|
|
91
|
+
<div id="motion-note">
|
|
92
|
+
<span id="motion-note-text"></span>
|
|
93
|
+
<code id="motion-command"></code>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
`;
|
|
98
|
+
/**
|
|
99
|
+
* Behaviour, spliced into the shell's IIFE. Runs after the shell's own helpers are defined, so
|
|
100
|
+
* `setStatus`, `start`, `autoReload`, `banner` and `frame` are in scope.
|
|
101
|
+
*/
|
|
102
|
+
export const MOTION_PANEL_JS = `
|
|
103
|
+
// ---- Record motion: webcam or file -> Uthana video-to-motion -> assets[] ----------------------
|
|
104
|
+
var motionToggle = document.getElementById('motion');
|
|
105
|
+
var motionOverlay = document.getElementById('motion-overlay');
|
|
106
|
+
var motionPreview = document.getElementById('motion-preview');
|
|
107
|
+
var motionTimer = document.getElementById('motion-timer');
|
|
108
|
+
var motionCountdown = document.getElementById('motion-countdown');
|
|
109
|
+
var motionCountdownNumber = document.getElementById('motion-countdown-number');
|
|
110
|
+
/** Seconds shown on the live preview before the recorder starts, so the first frames are the pose. */
|
|
111
|
+
var MOTION_COUNTDOWN_S = 3;
|
|
112
|
+
var motionCountdownToken = 0;
|
|
113
|
+
var motionHint = document.getElementById('motion-hint');
|
|
114
|
+
var motionRecordBtn = document.getElementById('motion-record');
|
|
115
|
+
var motionStopBtn = document.getElementById('motion-stop');
|
|
116
|
+
var motionPickBtn = document.getElementById('motion-pick');
|
|
117
|
+
var motionRetakeBtn = document.getElementById('motion-retake');
|
|
118
|
+
var motionFile = document.getElementById('motion-file');
|
|
119
|
+
var motionGuidance = document.getElementById('motion-guidance');
|
|
120
|
+
var motionName = document.getElementById('motion-name');
|
|
121
|
+
var motionSlot = document.getElementById('motion-slot');
|
|
122
|
+
var motionLoop = document.getElementById('motion-loop');
|
|
123
|
+
var motionSubmit = document.getElementById('motion-submit');
|
|
124
|
+
var motionNote = document.getElementById('motion-note');
|
|
125
|
+
var MOTION_MIN_S = 2;
|
|
126
|
+
var MOTION_MAX_S = 60;
|
|
127
|
+
var MOTION_MAX_BYTES = 200 * 1024 * 1024;
|
|
128
|
+
var motionBlob = null;
|
|
129
|
+
var motionSeconds = 0;
|
|
130
|
+
var motionStream = null;
|
|
131
|
+
var motionRecorder = null;
|
|
132
|
+
var motionChunks = [];
|
|
133
|
+
var motionTickTimer = null;
|
|
134
|
+
var motionStartedAt = 0;
|
|
135
|
+
var motionObjectUrl = null;
|
|
136
|
+
|
|
137
|
+
function motionSupported() {
|
|
138
|
+
return !!(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia
|
|
139
|
+
&& typeof MediaRecorder !== 'undefined');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** mp4 first (Safari), then the WebM the rest produce; asset-forger re-encodes either. */
|
|
143
|
+
function pickMotionMime() {
|
|
144
|
+
var candidates = ['video/mp4', 'video/webm;codecs=vp9', 'video/webm'];
|
|
145
|
+
for (var i = 0; i < candidates.length; i++) {
|
|
146
|
+
if (MediaRecorder.isTypeSupported(candidates[i])) return candidates[i];
|
|
147
|
+
}
|
|
148
|
+
return '';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function motionCameraError(error) {
|
|
152
|
+
var name = error && error.name;
|
|
153
|
+
if (name === 'NotAllowedError' || name === 'SecurityError') return 'Camera access was refused. Allow it in the address bar, or choose a video file.';
|
|
154
|
+
if (name === 'NotFoundError' || name === 'OverconstrainedError') return 'No camera found. Choose a video file instead.';
|
|
155
|
+
if (name === 'NotReadableError') return 'The camera is in use by another app. Close it, or choose a video file.';
|
|
156
|
+
return 'Could not start the camera: ' + ((error && error.message) || 'unknown error');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function formatMotionClock(seconds) {
|
|
160
|
+
var s = Math.max(0, Math.floor(seconds));
|
|
161
|
+
return Math.floor(s / 60) + ':' + (s % 60 < 10 ? '0' : '') + (s % 60);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function showMotionNote(text, command) {
|
|
165
|
+
document.getElementById('motion-note-text').textContent = text;
|
|
166
|
+
document.getElementById('motion-command').textContent = command || '';
|
|
167
|
+
motionNote.classList.add('show');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function releaseMotionObjectUrl() {
|
|
171
|
+
if (motionObjectUrl) { URL.revokeObjectURL(motionObjectUrl); motionObjectUrl = null; }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function stopMotionStream() {
|
|
175
|
+
if (motionStream) {
|
|
176
|
+
motionStream.getTracks().forEach(function (track) { track.stop(); });
|
|
177
|
+
motionStream = null;
|
|
178
|
+
}
|
|
179
|
+
if (motionTickTimer) { clearInterval(motionTickTimer); motionTickTimer = null; }
|
|
180
|
+
motionCountdownToken++;
|
|
181
|
+
motionCountdown.classList.remove('show');
|
|
182
|
+
motionTimer.classList.remove('show');
|
|
183
|
+
motionPreview.classList.remove('mirror');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function setMotionButtons(state) {
|
|
187
|
+
motionRecordBtn.hidden = state !== 'idle';
|
|
188
|
+
motionPickBtn.hidden = state !== 'idle';
|
|
189
|
+
motionStopBtn.hidden = state !== 'recording';
|
|
190
|
+
motionRetakeBtn.hidden = state !== 'ready';
|
|
191
|
+
motionSubmit.disabled = state !== 'ready';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function resetMotionPanel() {
|
|
195
|
+
stopMotionStream();
|
|
196
|
+
if (motionRecorder && motionRecorder.state !== 'inactive') { try { motionRecorder.stop(); } catch (error) { /* already stopped */ } }
|
|
197
|
+
motionRecorder = null;
|
|
198
|
+
motionChunks = [];
|
|
199
|
+
motionBlob = null;
|
|
200
|
+
motionSeconds = 0;
|
|
201
|
+
releaseMotionObjectUrl();
|
|
202
|
+
motionPreview.srcObject = null;
|
|
203
|
+
motionPreview.removeAttribute('src');
|
|
204
|
+
motionPreview.controls = false;
|
|
205
|
+
motionPreview.muted = true;
|
|
206
|
+
motionHint.classList.remove('hidden');
|
|
207
|
+
motionGuidance.textContent = motionSupported() ? '' : 'Webcam recording needs a secure page with a camera; choose a video file instead.';
|
|
208
|
+
motionRecordBtn.disabled = !motionSupported();
|
|
209
|
+
motionNote.classList.remove('show');
|
|
210
|
+
setMotionButtons('idle');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function openMotionPanel() {
|
|
214
|
+
resetMotionPanel();
|
|
215
|
+
motionOverlay.classList.add('show');
|
|
216
|
+
motionName.focus();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Returns whether it was open — the Escape handler's contract, like closeAssetDetail. */
|
|
220
|
+
function closeMotionPanel() {
|
|
221
|
+
if (!motionOverlay.classList.contains('show')) return false;
|
|
222
|
+
stopMotionStream();
|
|
223
|
+
if (motionRecorder && motionRecorder.state !== 'inactive') { try { motionRecorder.stop(); } catch (error) { /* already stopped */ } }
|
|
224
|
+
motionOverlay.classList.remove('show');
|
|
225
|
+
frame.focus();
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The clip is in hand: show it back, and say whether it is within Uthana's 2-60 s window. */
|
|
230
|
+
function acceptMotionClip(blob, seconds) {
|
|
231
|
+
motionBlob = blob;
|
|
232
|
+
motionSeconds = seconds;
|
|
233
|
+
releaseMotionObjectUrl();
|
|
234
|
+
motionObjectUrl = URL.createObjectURL(blob);
|
|
235
|
+
motionPreview.srcObject = null;
|
|
236
|
+
motionPreview.src = motionObjectUrl;
|
|
237
|
+
motionPreview.controls = true;
|
|
238
|
+
motionPreview.muted = true;
|
|
239
|
+
motionHint.classList.add('hidden');
|
|
240
|
+
var note;
|
|
241
|
+
if (blob.size > MOTION_MAX_BYTES) note = 'That video is over 200 MB. Trim it to the seconds that matter.';
|
|
242
|
+
else if (seconds > 0 && seconds < MOTION_MIN_S) note = 'Too short: Uthana needs at least ' + MOTION_MIN_S + ' seconds.';
|
|
243
|
+
else if (seconds > MOTION_MAX_S) note = 'Too long: Uthana takes at most ' + MOTION_MAX_S + ' seconds. Trim it, or record again.';
|
|
244
|
+
var ok = !note;
|
|
245
|
+
motionGuidance.textContent = ok
|
|
246
|
+
? (seconds > 0 ? formatMotionClock(seconds) + ' of video ready.' : 'Video ready.')
|
|
247
|
+
: note;
|
|
248
|
+
setMotionButtons('ready');
|
|
249
|
+
motionSubmit.disabled = !ok;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function startMotionRecording() {
|
|
253
|
+
if (!motionSupported()) return;
|
|
254
|
+
motionNote.classList.remove('show');
|
|
255
|
+
try {
|
|
256
|
+
motionStream = await navigator.mediaDevices.getUserMedia({
|
|
257
|
+
video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' }, audio: false
|
|
258
|
+
});
|
|
259
|
+
} catch (error) {
|
|
260
|
+
motionGuidance.textContent = motionCameraError(error);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
releaseMotionObjectUrl();
|
|
264
|
+
motionPreview.removeAttribute('src');
|
|
265
|
+
motionPreview.controls = false;
|
|
266
|
+
motionPreview.srcObject = motionStream;
|
|
267
|
+
motionPreview.classList.add('mirror');
|
|
268
|
+
motionHint.classList.add('hidden');
|
|
269
|
+
try { await motionPreview.play(); } catch (error) { /* autoplay refused; the stream still shows on interaction */ }
|
|
270
|
+
var mime = pickMotionMime();
|
|
271
|
+
try {
|
|
272
|
+
motionRecorder = mime ? new MediaRecorder(motionStream, { mimeType: mime }) : new MediaRecorder(motionStream);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
stopMotionStream();
|
|
275
|
+
motionGuidance.textContent = 'This browser cannot record video. Choose a video file instead.';
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// Count down on the live preview; Stop during the count aborts without recording.
|
|
279
|
+
var token = ++motionCountdownToken;
|
|
280
|
+
setMotionButtons('recording');
|
|
281
|
+
motionGuidance.textContent = 'Get in frame — recording starts after the count.';
|
|
282
|
+
motionCountdown.classList.add('show');
|
|
283
|
+
for (var left = MOTION_COUNTDOWN_S; left > 0; left--) {
|
|
284
|
+
motionCountdownNumber.textContent = String(left);
|
|
285
|
+
await new Promise(function (resolve) { setTimeout(resolve, 1000); });
|
|
286
|
+
if (token !== motionCountdownToken) return;
|
|
287
|
+
}
|
|
288
|
+
motionCountdown.classList.remove('show');
|
|
289
|
+
motionChunks = [];
|
|
290
|
+
motionRecorder.ondataavailable = function (event) { if (event.data && event.data.size > 0) motionChunks.push(event.data); };
|
|
291
|
+
motionRecorder.onstop = function () {
|
|
292
|
+
var seconds = (Date.now() - motionStartedAt) / 1000;
|
|
293
|
+
var type = motionRecorder.mimeType || mime || 'video/webm';
|
|
294
|
+
stopMotionStream();
|
|
295
|
+
acceptMotionClip(new Blob(motionChunks, { type: type }), seconds);
|
|
296
|
+
};
|
|
297
|
+
motionStartedAt = Date.now();
|
|
298
|
+
motionRecorder.start(1000);
|
|
299
|
+
motionTimer.textContent = '0:00';
|
|
300
|
+
motionTimer.classList.add('show');
|
|
301
|
+
motionGuidance.textContent = 'Recording. Stop when the move is done (auto-stops at ' + MOTION_MAX_S + ' s).';
|
|
302
|
+
setMotionButtons('recording');
|
|
303
|
+
motionTickTimer = setInterval(function () {
|
|
304
|
+
var elapsed = (Date.now() - motionStartedAt) / 1000;
|
|
305
|
+
motionTimer.textContent = formatMotionClock(elapsed);
|
|
306
|
+
if (elapsed >= MOTION_MAX_S) stopMotionRecording();
|
|
307
|
+
}, 250);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function stopMotionRecording() {
|
|
311
|
+
if (motionRecorder && motionRecorder.state === 'recording') { motionRecorder.stop(); return; }
|
|
312
|
+
// Stop pressed during the countdown: nothing was recorded, back to the start.
|
|
313
|
+
if (motionCountdown.classList.contains('show')) resetMotionPanel();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function pickMotionFile(file) {
|
|
317
|
+
if (!file) return;
|
|
318
|
+
// Read the duration off the file itself; a picked video knows its length, a fresh WebM often
|
|
319
|
+
// does not (Infinity), so 0 means "unknown" and the server's ffprobe is the judge.
|
|
320
|
+
var probe = document.createElement('video');
|
|
321
|
+
probe.preload = 'metadata';
|
|
322
|
+
var url = URL.createObjectURL(file);
|
|
323
|
+
var settled = false;
|
|
324
|
+
var finish = function (seconds) {
|
|
325
|
+
if (settled) return;
|
|
326
|
+
settled = true;
|
|
327
|
+
URL.revokeObjectURL(url);
|
|
328
|
+
acceptMotionClip(file, seconds);
|
|
329
|
+
};
|
|
330
|
+
probe.onloadedmetadata = function () { finish(isFinite(probe.duration) ? probe.duration : 0); };
|
|
331
|
+
probe.onerror = function () { finish(0); };
|
|
332
|
+
probe.src = url;
|
|
333
|
+
setTimeout(function () { finish(0); }, 3000);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
var motionPollTimer = null;
|
|
337
|
+
function pollMotionJob() {
|
|
338
|
+
if (motionPollTimer) clearTimeout(motionPollTimer);
|
|
339
|
+
var tick = async function () {
|
|
340
|
+
var job;
|
|
341
|
+
try {
|
|
342
|
+
job = (await (await fetch('/api/editor/motion-status')).json()).job;
|
|
343
|
+
} catch (error) {
|
|
344
|
+
motionPollTimer = setTimeout(tick, 3000);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (!job) return;
|
|
348
|
+
if (job.status === 'running') {
|
|
349
|
+
showMotionNote(job.message, '');
|
|
350
|
+
setStatus('saving', 'Making the animation…');
|
|
351
|
+
motionPollTimer = setTimeout(tick, 3000);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (job.status === 'failed') {
|
|
355
|
+
showMotionNote('Could not make the animation: ' + job.message, '');
|
|
356
|
+
setStatus('error', 'Animation failed');
|
|
357
|
+
setMotionButtons('ready');
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
showMotionNote(job.message + ' Reloading to show it.', '');
|
|
361
|
+
setStatus('saved', 'Animation added');
|
|
362
|
+
setMotionButtons('ready');
|
|
363
|
+
if (autoReload && !voxelSessionActive && !voxelSessionPending) start();
|
|
364
|
+
else banner.classList.add('show');
|
|
365
|
+
};
|
|
366
|
+
void tick();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function submitMotion() {
|
|
370
|
+
if (!motionBlob) return;
|
|
371
|
+
var name = motionName.value.trim();
|
|
372
|
+
if (name === '') { motionName.focus(); return; }
|
|
373
|
+
var query = '?name=' + encodeURIComponent(name)
|
|
374
|
+
+ '&slot=' + encodeURIComponent(motionSlot.value)
|
|
375
|
+
+ '&loop=' + (motionLoop.checked ? '1' : '0');
|
|
376
|
+
motionSubmit.disabled = true;
|
|
377
|
+
showMotionNote('Uploading the video…', '');
|
|
378
|
+
var response, result;
|
|
379
|
+
try {
|
|
380
|
+
response = await fetch('/api/editor/motion-video' + query, {
|
|
381
|
+
method: 'POST',
|
|
382
|
+
headers: { 'Content-Type': motionBlob.type || 'video/webm' },
|
|
383
|
+
body: motionBlob
|
|
384
|
+
});
|
|
385
|
+
result = await response.json();
|
|
386
|
+
} catch (error) {
|
|
387
|
+
showMotionNote('Could not send the video to the sidecar.', '');
|
|
388
|
+
motionSubmit.disabled = false;
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (!response.ok || result.ok !== true) {
|
|
392
|
+
showMotionNote(result.error || 'The sidecar refused the video.', '');
|
|
393
|
+
motionSubmit.disabled = false;
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (result.running !== true) {
|
|
397
|
+
// The one case the sidecar cannot run itself: nobody is logged in.
|
|
398
|
+
showMotionNote(result.reason || '', result.command || '');
|
|
399
|
+
setStatus('saved', 'Video saved');
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
showMotionNote(result.note || 'Making the animation — this takes a few minutes. You can keep editing.', '');
|
|
403
|
+
setStatus('saving', 'Making the animation…');
|
|
404
|
+
pollMotionJob();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
motionToggle.addEventListener('click', openMotionPanel);
|
|
408
|
+
document.getElementById('motion-close').addEventListener('click', function () { closeMotionPanel(); });
|
|
409
|
+
motionOverlay.addEventListener('click', function (event) {
|
|
410
|
+
if (event.target === motionOverlay) closeMotionPanel();
|
|
411
|
+
});
|
|
412
|
+
motionRecordBtn.addEventListener('click', function () { void startMotionRecording(); });
|
|
413
|
+
motionStopBtn.addEventListener('click', stopMotionRecording);
|
|
414
|
+
motionRetakeBtn.addEventListener('click', resetMotionPanel);
|
|
415
|
+
motionPickBtn.addEventListener('click', function () { motionFile.value = ''; motionFile.click(); });
|
|
416
|
+
motionFile.addEventListener('change', function () { pickMotionFile(motionFile.files && motionFile.files[0]); });
|
|
417
|
+
motionSubmit.addEventListener('click', function () { void submitMotion(); });
|
|
418
|
+
`;
|
|
419
|
+
//# sourceMappingURL=motion-panel.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"motion-panel.js","sourceRoot":"","sources":["../../src/editor/motion-panel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,MAAM,CAAC,MAAM,gBAAgB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoC/B,CAAC;AAEF,MAAM,CAAC,MAAM,iBAAiB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8ChC,CAAC;AAEF;;;GAGG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4T9B,CAAC"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Record-motion panel's server half, minus the I/O: what `POST /api/editor/motion-video`
|
|
3
|
+
* accepts and how it names things. Kept pure — no fs, no network — so it is testable the way
|
|
4
|
+
* `recording-sink.ts` and `save.ts` are; `server.ts` owns the request and the job.
|
|
5
|
+
*
|
|
6
|
+
* The page POSTs the recording's raw bytes (a MediaRecorder Blob or a picked file) with the
|
|
7
|
+
* blob's own Content-Type, and the asset options as query parameters. Raw rather than multipart
|
|
8
|
+
* because the sidecar has no multipart parser beyond the recorder's frame sink, and a single
|
|
9
|
+
* binary body is the simplest thing both sides get right.
|
|
10
|
+
*/
|
|
11
|
+
import { type LocomotionState } from '@bitmagic/asset-core';
|
|
12
|
+
/** Matches the CLI lane's own cap (`generate/animation-from-video.ts`) and asset-forger's. */
|
|
13
|
+
export declare const MOTION_BODY_LIMIT_BYTES: number;
|
|
14
|
+
export interface MotionVideoRequest {
|
|
15
|
+
extension: string;
|
|
16
|
+
contentType: string;
|
|
17
|
+
name: string;
|
|
18
|
+
locomotionState?: LocomotionState;
|
|
19
|
+
loop?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface MotionVideoRefusal {
|
|
22
|
+
status: 400 | 413 | 415;
|
|
23
|
+
error: string;
|
|
24
|
+
}
|
|
25
|
+
/** The bare media type: `video/webm;codecs=vp9` → `video/webm`. */
|
|
26
|
+
export declare function baseContentType(contentType: string | undefined): string;
|
|
27
|
+
export declare function parseMotionVideoRequest(input: {
|
|
28
|
+
contentType: string | undefined;
|
|
29
|
+
contentLength: number;
|
|
30
|
+
query: URLSearchParams;
|
|
31
|
+
}): MotionVideoRequest | MotionVideoRefusal;
|
|
32
|
+
/** The command the panel prints when nobody is logged in — what the agent (or the creator) runs instead. */
|
|
33
|
+
export declare function motionCommand(videoPath: string, request: Pick<MotionVideoRequest, 'name' | 'locomotionState' | 'loop'>): string;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Record-motion panel's server half, minus the I/O: what `POST /api/editor/motion-video`
|
|
3
|
+
* accepts and how it names things. Kept pure — no fs, no network — so it is testable the way
|
|
4
|
+
* `recording-sink.ts` and `save.ts` are; `server.ts` owns the request and the job.
|
|
5
|
+
*
|
|
6
|
+
* The page POSTs the recording's raw bytes (a MediaRecorder Blob or a picked file) with the
|
|
7
|
+
* blob's own Content-Type, and the asset options as query parameters. Raw rather than multipart
|
|
8
|
+
* because the sidecar has no multipart parser beyond the recorder's frame sink, and a single
|
|
9
|
+
* binary body is the simplest thing both sides get right.
|
|
10
|
+
*/
|
|
11
|
+
import { LOCOMOTION_STATES } from '@bitmagic/asset-core';
|
|
12
|
+
/** Matches the CLI lane's own cap (`generate/animation-from-video.ts`) and asset-forger's. */
|
|
13
|
+
export const MOTION_BODY_LIMIT_BYTES = 200 * 1024 * 1024;
|
|
14
|
+
/** Content types a browser's MediaRecorder or a file picker hands over, and the extension each becomes. */
|
|
15
|
+
const EXTENSION_BY_TYPE = {
|
|
16
|
+
'video/mp4': 'mp4',
|
|
17
|
+
'video/quicktime': 'mov',
|
|
18
|
+
'video/x-msvideo': 'avi',
|
|
19
|
+
'video/webm': 'webm',
|
|
20
|
+
};
|
|
21
|
+
/** The bare media type: `video/webm;codecs=vp9` → `video/webm`. */
|
|
22
|
+
export function baseContentType(contentType) {
|
|
23
|
+
return (contentType ?? '').split(';')[0].trim().toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
export function parseMotionVideoRequest(input) {
|
|
26
|
+
const contentType = baseContentType(input.contentType);
|
|
27
|
+
const extension = EXTENSION_BY_TYPE[contentType];
|
|
28
|
+
if (extension === undefined) {
|
|
29
|
+
return {
|
|
30
|
+
status: 415,
|
|
31
|
+
error: `Expected a video body (${Object.keys(EXTENSION_BY_TYPE).join(', ')}); got ${JSON.stringify(contentType || 'nothing')}.`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
if (input.contentLength > MOTION_BODY_LIMIT_BYTES) {
|
|
35
|
+
return { status: 413, error: `The video is over ${MOTION_BODY_LIMIT_BYTES / 1024 / 1024} MB.` };
|
|
36
|
+
}
|
|
37
|
+
if (input.contentLength <= 0) {
|
|
38
|
+
return { status: 400, error: 'The video is empty.' };
|
|
39
|
+
}
|
|
40
|
+
const name = (input.query.get('name') ?? '').trim().slice(0, 80);
|
|
41
|
+
if (name === '') {
|
|
42
|
+
return { status: 400, error: 'name is required.' };
|
|
43
|
+
}
|
|
44
|
+
const slot = input.query.get('slot');
|
|
45
|
+
if (slot !== null && slot !== '' && !LOCOMOTION_STATES.includes(slot)) {
|
|
46
|
+
return { status: 400, error: `slot must be one of ${LOCOMOTION_STATES.join(', ')}.` };
|
|
47
|
+
}
|
|
48
|
+
const loop = input.query.get('loop');
|
|
49
|
+
return {
|
|
50
|
+
extension,
|
|
51
|
+
contentType,
|
|
52
|
+
name,
|
|
53
|
+
...(slot !== null && slot !== '' ? { locomotionState: slot } : {}),
|
|
54
|
+
...(loop === '1' || loop === 'true' ? { loop: true } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** The command the panel prints when nobody is logged in — what the agent (or the creator) runs instead. */
|
|
58
|
+
export function motionCommand(videoPath, request) {
|
|
59
|
+
const parts = ['bitmagic generate animation', `--video ${JSON.stringify(videoPath)}`, `--name ${JSON.stringify(request.name)}`];
|
|
60
|
+
if (request.locomotionState)
|
|
61
|
+
parts.push(`--slot ${request.locomotionState}`);
|
|
62
|
+
if (request.loop)
|
|
63
|
+
parts.push('--loop');
|
|
64
|
+
return parts.join(' ');
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=motion-sink.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"motion-sink.js","sourceRoot":"","sources":["../../src/editor/motion-sink.ts"],"names":[],"mappings":"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"}
|