@bitmagic/cli 0.1.59-dev.5 → 0.1.59-dev.6
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 +393 -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,393 @@
|
|
|
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-hint { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
|
28
|
+
text-align: center; padding: 20px; font-size: 13px; color: var(--bm-text-muted); }
|
|
29
|
+
#motion-hint.hidden { display: none; }
|
|
30
|
+
#motion-sources { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
|
31
|
+
#motion-file { display: none; }
|
|
32
|
+
#motion-guidance { margin: 10px 0 0; font-size: 12px; color: var(--bm-text-muted); }
|
|
33
|
+
#motion-options { display: grid; grid-template-columns: 1fr auto; gap: 10px 12px; align-items: end; }
|
|
34
|
+
#motion-name { width: 100%; }
|
|
35
|
+
#motion-slot { width: 100%; }
|
|
36
|
+
.motion-check { display: flex; align-items: center; gap: 8px; margin-top: 12px; font-size: 13px; cursor: pointer; }
|
|
37
|
+
.motion-check input { accent-color: var(--bm-aqua); }
|
|
38
|
+
#motion-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
|
39
|
+
#motion-note { display: none; margin-top: 12px; padding: 12px; border-radius: 6px; font-size: 12px;
|
|
40
|
+
background: var(--bm-surface-alt); border: 1px solid var(--bm-border-muted);
|
|
41
|
+
color: var(--bm-text-muted); }
|
|
42
|
+
#motion-note.show { display: block; }
|
|
43
|
+
#motion-note code { display: block; margin-top: 6px; color: var(--bm-aqua);
|
|
44
|
+
font-family: var(--bm-font-mono); word-break: break-all; }
|
|
45
|
+
`;
|
|
46
|
+
export const MOTION_PANEL_HTML = ` <div id="motion-overlay" class="bm-overlay">
|
|
47
|
+
<div id="motion-dialog" class="bm-dialog">
|
|
48
|
+
<h2>Record a motion</h2>
|
|
49
|
+
<p>Perform the move on camera, or pick a video. Uthana turns it into an animation clip for
|
|
50
|
+
this game's characters. Costs sparks.</p>
|
|
51
|
+
<div id="motion-stage">
|
|
52
|
+
<video id="motion-preview" playsinline muted></video>
|
|
53
|
+
<span id="motion-timer">0:00</span>
|
|
54
|
+
<div id="motion-hint">Whole body in frame, one person, plain background, good light.
|
|
55
|
+
2–60 seconds.</div>
|
|
56
|
+
</div>
|
|
57
|
+
<div id="motion-sources">
|
|
58
|
+
<button id="motion-record" class="bm-pill bm-pill--primary" type="button">Record with webcam</button>
|
|
59
|
+
<button id="motion-stop" class="bm-pill" type="button" hidden>Stop</button>
|
|
60
|
+
<button id="motion-pick" class="bm-pill" type="button">Choose a video…</button>
|
|
61
|
+
<button id="motion-retake" class="bm-pill bm-pill--ghost" type="button" hidden>Start over</button>
|
|
62
|
+
<input id="motion-file" type="file" accept=".mp4,.mov,.avi,.webm,video/mp4,video/quicktime,video/webm">
|
|
63
|
+
</div>
|
|
64
|
+
<p id="motion-guidance"></p>
|
|
65
|
+
<div id="motion-options">
|
|
66
|
+
<div>
|
|
67
|
+
<label class="bm-label" for="motion-name">Name</label>
|
|
68
|
+
<input id="motion-name" class="bm-input" type="text" placeholder="e.g. Victory dance" maxlength="80">
|
|
69
|
+
</div>
|
|
70
|
+
<div>
|
|
71
|
+
<label class="bm-label" for="motion-slot">Use as</label>
|
|
72
|
+
<select id="motion-slot" class="bm-input">
|
|
73
|
+
<option value="">One-shot clip</option>
|
|
74
|
+
<option value="idle">Idle</option>
|
|
75
|
+
<option value="walk">Walk</option>
|
|
76
|
+
<option value="run">Run</option>
|
|
77
|
+
</select>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
<label class="motion-check"><input id="motion-loop" type="checkbox"> Loops</label>
|
|
81
|
+
<div id="motion-actions">
|
|
82
|
+
<button id="motion-close" class="bm-pill bm-pill--ghost" type="button">Close</button>
|
|
83
|
+
<button id="motion-submit" class="bm-pill bm-pill--primary" type="button" disabled>Make the animation</button>
|
|
84
|
+
</div>
|
|
85
|
+
<div id="motion-note">
|
|
86
|
+
<span id="motion-note-text"></span>
|
|
87
|
+
<code id="motion-command"></code>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
`;
|
|
92
|
+
/**
|
|
93
|
+
* Behaviour, spliced into the shell's IIFE. Runs after the shell's own helpers are defined, so
|
|
94
|
+
* `setStatus`, `start`, `autoReload`, `banner` and `frame` are in scope.
|
|
95
|
+
*/
|
|
96
|
+
export const MOTION_PANEL_JS = `
|
|
97
|
+
// ---- Record motion: webcam or file -> Uthana video-to-motion -> assets[] ----------------------
|
|
98
|
+
var motionToggle = document.getElementById('motion');
|
|
99
|
+
var motionOverlay = document.getElementById('motion-overlay');
|
|
100
|
+
var motionPreview = document.getElementById('motion-preview');
|
|
101
|
+
var motionTimer = document.getElementById('motion-timer');
|
|
102
|
+
var motionHint = document.getElementById('motion-hint');
|
|
103
|
+
var motionRecordBtn = document.getElementById('motion-record');
|
|
104
|
+
var motionStopBtn = document.getElementById('motion-stop');
|
|
105
|
+
var motionPickBtn = document.getElementById('motion-pick');
|
|
106
|
+
var motionRetakeBtn = document.getElementById('motion-retake');
|
|
107
|
+
var motionFile = document.getElementById('motion-file');
|
|
108
|
+
var motionGuidance = document.getElementById('motion-guidance');
|
|
109
|
+
var motionName = document.getElementById('motion-name');
|
|
110
|
+
var motionSlot = document.getElementById('motion-slot');
|
|
111
|
+
var motionLoop = document.getElementById('motion-loop');
|
|
112
|
+
var motionSubmit = document.getElementById('motion-submit');
|
|
113
|
+
var motionNote = document.getElementById('motion-note');
|
|
114
|
+
var MOTION_MIN_S = 2;
|
|
115
|
+
var MOTION_MAX_S = 60;
|
|
116
|
+
var MOTION_MAX_BYTES = 200 * 1024 * 1024;
|
|
117
|
+
var motionBlob = null;
|
|
118
|
+
var motionSeconds = 0;
|
|
119
|
+
var motionStream = null;
|
|
120
|
+
var motionRecorder = null;
|
|
121
|
+
var motionChunks = [];
|
|
122
|
+
var motionTickTimer = null;
|
|
123
|
+
var motionStartedAt = 0;
|
|
124
|
+
var motionObjectUrl = null;
|
|
125
|
+
|
|
126
|
+
function motionSupported() {
|
|
127
|
+
return !!(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia
|
|
128
|
+
&& typeof MediaRecorder !== 'undefined');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** mp4 first (Safari), then the WebM the rest produce; asset-forger re-encodes either. */
|
|
132
|
+
function pickMotionMime() {
|
|
133
|
+
var candidates = ['video/mp4', 'video/webm;codecs=vp9', 'video/webm'];
|
|
134
|
+
for (var i = 0; i < candidates.length; i++) {
|
|
135
|
+
if (MediaRecorder.isTypeSupported(candidates[i])) return candidates[i];
|
|
136
|
+
}
|
|
137
|
+
return '';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function motionCameraError(error) {
|
|
141
|
+
var name = error && error.name;
|
|
142
|
+
if (name === 'NotAllowedError' || name === 'SecurityError') return 'Camera access was refused. Allow it in the address bar, or choose a video file.';
|
|
143
|
+
if (name === 'NotFoundError' || name === 'OverconstrainedError') return 'No camera found. Choose a video file instead.';
|
|
144
|
+
if (name === 'NotReadableError') return 'The camera is in use by another app. Close it, or choose a video file.';
|
|
145
|
+
return 'Could not start the camera: ' + ((error && error.message) || 'unknown error');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function formatMotionClock(seconds) {
|
|
149
|
+
var s = Math.max(0, Math.floor(seconds));
|
|
150
|
+
return Math.floor(s / 60) + ':' + (s % 60 < 10 ? '0' : '') + (s % 60);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function showMotionNote(text, command) {
|
|
154
|
+
document.getElementById('motion-note-text').textContent = text;
|
|
155
|
+
document.getElementById('motion-command').textContent = command || '';
|
|
156
|
+
motionNote.classList.add('show');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function releaseMotionObjectUrl() {
|
|
160
|
+
if (motionObjectUrl) { URL.revokeObjectURL(motionObjectUrl); motionObjectUrl = null; }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function stopMotionStream() {
|
|
164
|
+
if (motionStream) {
|
|
165
|
+
motionStream.getTracks().forEach(function (track) { track.stop(); });
|
|
166
|
+
motionStream = null;
|
|
167
|
+
}
|
|
168
|
+
if (motionTickTimer) { clearInterval(motionTickTimer); motionTickTimer = null; }
|
|
169
|
+
motionTimer.classList.remove('show');
|
|
170
|
+
motionPreview.classList.remove('mirror');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function setMotionButtons(state) {
|
|
174
|
+
motionRecordBtn.hidden = state !== 'idle';
|
|
175
|
+
motionPickBtn.hidden = state !== 'idle';
|
|
176
|
+
motionStopBtn.hidden = state !== 'recording';
|
|
177
|
+
motionRetakeBtn.hidden = state !== 'ready';
|
|
178
|
+
motionSubmit.disabled = state !== 'ready';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function resetMotionPanel() {
|
|
182
|
+
stopMotionStream();
|
|
183
|
+
if (motionRecorder && motionRecorder.state !== 'inactive') { try { motionRecorder.stop(); } catch (error) { /* already stopped */ } }
|
|
184
|
+
motionRecorder = null;
|
|
185
|
+
motionChunks = [];
|
|
186
|
+
motionBlob = null;
|
|
187
|
+
motionSeconds = 0;
|
|
188
|
+
releaseMotionObjectUrl();
|
|
189
|
+
motionPreview.srcObject = null;
|
|
190
|
+
motionPreview.removeAttribute('src');
|
|
191
|
+
motionPreview.controls = false;
|
|
192
|
+
motionPreview.muted = true;
|
|
193
|
+
motionHint.classList.remove('hidden');
|
|
194
|
+
motionGuidance.textContent = motionSupported() ? '' : 'Webcam recording needs a secure page with a camera; choose a video file instead.';
|
|
195
|
+
motionRecordBtn.disabled = !motionSupported();
|
|
196
|
+
motionNote.classList.remove('show');
|
|
197
|
+
setMotionButtons('idle');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function openMotionPanel() {
|
|
201
|
+
resetMotionPanel();
|
|
202
|
+
motionOverlay.classList.add('show');
|
|
203
|
+
motionName.focus();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Returns whether it was open — the Escape handler's contract, like closeAssetDetail. */
|
|
207
|
+
function closeMotionPanel() {
|
|
208
|
+
if (!motionOverlay.classList.contains('show')) return false;
|
|
209
|
+
stopMotionStream();
|
|
210
|
+
if (motionRecorder && motionRecorder.state !== 'inactive') { try { motionRecorder.stop(); } catch (error) { /* already stopped */ } }
|
|
211
|
+
motionOverlay.classList.remove('show');
|
|
212
|
+
frame.focus();
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The clip is in hand: show it back, and say whether it is within Uthana's 2-60 s window. */
|
|
217
|
+
function acceptMotionClip(blob, seconds) {
|
|
218
|
+
motionBlob = blob;
|
|
219
|
+
motionSeconds = seconds;
|
|
220
|
+
releaseMotionObjectUrl();
|
|
221
|
+
motionObjectUrl = URL.createObjectURL(blob);
|
|
222
|
+
motionPreview.srcObject = null;
|
|
223
|
+
motionPreview.src = motionObjectUrl;
|
|
224
|
+
motionPreview.controls = true;
|
|
225
|
+
motionPreview.muted = true;
|
|
226
|
+
motionHint.classList.add('hidden');
|
|
227
|
+
var note;
|
|
228
|
+
if (blob.size > MOTION_MAX_BYTES) note = 'That video is over 200 MB. Trim it to the seconds that matter.';
|
|
229
|
+
else if (seconds > 0 && seconds < MOTION_MIN_S) note = 'Too short: Uthana needs at least ' + MOTION_MIN_S + ' seconds.';
|
|
230
|
+
else if (seconds > MOTION_MAX_S) note = 'Too long: Uthana takes at most ' + MOTION_MAX_S + ' seconds. Trim it, or record again.';
|
|
231
|
+
var ok = !note;
|
|
232
|
+
motionGuidance.textContent = ok
|
|
233
|
+
? (seconds > 0 ? formatMotionClock(seconds) + ' of video ready.' : 'Video ready.')
|
|
234
|
+
: note;
|
|
235
|
+
setMotionButtons('ready');
|
|
236
|
+
motionSubmit.disabled = !ok;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function startMotionRecording() {
|
|
240
|
+
if (!motionSupported()) return;
|
|
241
|
+
motionNote.classList.remove('show');
|
|
242
|
+
try {
|
|
243
|
+
motionStream = await navigator.mediaDevices.getUserMedia({
|
|
244
|
+
video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' }, audio: false
|
|
245
|
+
});
|
|
246
|
+
} catch (error) {
|
|
247
|
+
motionGuidance.textContent = motionCameraError(error);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
releaseMotionObjectUrl();
|
|
251
|
+
motionPreview.removeAttribute('src');
|
|
252
|
+
motionPreview.controls = false;
|
|
253
|
+
motionPreview.srcObject = motionStream;
|
|
254
|
+
motionPreview.classList.add('mirror');
|
|
255
|
+
motionHint.classList.add('hidden');
|
|
256
|
+
try { await motionPreview.play(); } catch (error) { /* autoplay refused; the stream still shows on interaction */ }
|
|
257
|
+
var mime = pickMotionMime();
|
|
258
|
+
try {
|
|
259
|
+
motionRecorder = mime ? new MediaRecorder(motionStream, { mimeType: mime }) : new MediaRecorder(motionStream);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
stopMotionStream();
|
|
262
|
+
motionGuidance.textContent = 'This browser cannot record video. Choose a video file instead.';
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
motionChunks = [];
|
|
266
|
+
motionRecorder.ondataavailable = function (event) { if (event.data && event.data.size > 0) motionChunks.push(event.data); };
|
|
267
|
+
motionRecorder.onstop = function () {
|
|
268
|
+
var seconds = (Date.now() - motionStartedAt) / 1000;
|
|
269
|
+
var type = motionRecorder.mimeType || mime || 'video/webm';
|
|
270
|
+
stopMotionStream();
|
|
271
|
+
acceptMotionClip(new Blob(motionChunks, { type: type }), seconds);
|
|
272
|
+
};
|
|
273
|
+
motionStartedAt = Date.now();
|
|
274
|
+
motionRecorder.start(1000);
|
|
275
|
+
motionTimer.textContent = '0:00';
|
|
276
|
+
motionTimer.classList.add('show');
|
|
277
|
+
motionGuidance.textContent = 'Recording. Stop when the move is done (auto-stops at ' + MOTION_MAX_S + ' s).';
|
|
278
|
+
setMotionButtons('recording');
|
|
279
|
+
motionTickTimer = setInterval(function () {
|
|
280
|
+
var elapsed = (Date.now() - motionStartedAt) / 1000;
|
|
281
|
+
motionTimer.textContent = formatMotionClock(elapsed);
|
|
282
|
+
if (elapsed >= MOTION_MAX_S) stopMotionRecording();
|
|
283
|
+
}, 250);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function stopMotionRecording() {
|
|
287
|
+
if (motionRecorder && motionRecorder.state === 'recording') motionRecorder.stop();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function pickMotionFile(file) {
|
|
291
|
+
if (!file) return;
|
|
292
|
+
// Read the duration off the file itself; a picked video knows its length, a fresh WebM often
|
|
293
|
+
// does not (Infinity), so 0 means "unknown" and the server's ffprobe is the judge.
|
|
294
|
+
var probe = document.createElement('video');
|
|
295
|
+
probe.preload = 'metadata';
|
|
296
|
+
var url = URL.createObjectURL(file);
|
|
297
|
+
var settled = false;
|
|
298
|
+
var finish = function (seconds) {
|
|
299
|
+
if (settled) return;
|
|
300
|
+
settled = true;
|
|
301
|
+
URL.revokeObjectURL(url);
|
|
302
|
+
acceptMotionClip(file, seconds);
|
|
303
|
+
};
|
|
304
|
+
probe.onloadedmetadata = function () { finish(isFinite(probe.duration) ? probe.duration : 0); };
|
|
305
|
+
probe.onerror = function () { finish(0); };
|
|
306
|
+
probe.src = url;
|
|
307
|
+
setTimeout(function () { finish(0); }, 3000);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
var motionPollTimer = null;
|
|
311
|
+
function pollMotionJob() {
|
|
312
|
+
if (motionPollTimer) clearTimeout(motionPollTimer);
|
|
313
|
+
var tick = async function () {
|
|
314
|
+
var job;
|
|
315
|
+
try {
|
|
316
|
+
job = (await (await fetch('/api/editor/motion-status')).json()).job;
|
|
317
|
+
} catch (error) {
|
|
318
|
+
motionPollTimer = setTimeout(tick, 3000);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (!job) return;
|
|
322
|
+
if (job.status === 'running') {
|
|
323
|
+
showMotionNote(job.message, '');
|
|
324
|
+
setStatus('saving', 'Making the animation…');
|
|
325
|
+
motionPollTimer = setTimeout(tick, 3000);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (job.status === 'failed') {
|
|
329
|
+
showMotionNote('Could not make the animation: ' + job.message, '');
|
|
330
|
+
setStatus('error', 'Animation failed');
|
|
331
|
+
setMotionButtons('ready');
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
showMotionNote(job.message + ' Reloading to show it.', '');
|
|
335
|
+
setStatus('saved', 'Animation added');
|
|
336
|
+
setMotionButtons('ready');
|
|
337
|
+
if (autoReload && !voxelSessionActive && !voxelSessionPending) start();
|
|
338
|
+
else banner.classList.add('show');
|
|
339
|
+
};
|
|
340
|
+
void tick();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function submitMotion() {
|
|
344
|
+
if (!motionBlob) return;
|
|
345
|
+
var name = motionName.value.trim();
|
|
346
|
+
if (name === '') { motionName.focus(); return; }
|
|
347
|
+
var query = '?name=' + encodeURIComponent(name)
|
|
348
|
+
+ '&slot=' + encodeURIComponent(motionSlot.value)
|
|
349
|
+
+ '&loop=' + (motionLoop.checked ? '1' : '0');
|
|
350
|
+
motionSubmit.disabled = true;
|
|
351
|
+
showMotionNote('Uploading the video…', '');
|
|
352
|
+
var response, result;
|
|
353
|
+
try {
|
|
354
|
+
response = await fetch('/api/editor/motion-video' + query, {
|
|
355
|
+
method: 'POST',
|
|
356
|
+
headers: { 'Content-Type': motionBlob.type || 'video/webm' },
|
|
357
|
+
body: motionBlob
|
|
358
|
+
});
|
|
359
|
+
result = await response.json();
|
|
360
|
+
} catch (error) {
|
|
361
|
+
showMotionNote('Could not send the video to the sidecar.', '');
|
|
362
|
+
motionSubmit.disabled = false;
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (!response.ok || result.ok !== true) {
|
|
366
|
+
showMotionNote(result.error || 'The sidecar refused the video.', '');
|
|
367
|
+
motionSubmit.disabled = false;
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (result.running !== true) {
|
|
371
|
+
// The one case the sidecar cannot run itself: nobody is logged in.
|
|
372
|
+
showMotionNote(result.reason || '', result.command || '');
|
|
373
|
+
setStatus('saved', 'Video saved');
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
showMotionNote(result.note || 'Making the animation — this takes a few minutes. You can keep editing.', '');
|
|
377
|
+
setStatus('saving', 'Making the animation…');
|
|
378
|
+
pollMotionJob();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
motionToggle.addEventListener('click', openMotionPanel);
|
|
382
|
+
document.getElementById('motion-close').addEventListener('click', function () { closeMotionPanel(); });
|
|
383
|
+
motionOverlay.addEventListener('click', function (event) {
|
|
384
|
+
if (event.target === motionOverlay) closeMotionPanel();
|
|
385
|
+
});
|
|
386
|
+
motionRecordBtn.addEventListener('click', function () { void startMotionRecording(); });
|
|
387
|
+
motionStopBtn.addEventListener('click', stopMotionRecording);
|
|
388
|
+
motionRetakeBtn.addEventListener('click', resetMotionPanel);
|
|
389
|
+
motionPickBtn.addEventListener('click', function () { motionFile.value = ''; motionFile.click(); });
|
|
390
|
+
motionFile.addEventListener('change', function () { pickMotionFile(motionFile.files && motionFile.files[0]); });
|
|
391
|
+
motionSubmit.addEventListener('click', function () { void submitMotion(); });
|
|
392
|
+
`;
|
|
393
|
+
//# 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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+B/B,CAAC;AAEF,MAAM,CAAC,MAAM,iBAAiB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6ChC,CAAC;AAEF;;;GAGG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwS9B,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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"}
|
package/dist/editor/server.js
CHANGED
|
@@ -36,6 +36,8 @@ import { asHostJournalEvent, deriveSceneEvents, EditorJournal } from './journal.
|
|
|
36
36
|
import { jobsDirPath, readJobLayout, readRunningJobs, withJobRecord } from '../project/jobs.js';
|
|
37
37
|
import { listPreviews, readPreview, removePreview, writePreview } from './previews.js';
|
|
38
38
|
import { findPropAsset, generateAndInstallProp } from '../generate/prop.js';
|
|
39
|
+
import { generateAndInstallAnimationFromVideo } from '../generate/animation-from-video.js';
|
|
40
|
+
import { MOTION_BODY_LIMIT_BYTES, motionCommand, parseMotionVideoRequest } from './motion-sink.js';
|
|
39
41
|
import { resolveEnvironmentForCommand, resolveAuth0ClientId } from '../config/environments.js';
|
|
40
42
|
import { getAccessToken } from '../auth/session.js';
|
|
41
43
|
import { startUploadProxy } from '../forge/upload-proxy.js';
|
|
@@ -274,6 +276,8 @@ export async function startEditorServer(options) {
|
|
|
274
276
|
* vite and headless Chrome, and two at once on a creator's laptop would fight for both.
|
|
275
277
|
*/
|
|
276
278
|
let hqJob = null;
|
|
279
|
+
/** The Record-motion panel's job, one at a time for the same reason as `hqJob`. */
|
|
280
|
+
let motionJob = null;
|
|
277
281
|
/**
|
|
278
282
|
* A token, or null when nobody is logged in. Never starts a device-code flow.
|
|
279
283
|
*
|
|
@@ -661,6 +665,113 @@ export async function startEditorServer(options) {
|
|
|
661
665
|
const handleHqStatus = (res) => {
|
|
662
666
|
sendJson(res, 200, { ok: true, job: hqJob, worldMtimeMs: lastWrittenMtimeMs });
|
|
663
667
|
};
|
|
668
|
+
/**
|
|
669
|
+
* `POST /api/editor/motion-video` — the Record-motion panel's recording, as raw bytes.
|
|
670
|
+
*
|
|
671
|
+
* The sidecar runs the whole `bitmagic generate animation --video` lane in-process (upload,
|
|
672
|
+
* job, poll, world.json), the way the HQ button runs `generate prop`. The video is spooled to
|
|
673
|
+
* `.bitmagic/tmp/` first: when nobody is logged in the file is what the printed command points
|
|
674
|
+
* at, and when the job runs it is removed once the lane has read it.
|
|
675
|
+
*/
|
|
676
|
+
const handleMotionVideo = async (req, res) => {
|
|
677
|
+
const requestUrl = new URL(req.url ?? '/', 'http://localhost');
|
|
678
|
+
const parsed = parseMotionVideoRequest({
|
|
679
|
+
contentType: req.headers['content-type'],
|
|
680
|
+
contentLength: Number(req.headers['content-length'] ?? '0'),
|
|
681
|
+
query: requestUrl.searchParams,
|
|
682
|
+
});
|
|
683
|
+
if ('error' in parsed) {
|
|
684
|
+
sendJson(res, parsed.status, { ok: false, error: parsed.error });
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
let body;
|
|
688
|
+
try {
|
|
689
|
+
body = await readBodyBuffer(req, MOTION_BODY_LIMIT_BYTES);
|
|
690
|
+
}
|
|
691
|
+
catch {
|
|
692
|
+
sendJson(res, 413, { ok: false, error: `The video is over ${MOTION_BODY_LIMIT_BYTES / 1024 / 1024} MB.` });
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (body.length === 0) {
|
|
696
|
+
sendJson(res, 400, { ok: false, error: 'The video is empty.' });
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (motionJob !== null && motionJob.status === 'running') {
|
|
700
|
+
sendJson(res, 200, { ok: true, running: true, note: `Already making "${motionJob.name}" — one at a time.` });
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
const tmpDir = path.join(options.root, '.bitmagic', 'tmp');
|
|
704
|
+
const videoPath = path.join(tmpDir, `motion-${Date.now()}.${parsed.extension}`);
|
|
705
|
+
try {
|
|
706
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
707
|
+
fs.writeFileSync(videoPath, body);
|
|
708
|
+
}
|
|
709
|
+
catch (error) {
|
|
710
|
+
const message = messageOf(error);
|
|
711
|
+
log(`[editor] could not spool the motion video: ${message}`);
|
|
712
|
+
sendJson(res, 500, { ok: false, error: message });
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
const auth = await resolveAuth();
|
|
716
|
+
if (auth === null) {
|
|
717
|
+
const command = motionCommand(path.relative(options.root, videoPath), parsed);
|
|
718
|
+
journal.append({ event: 'motion.requested', name: parsed.name, videoPath, command });
|
|
719
|
+
sendJson(res, 200, {
|
|
720
|
+
ok: true,
|
|
721
|
+
running: false,
|
|
722
|
+
reason: 'Video saved. You are not logged in, so the editor cannot make the animation itself. '
|
|
723
|
+
+ 'Run `bitmagic login`, or have your agent run:',
|
|
724
|
+
command,
|
|
725
|
+
});
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
const run = {
|
|
729
|
+
videoPath,
|
|
730
|
+
name: parsed.name,
|
|
731
|
+
...(parsed.loop !== undefined ? { loop: parsed.loop } : {}),
|
|
732
|
+
...(parsed.locomotionState !== undefined ? { locomotionState: parsed.locomotionState } : {}),
|
|
733
|
+
};
|
|
734
|
+
motionJob = { name: parsed.name, status: 'running', message: 'Uploading the video…' };
|
|
735
|
+
journal.append({ event: 'motion.started', name: parsed.name, ...(run.locomotionState ? { locomotionState: run.locomotionState } : {}) });
|
|
736
|
+
void withJobRecord({
|
|
737
|
+
root: options.root, kind: 'animation-video', label: parsed.name, log: (m) => log(`[editor] ${m}`),
|
|
738
|
+
}, async (recordedLog) => generateAndInstallAnimationFromVideo({
|
|
739
|
+
context: { root: options.root, metadata: options.metadata },
|
|
740
|
+
environment: auth.environment,
|
|
741
|
+
token: auth.token,
|
|
742
|
+
run,
|
|
743
|
+
deps: {
|
|
744
|
+
fetch: globalThis.fetch,
|
|
745
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
746
|
+
log: (message) => {
|
|
747
|
+
recordedLog(message);
|
|
748
|
+
if (motionJob?.status === 'running')
|
|
749
|
+
motionJob.message = message;
|
|
750
|
+
},
|
|
751
|
+
},
|
|
752
|
+
})).then((result) => {
|
|
753
|
+
recordOwnWrite();
|
|
754
|
+
motionJob = {
|
|
755
|
+
name: parsed.name, status: 'done',
|
|
756
|
+
message: `Added "${parsed.name}" (${result.motionId})`
|
|
757
|
+
+ (result.locomotionState ? ` as the ${result.locomotionState} animation.` : '.'),
|
|
758
|
+
};
|
|
759
|
+
journal.append({
|
|
760
|
+
event: 'motion.completed', name: parsed.name, motionId: result.motionId, animationUrl: result.animationUrl,
|
|
761
|
+
...(result.locomotionState ? { locomotionState: result.locomotionState } : {}),
|
|
762
|
+
});
|
|
763
|
+
}).catch((error) => {
|
|
764
|
+
const message = messageOf(error);
|
|
765
|
+
motionJob = { name: parsed.name, status: 'failed', message };
|
|
766
|
+
journal.append({ event: 'motion.failed', name: parsed.name, error: message });
|
|
767
|
+
}).finally(() => {
|
|
768
|
+
fs.rm(videoPath, { force: true }, () => { });
|
|
769
|
+
});
|
|
770
|
+
sendJson(res, 200, { ok: true, running: true });
|
|
771
|
+
};
|
|
772
|
+
const handleMotionStatus = (res) => {
|
|
773
|
+
sendJson(res, 200, { ok: true, job: motionJob, worldMtimeMs: lastWrittenMtimeMs });
|
|
774
|
+
};
|
|
664
775
|
/**
|
|
665
776
|
* One F9 gameplay frame from the recorder in the game iframe — the one caller of this server
|
|
666
777
|
* that is NOT the shell. Request shape and the `~/Downloads/<name>/` layout mirror
|
|
@@ -986,6 +1097,14 @@ export async function startEditorServer(options) {
|
|
|
986
1097
|
handleHqStatus(res);
|
|
987
1098
|
return;
|
|
988
1099
|
}
|
|
1100
|
+
if (req.method === 'POST' && url.startsWith('/api/editor/motion-video')) {
|
|
1101
|
+
await handleMotionVideo(req, res);
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
if (req.method === 'GET' && url === '/api/editor/motion-status') {
|
|
1105
|
+
handleMotionStatus(res);
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
989
1108
|
if (req.method === 'POST' && url === '/api/editor/terrain-saved') {
|
|
990
1109
|
await handleTerrainSaved(req, res);
|
|
991
1110
|
return;
|