@bitmagic/cli 0.1.60-dev.0 → 0.1.60-dev.1
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 +11 -4
- package/dist/commands/generate.d.ts +2 -0
- package/dist/commands/generate.js +28 -1
- package/dist/commands/generate.js.map +1 -1
- package/dist/editor/animation-preview-page.js +1 -1
- package/dist/editor/motion-panel.d.ts +3 -3
- package/dist/editor/motion-panel.js +227 -2
- package/dist/editor/motion-panel.js.map +1 -1
- package/dist/editor/motion-sink.d.ts +10 -2
- package/dist/editor/motion-sink.js +27 -2
- package/dist/editor/motion-sink.js.map +1 -1
- package/dist/editor/server.js +100 -1
- package/dist/editor/server.js.map +1 -1
- package/dist/generate/animation-from-video.d.ts +12 -0
- package/dist/generate/animation-from-video.js +15 -6
- package/dist/generate/animation-from-video.js.map +1 -1
- package/dist/generate/trim-analysis.d.ts +22 -0
- package/dist/generate/trim-analysis.js +31 -0
- package/dist/generate/trim-analysis.js.map +1 -0
- package/package.json +4 -4
|
@@ -32,6 +32,19 @@ export const MOTION_PANEL_CSS = `
|
|
|
32
32
|
#motion-hint { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
|
33
33
|
text-align: center; padding: 20px; font-size: 13px; color: var(--bm-text-muted); }
|
|
34
34
|
#motion-hint.hidden { display: none; }
|
|
35
|
+
#motion-trim { display: none; margin-top: 10px; }
|
|
36
|
+
#motion-trim.show { display: block; }
|
|
37
|
+
#motion-trimbar { position: relative; height: 14px; border-radius: 7px; background: var(--bm-surface-alt);
|
|
38
|
+
touch-action: none; }
|
|
39
|
+
#motion-trim-kept { position: absolute; top: 0; bottom: 0; border-radius: 7px; background: var(--bm-aqua); }
|
|
40
|
+
#motion-trim-playhead { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--bm-pink);
|
|
41
|
+
pointer-events: none; }
|
|
42
|
+
.motion-trim-handle { position: absolute; top: 50%; width: 14px; height: 22px;
|
|
43
|
+
transform: translate(-50%, -50%); border-radius: 5px; background: var(--bm-text);
|
|
44
|
+
cursor: ew-resize; touch-action: none; }
|
|
45
|
+
#motion-trim-row { display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
|
46
|
+
margin-top: 6px; min-height: 24px; }
|
|
47
|
+
#motion-trim-note { font-size: 11px; color: var(--bm-text-muted); }
|
|
35
48
|
#motion-sources { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
|
36
49
|
#motion-file { display: none; }
|
|
37
50
|
#motion-guidance { margin: 10px 0 0; font-size: 12px; color: var(--bm-text-muted); }
|
|
@@ -59,6 +72,18 @@ export const MOTION_PANEL_HTML = ` <div id="motion-overlay" class="bm-overlay">
|
|
|
59
72
|
<div id="motion-hint">Whole body in frame, one person, plain background, good light.
|
|
60
73
|
2–60 seconds.</div>
|
|
61
74
|
</div>
|
|
75
|
+
<div id="motion-trim">
|
|
76
|
+
<div id="motion-trimbar" title="Drag the handles to choose what the animation is made from">
|
|
77
|
+
<div id="motion-trim-kept"></div>
|
|
78
|
+
<div id="motion-trim-playhead"></div>
|
|
79
|
+
<div id="motion-trim-start" class="motion-trim-handle"></div>
|
|
80
|
+
<div id="motion-trim-end" class="motion-trim-handle"></div>
|
|
81
|
+
</div>
|
|
82
|
+
<div id="motion-trim-row">
|
|
83
|
+
<span id="motion-trim-note"></span>
|
|
84
|
+
<button id="motion-trim-full" class="bm-pill bm-pill--ghost" type="button" hidden>Use full clip</button>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
62
87
|
<div id="motion-sources">
|
|
63
88
|
<button id="motion-record" class="bm-pill bm-pill--primary" type="button">Record with webcam</button>
|
|
64
89
|
<button id="motion-stop" class="bm-pill" type="button" hidden>Stop</button>
|
|
@@ -134,6 +159,168 @@ export const MOTION_PANEL_JS = `
|
|
|
134
159
|
var motionTickTimer = null;
|
|
135
160
|
var motionStartedAt = 0;
|
|
136
161
|
var motionObjectUrl = null;
|
|
162
|
+
// ---- Trim window: seeded by the sidecar's trim analysis, adjusted on the handles ------------
|
|
163
|
+
var motionTrimWrap = document.getElementById('motion-trim');
|
|
164
|
+
var motionTrimBar = document.getElementById('motion-trimbar');
|
|
165
|
+
var motionTrimKept = document.getElementById('motion-trim-kept');
|
|
166
|
+
var motionTrimPlayhead = document.getElementById('motion-trim-playhead');
|
|
167
|
+
var motionTrimStartHandle = document.getElementById('motion-trim-start');
|
|
168
|
+
var motionTrimEndHandle = document.getElementById('motion-trim-end');
|
|
169
|
+
var motionTrimNote = document.getElementById('motion-trim-note');
|
|
170
|
+
var motionTrimFullBtn = document.getElementById('motion-trim-full');
|
|
171
|
+
var MOTION_TRIM_MIN_KEPT_S = 2;
|
|
172
|
+
var motionTrimDuration = 0;
|
|
173
|
+
var motionTrimStart = 0;
|
|
174
|
+
var motionTrimEnd = 0;
|
|
175
|
+
var motionPendingId = null;
|
|
176
|
+
var motionAnalysisState = 'idle';
|
|
177
|
+
var motionAnalysisToken = 0;
|
|
178
|
+
var motionAnalyzePollTimer = null;
|
|
179
|
+
|
|
180
|
+
function motionHasTrim() {
|
|
181
|
+
return motionTrimDuration > 0 && motionTrimEnd > motionTrimStart
|
|
182
|
+
&& (motionTrimStart >= 0.1 || motionTrimDuration - motionTrimEnd >= 0.1);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function renderMotionTrim() {
|
|
186
|
+
if (motionTrimDuration <= 0) { motionTrimWrap.classList.remove('show'); return; }
|
|
187
|
+
motionTrimWrap.classList.add('show');
|
|
188
|
+
var pct = function (seconds) {
|
|
189
|
+
return Math.min(Math.max(seconds / motionTrimDuration, 0), 1) * 100 + '%';
|
|
190
|
+
};
|
|
191
|
+
motionTrimKept.style.left = pct(motionTrimStart);
|
|
192
|
+
motionTrimKept.style.width = Math.max(0, (motionTrimEnd - motionTrimStart) / motionTrimDuration) * 100 + '%';
|
|
193
|
+
motionTrimStartHandle.style.left = pct(motionTrimStart);
|
|
194
|
+
motionTrimEndHandle.style.left = pct(motionTrimEnd);
|
|
195
|
+
motionTrimFullBtn.hidden = !motionHasTrim();
|
|
196
|
+
var note;
|
|
197
|
+
if (motionAnalysisState === 'running') {
|
|
198
|
+
note = 'Analyzing the clip for a good trim\u2026';
|
|
199
|
+
} else if (!motionHasTrim()) {
|
|
200
|
+
note = 'Using the full clip.';
|
|
201
|
+
} else {
|
|
202
|
+
var parts = [];
|
|
203
|
+
if (motionTrimStart >= 0.1) parts.push(motionTrimStart.toFixed(1) + 's from the start');
|
|
204
|
+
if (motionTrimDuration - motionTrimEnd >= 0.1) parts.push((motionTrimDuration - motionTrimEnd).toFixed(1) + 's from the end');
|
|
205
|
+
note = 'Trimming ' + parts.join(' and ') + '.';
|
|
206
|
+
}
|
|
207
|
+
motionTrimNote.textContent = note;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function resetMotionTrim() {
|
|
211
|
+
motionAnalysisToken++;
|
|
212
|
+
if (motionAnalyzePollTimer) { clearTimeout(motionAnalyzePollTimer); motionAnalyzePollTimer = null; }
|
|
213
|
+
motionPendingId = null;
|
|
214
|
+
motionAnalysisState = 'idle';
|
|
215
|
+
motionTrimDuration = 0;
|
|
216
|
+
motionTrimStart = 0;
|
|
217
|
+
motionTrimEnd = 0;
|
|
218
|
+
motionTrimWrap.classList.remove('show');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function seedMotionTrim(durationSeconds) {
|
|
222
|
+
if (!(durationSeconds > 0)) return;
|
|
223
|
+
motionTrimDuration = durationSeconds;
|
|
224
|
+
motionTrimStart = 0;
|
|
225
|
+
motionTrimEnd = durationSeconds;
|
|
226
|
+
renderMotionTrim();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function applyMotionSuggestion(suggestion) {
|
|
230
|
+
if (!suggestion) { renderMotionTrim(); return; }
|
|
231
|
+
if (suggestion.durationSeconds > 0) {
|
|
232
|
+
motionTrimDuration = suggestion.durationSeconds;
|
|
233
|
+
motionTrimStart = 0;
|
|
234
|
+
motionTrimEnd = suggestion.durationSeconds;
|
|
235
|
+
}
|
|
236
|
+
if (suggestion.confidence === 'high'
|
|
237
|
+
&& typeof suggestion.suggestedStartSeconds === 'number'
|
|
238
|
+
&& typeof suggestion.suggestedEndSeconds === 'number'
|
|
239
|
+
&& suggestion.suggestedEndSeconds > suggestion.suggestedStartSeconds) {
|
|
240
|
+
motionTrimStart = suggestion.suggestedStartSeconds;
|
|
241
|
+
motionTrimEnd = Math.min(suggestion.suggestedEndSeconds, motionTrimDuration || suggestion.suggestedEndSeconds);
|
|
242
|
+
}
|
|
243
|
+
renderMotionTrim();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function beginMotionTrimDrag(which, event) {
|
|
247
|
+
if (motionTrimDuration <= 0) return;
|
|
248
|
+
var handle = event.currentTarget;
|
|
249
|
+
handle.setPointerCapture(event.pointerId);
|
|
250
|
+
var move = function (e) {
|
|
251
|
+
var rect = motionTrimBar.getBoundingClientRect();
|
|
252
|
+
if (rect.width === 0) return;
|
|
253
|
+
var at = Math.min(Math.max((e.clientX - rect.left) / rect.width, 0), 1) * motionTrimDuration;
|
|
254
|
+
if (which === 'start') motionTrimStart = Math.min(at, motionTrimEnd - MOTION_TRIM_MIN_KEPT_S);
|
|
255
|
+
else motionTrimEnd = Math.max(at, motionTrimStart + MOTION_TRIM_MIN_KEPT_S);
|
|
256
|
+
renderMotionTrim();
|
|
257
|
+
};
|
|
258
|
+
var up = function () {
|
|
259
|
+
handle.removeEventListener('pointermove', move);
|
|
260
|
+
handle.removeEventListener('pointerup', up);
|
|
261
|
+
handle.removeEventListener('pointercancel', up);
|
|
262
|
+
};
|
|
263
|
+
handle.addEventListener('pointermove', move);
|
|
264
|
+
handle.addEventListener('pointerup', up);
|
|
265
|
+
handle.addEventListener('pointercancel', up);
|
|
266
|
+
event.preventDefault();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function pollMotionAnalysis(token) {
|
|
270
|
+
var tick = async function () {
|
|
271
|
+
if (token !== motionAnalysisToken) return;
|
|
272
|
+
var payload;
|
|
273
|
+
try {
|
|
274
|
+
payload = await (await fetch('/api/editor/motion-analyze-status')).json();
|
|
275
|
+
} catch (error) {
|
|
276
|
+
motionAnalyzePollTimer = setTimeout(tick, 2000);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (token !== motionAnalysisToken) return;
|
|
280
|
+
if (payload && payload.status === 'done') {
|
|
281
|
+
motionAnalysisState = 'done';
|
|
282
|
+
applyMotionSuggestion(payload.suggestion);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (payload && payload.status === 'running') {
|
|
286
|
+
motionAnalyzePollTimer = setTimeout(tick, 2000);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// failed / unavailable / none: no suggestion, the full-range handles stand.
|
|
290
|
+
motionAnalysisState = 'failed';
|
|
291
|
+
renderMotionTrim();
|
|
292
|
+
};
|
|
293
|
+
motionAnalyzePollTimer = setTimeout(tick, 2000);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Hand the clip to the sidecar ahead of submission: it uploads once and runs the unbilled trim
|
|
298
|
+
* analysis while the creator reviews. Degraded, never blocking — any failure just leaves the
|
|
299
|
+
* handles at full range, and Submit works throughout.
|
|
300
|
+
*/
|
|
301
|
+
async function startMotionAnalysis(blob) {
|
|
302
|
+
var token = ++motionAnalysisToken;
|
|
303
|
+
motionPendingId = null;
|
|
304
|
+
motionAnalysisState = 'running';
|
|
305
|
+
renderMotionTrim();
|
|
306
|
+
var result = null;
|
|
307
|
+
try {
|
|
308
|
+
var response = await fetch('/api/editor/motion-analyze', {
|
|
309
|
+
method: 'POST',
|
|
310
|
+
headers: { 'Content-Type': blob.type || 'video/webm' },
|
|
311
|
+
body: blob
|
|
312
|
+
});
|
|
313
|
+
result = await response.json();
|
|
314
|
+
} catch (error) { /* the sidecar said nothing; treat as unavailable */ }
|
|
315
|
+
if (token !== motionAnalysisToken) return;
|
|
316
|
+
if (!result || result.ok !== true || result.analysing !== true) {
|
|
317
|
+
motionAnalysisState = 'idle';
|
|
318
|
+
renderMotionTrim();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
motionPendingId = result.pendingId || null;
|
|
322
|
+
pollMotionAnalysis(token);
|
|
323
|
+
}
|
|
137
324
|
|
|
138
325
|
function motionSupported() {
|
|
139
326
|
return !!(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia
|
|
@@ -201,6 +388,7 @@ export const MOTION_PANEL_JS = `
|
|
|
201
388
|
}
|
|
202
389
|
|
|
203
390
|
function resetMotionPanel() {
|
|
391
|
+
resetMotionTrim();
|
|
204
392
|
stopMotionStream();
|
|
205
393
|
discardMotionRecorder();
|
|
206
394
|
motionRecorder = null;
|
|
@@ -258,6 +446,17 @@ export const MOTION_PANEL_JS = `
|
|
|
258
446
|
: note;
|
|
259
447
|
setMotionButtons('ready');
|
|
260
448
|
motionSubmit.disabled = !ok;
|
|
449
|
+
resetMotionTrim();
|
|
450
|
+
if (ok) {
|
|
451
|
+
seedMotionTrim(seconds);
|
|
452
|
+
// A fresh WebM often reports no duration; the preview's metadata is the fallback scale.
|
|
453
|
+
motionPreview.onloadedmetadata = function () {
|
|
454
|
+
if (motionTrimDuration === 0 && isFinite(motionPreview.duration) && motionPreview.duration > 0) {
|
|
455
|
+
seedMotionTrim(motionPreview.duration);
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
void startMotionAnalysis(blob);
|
|
459
|
+
}
|
|
261
460
|
}
|
|
262
461
|
|
|
263
462
|
async function startMotionRecording() {
|
|
@@ -382,10 +581,19 @@ export const MOTION_PANEL_JS = `
|
|
|
382
581
|
if (!motionBlob) return;
|
|
383
582
|
var name = motionName.value.trim();
|
|
384
583
|
if (name === '') { motionName.focus(); return; }
|
|
584
|
+
// Whatever the handles show is what gets made; a still-running analysis must not move them.
|
|
585
|
+
motionAnalysisToken++;
|
|
586
|
+
if (motionAnalyzePollTimer) { clearTimeout(motionAnalyzePollTimer); motionAnalyzePollTimer = null; }
|
|
385
587
|
var query = '?name=' + encodeURIComponent(name)
|
|
386
588
|
+ '&slot=' + encodeURIComponent(motionSlot.value)
|
|
387
|
-
+ '&loop=' + (motionLoop.checked ? '1' : '0')
|
|
388
|
-
|
|
589
|
+
+ '&loop=' + (motionLoop.checked ? '1' : '0');
|
|
590
|
+
if (motionHasTrim()) {
|
|
591
|
+
query += '&trimStart=' + encodeURIComponent(String(Math.round(motionTrimStart * 100) / 100))
|
|
592
|
+
+ '&trimEnd=' + encodeURIComponent(String(Math.round(motionTrimEnd * 100) / 100));
|
|
593
|
+
}
|
|
594
|
+
if (motionPendingId) {
|
|
595
|
+
query += '&pendingId=' + encodeURIComponent(motionPendingId);
|
|
596
|
+
}
|
|
389
597
|
motionSubmit.disabled = true;
|
|
390
598
|
showMotionNote('Uploading the video…', '');
|
|
391
599
|
var response, result;
|
|
@@ -428,5 +636,22 @@ export const MOTION_PANEL_JS = `
|
|
|
428
636
|
motionPickBtn.addEventListener('click', function () { motionFile.value = ''; motionFile.click(); });
|
|
429
637
|
motionFile.addEventListener('change', function () { pickMotionFile(motionFile.files && motionFile.files[0]); });
|
|
430
638
|
motionSubmit.addEventListener('click', function () { void submitMotion(); });
|
|
639
|
+
motionTrimStartHandle.addEventListener('pointerdown', function (event) { beginMotionTrimDrag('start', event); });
|
|
640
|
+
motionTrimEndHandle.addEventListener('pointerdown', function (event) { beginMotionTrimDrag('end', event); });
|
|
641
|
+
motionTrimFullBtn.addEventListener('click', function () {
|
|
642
|
+
motionTrimStart = 0;
|
|
643
|
+
motionTrimEnd = motionTrimDuration;
|
|
644
|
+
renderMotionTrim();
|
|
645
|
+
});
|
|
646
|
+
// Review playback stays inside the kept range, so the creator sees what gets made.
|
|
647
|
+
motionPreview.addEventListener('timeupdate', function () {
|
|
648
|
+
if (motionTrimDuration > 0) {
|
|
649
|
+
motionTrimPlayhead.style.left = Math.min(Math.max(motionPreview.currentTime / motionTrimDuration, 0), 1) * 100 + '%';
|
|
650
|
+
}
|
|
651
|
+
if (!motionBlob || !motionHasTrim()) return;
|
|
652
|
+
if (motionPreview.currentTime >= motionTrimEnd || motionPreview.currentTime < motionTrimStart - 0.25) {
|
|
653
|
+
motionPreview.currentTime = motionTrimStart;
|
|
654
|
+
}
|
|
655
|
+
});
|
|
431
656
|
`;
|
|
432
657
|
//# sourceMappingURL=motion-panel.js.map
|
|
@@ -1 +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
|
|
1
|
+
{"version":3,"file":"motion-panel.js","sourceRoot":"","sources":["../../src/editor/motion-panel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,MAAM,CAAC,MAAM,gBAAgB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgD/B,CAAC;AAEF,MAAM,CAAC,MAAM,iBAAiB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0DhC,CAAC;AAEF;;;GAGG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkhB9B,CAAC"}
|
|
@@ -17,8 +17,16 @@ export interface MotionVideoRequest {
|
|
|
17
17
|
name: string;
|
|
18
18
|
locomotionState?: LocomotionState;
|
|
19
19
|
loop?: boolean;
|
|
20
|
-
/** Cut walk-to-the-camera frames off both ends
|
|
20
|
+
/** Cut walk-to-the-camera frames off both ends. Legacy clients only; the panel now sends an explicit window. */
|
|
21
21
|
autoTrim?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Explicit trim window (seconds into the source clip), both or neither — the
|
|
24
|
+
* window the creator confirmed on the panel's trim bar. Wins over autoTrim.
|
|
25
|
+
*/
|
|
26
|
+
trimStartSeconds?: number;
|
|
27
|
+
trimEndSeconds?: number;
|
|
28
|
+
/** The sidecar's pending analysis clip; a match reuses its already-uploaded URL. */
|
|
29
|
+
pendingId?: string;
|
|
22
30
|
}
|
|
23
31
|
export interface MotionVideoRefusal {
|
|
24
32
|
status: 400 | 413 | 415;
|
|
@@ -44,4 +52,4 @@ export declare function motionDoneMessage(result: {
|
|
|
44
52
|
trimmedEndSeconds?: number;
|
|
45
53
|
}): string;
|
|
46
54
|
/** The command the panel prints when nobody is logged in — what the agent (or the creator) runs instead. */
|
|
47
|
-
export declare function motionCommand(videoPath: string, request: Pick<MotionVideoRequest, 'name' | 'locomotionState' | 'loop' | 'autoTrim'>): string;
|
|
55
|
+
export declare function motionCommand(videoPath: string, request: Pick<MotionVideoRequest, 'name' | 'locomotionState' | 'loop' | 'autoTrim' | 'trimStartSeconds' | 'trimEndSeconds'>): string;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* because the sidecar has no multipart parser beyond the recorder's frame sink, and a single
|
|
9
9
|
* binary body is the simplest thing both sides get right.
|
|
10
10
|
*/
|
|
11
|
-
import { LOCOMOTION_STATES, describeAutoTrim } from '@bitmagic/asset-core';
|
|
11
|
+
import { LOCOMOTION_STATES, MIN_TRIM_KEPT_SECONDS, describeAutoTrim } from '@bitmagic/asset-core';
|
|
12
12
|
/** Matches the CLI lane's own cap (`generate/animation-from-video.ts`) and asset-forger's. */
|
|
13
13
|
export const MOTION_BODY_LIMIT_BYTES = 200 * 1024 * 1024;
|
|
14
14
|
/** Content types a browser's MediaRecorder or a file picker hands over, and the extension each becomes. */
|
|
@@ -47,6 +47,24 @@ export function parseMotionVideoRequest(input) {
|
|
|
47
47
|
}
|
|
48
48
|
const loop = input.query.get('loop');
|
|
49
49
|
const autoTrim = input.query.get('autoTrim');
|
|
50
|
+
const trimStartRaw = input.query.get('trimStart');
|
|
51
|
+
const trimEndRaw = input.query.get('trimEnd');
|
|
52
|
+
if ((trimStartRaw === null) !== (trimEndRaw === null)) {
|
|
53
|
+
return { status: 400, error: 'trimStart and trimEnd must be given together.' };
|
|
54
|
+
}
|
|
55
|
+
let trimWindow = null;
|
|
56
|
+
if (trimStartRaw !== null && trimEndRaw !== null) {
|
|
57
|
+
const trimStart = Number(trimStartRaw);
|
|
58
|
+
const trimEnd = Number(trimEndRaw);
|
|
59
|
+
if (!Number.isFinite(trimStart) || !Number.isFinite(trimEnd) || trimStart < 0) {
|
|
60
|
+
return { status: 400, error: 'trimStart and trimEnd must be non-negative seconds into the clip.' };
|
|
61
|
+
}
|
|
62
|
+
if (trimEnd - trimStart < MIN_TRIM_KEPT_SECONDS) {
|
|
63
|
+
return { status: 400, error: `trimStart/trimEnd must keep at least ${MIN_TRIM_KEPT_SECONDS} seconds of the clip.` };
|
|
64
|
+
}
|
|
65
|
+
trimWindow = { trimStartSeconds: trimStart, trimEndSeconds: trimEnd };
|
|
66
|
+
}
|
|
67
|
+
const pendingId = input.query.get('pendingId');
|
|
50
68
|
return {
|
|
51
69
|
extension,
|
|
52
70
|
contentType,
|
|
@@ -54,6 +72,8 @@ export function parseMotionVideoRequest(input) {
|
|
|
54
72
|
...(slot !== null && slot !== '' ? { locomotionState: slot } : {}),
|
|
55
73
|
...(loop === '1' || loop === 'true' ? { loop: true } : {}),
|
|
56
74
|
...(autoTrim === '1' || autoTrim === 'true' ? { autoTrim: true } : {}),
|
|
75
|
+
...(trimWindow ?? {}),
|
|
76
|
+
...(pendingId !== null && pendingId !== '' ? { pendingId } : {}),
|
|
57
77
|
};
|
|
58
78
|
}
|
|
59
79
|
/**
|
|
@@ -72,8 +92,13 @@ export function motionCommand(videoPath, request) {
|
|
|
72
92
|
parts.push(`--slot ${request.locomotionState}`);
|
|
73
93
|
if (request.loop)
|
|
74
94
|
parts.push('--loop');
|
|
75
|
-
|
|
95
|
+
// The hand-off must reproduce the creator's confirmed window; --auto-trim only without one.
|
|
96
|
+
if (request.trimStartSeconds !== undefined && request.trimEndSeconds !== undefined) {
|
|
97
|
+
parts.push(`--trim-start ${request.trimStartSeconds}`, `--trim-end ${request.trimEndSeconds}`);
|
|
98
|
+
}
|
|
99
|
+
else if (request.autoTrim) {
|
|
76
100
|
parts.push('--auto-trim');
|
|
101
|
+
}
|
|
77
102
|
return parts.join(' ');
|
|
78
103
|
}
|
|
79
104
|
//# sourceMappingURL=motion-sink.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"motion-sink.js","sourceRoot":"","sources":["../../src/editor/motion-sink.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAwB,MAAM,sBAAsB,CAAC;
|
|
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
|
@@ -39,7 +39,9 @@ import { jobsDirPath, readJobLayout, readRunningJobs, withJobRecord } from '../p
|
|
|
39
39
|
import { listPreviews, readPreview, removePreview, writePreview } from './previews.js';
|
|
40
40
|
import { findPropAsset, generateAndInstallProp } from '../generate/prop.js';
|
|
41
41
|
import { generateAndInstallAnimationFromVideo } from '../generate/animation-from-video.js';
|
|
42
|
-
import {
|
|
42
|
+
import { analyseTrimForVideo } from '../generate/trim-analysis.js';
|
|
43
|
+
import { uploadProjectFile } from '../http/signed-upload.js';
|
|
44
|
+
import { MOTION_BODY_LIMIT_BYTES, baseContentType, motionCommand, motionDoneMessage, parseMotionVideoRequest } from './motion-sink.js';
|
|
43
45
|
import { resolveEnvironmentForCommand, resolveAuth0ClientId } from '../config/environments.js';
|
|
44
46
|
import { getAccessToken } from '../auth/session.js';
|
|
45
47
|
import { startUploadProxy } from '../forge/upload-proxy.js';
|
|
@@ -287,6 +289,13 @@ export async function startEditorServer(options) {
|
|
|
287
289
|
let hqJob = null;
|
|
288
290
|
/** The Record-motion panel's job, one at a time for the same reason as `hqJob`. */
|
|
289
291
|
let motionJob = null;
|
|
292
|
+
/**
|
|
293
|
+
* The clip the panel just recorded, uploaded ahead of submission so the trim
|
|
294
|
+
* analysis can run while the creator reviews. One at a time, like motionJob;
|
|
295
|
+
* a new clip replaces it. `videoUrl` doubles as the submit path's upload —
|
|
296
|
+
* a matching pendingId skips the second upload.
|
|
297
|
+
*/
|
|
298
|
+
let pendingMotion = null;
|
|
290
299
|
/**
|
|
291
300
|
* A token, or null when nobody is logged in. Never starts a device-code flow.
|
|
292
301
|
*
|
|
@@ -674,6 +683,79 @@ export async function startEditorServer(options) {
|
|
|
674
683
|
const handleHqStatus = (res) => {
|
|
675
684
|
sendJson(res, 200, { ok: true, job: hqJob, worldMtimeMs: lastWrittenMtimeMs });
|
|
676
685
|
};
|
|
686
|
+
/**
|
|
687
|
+
* `POST /api/editor/motion-analyze` — the clip the panel just accepted, as raw bytes, ahead of
|
|
688
|
+
* submission. Logged in: upload it through the signed-URL lane and run the Forger's unbilled
|
|
689
|
+
* trim analysis in the background; the panel polls `motion-analyze-status` and seeds its trim
|
|
690
|
+
* handles from the suggestion. Logged out: nothing to run — the panel keeps full-range handles.
|
|
691
|
+
* Degraded, never blocking: an analysis failure only means no suggestion.
|
|
692
|
+
*/
|
|
693
|
+
const handleMotionAnalyze = async (req, res) => {
|
|
694
|
+
const contentType = baseContentType(req.headers['content-type']);
|
|
695
|
+
if (!contentType.startsWith('video/')) {
|
|
696
|
+
sendJson(res, 415, { ok: false, error: `Expected a video body; got ${JSON.stringify(contentType || 'nothing')}.` });
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
let body;
|
|
700
|
+
try {
|
|
701
|
+
body = await readBodyBuffer(req, MOTION_BODY_LIMIT_BYTES);
|
|
702
|
+
}
|
|
703
|
+
catch {
|
|
704
|
+
sendJson(res, 413, { ok: false, error: `The video is over ${MOTION_BODY_LIMIT_BYTES / 1024 / 1024} MB.` });
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
if (body.length === 0) {
|
|
708
|
+
sendJson(res, 400, { ok: false, error: 'The video is empty.' });
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const auth = await resolveAuth();
|
|
712
|
+
if (auth === null) {
|
|
713
|
+
pendingMotion = null;
|
|
714
|
+
sendJson(res, 200, { ok: true, analysing: false });
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
const id = `pm-${Date.now()}`;
|
|
718
|
+
const pending = { id, videoUrl: null, analysis: 'running', suggestion: null };
|
|
719
|
+
pendingMotion = pending;
|
|
720
|
+
sendJson(res, 200, { ok: true, analysing: true, pendingId: id });
|
|
721
|
+
void (async () => {
|
|
722
|
+
const extension = contentType === 'video/mp4' ? 'mp4'
|
|
723
|
+
: contentType === 'video/quicktime' ? 'mov'
|
|
724
|
+
: contentType === 'video/x-msvideo' ? 'avi' : 'webm';
|
|
725
|
+
const uploaded = await uploadProjectFile(auth.environment, auth.token, {
|
|
726
|
+
gameId: options.metadata.gameId,
|
|
727
|
+
filename: `motion-${Date.now()}.${extension}`,
|
|
728
|
+
contentType,
|
|
729
|
+
bytes: new Uint8Array(body),
|
|
730
|
+
}, { fetch: globalThis.fetch });
|
|
731
|
+
pending.videoUrl = uploaded.url;
|
|
732
|
+
const suggestion = await analyseTrimForVideo({
|
|
733
|
+
environment: auth.environment,
|
|
734
|
+
token: auth.token,
|
|
735
|
+
gameId: options.metadata.gameId,
|
|
736
|
+
videoUrl: uploaded.url,
|
|
737
|
+
deps: { fetch: globalThis.fetch, sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) },
|
|
738
|
+
});
|
|
739
|
+
if (pendingMotion === pending) {
|
|
740
|
+
pending.suggestion = suggestion;
|
|
741
|
+
pending.analysis = 'done';
|
|
742
|
+
}
|
|
743
|
+
})().catch((error) => {
|
|
744
|
+
log(`[editor] trim analysis failed: ${messageOf(error)}`);
|
|
745
|
+
if (pendingMotion === pending)
|
|
746
|
+
pending.analysis = 'failed';
|
|
747
|
+
});
|
|
748
|
+
};
|
|
749
|
+
const handleMotionAnalyzeStatus = (res) => {
|
|
750
|
+
sendJson(res, 200, {
|
|
751
|
+
ok: true,
|
|
752
|
+
...(pendingMotion === null ? { status: 'none' } : {
|
|
753
|
+
status: pendingMotion.analysis,
|
|
754
|
+
pendingId: pendingMotion.id,
|
|
755
|
+
...(pendingMotion.suggestion !== null ? { suggestion: pendingMotion.suggestion } : {}),
|
|
756
|
+
}),
|
|
757
|
+
});
|
|
758
|
+
};
|
|
677
759
|
/**
|
|
678
760
|
* `POST /api/editor/motion-video` — the Record-motion panel's recording, as raw bytes.
|
|
679
761
|
*
|
|
@@ -734,12 +816,21 @@ export async function startEditorServer(options) {
|
|
|
734
816
|
});
|
|
735
817
|
return;
|
|
736
818
|
}
|
|
819
|
+
// The analysis step already uploaded this exact clip; a matching pendingId
|
|
820
|
+
// hands its URL over so the clip is not uploaded to storage twice.
|
|
821
|
+
const pendingUrl = parsed.pendingId !== undefined && pendingMotion?.id === parsed.pendingId
|
|
822
|
+
? pendingMotion.videoUrl
|
|
823
|
+
: null;
|
|
737
824
|
const run = {
|
|
738
825
|
videoPath,
|
|
826
|
+
...(pendingUrl !== null ? { videoUrl: pendingUrl } : {}),
|
|
739
827
|
name: parsed.name,
|
|
740
828
|
...(parsed.loop !== undefined ? { loop: parsed.loop } : {}),
|
|
741
829
|
...(parsed.locomotionState !== undefined ? { locomotionState: parsed.locomotionState } : {}),
|
|
742
830
|
...(parsed.autoTrim !== undefined ? { autoTrim: parsed.autoTrim } : {}),
|
|
831
|
+
...(parsed.trimStartSeconds !== undefined && parsed.trimEndSeconds !== undefined
|
|
832
|
+
? { trimStartSeconds: parsed.trimStartSeconds, trimEndSeconds: parsed.trimEndSeconds }
|
|
833
|
+
: {}),
|
|
743
834
|
};
|
|
744
835
|
motionJob = { name: parsed.name, status: 'running', message: 'Uploading the video…' };
|
|
745
836
|
journal.append({ event: 'motion.started', name: parsed.name, ...(run.locomotionState ? { locomotionState: run.locomotionState } : {}) });
|
|
@@ -1135,6 +1226,14 @@ export async function startEditorServer(options) {
|
|
|
1135
1226
|
await handleMotionVideo(req, res);
|
|
1136
1227
|
return;
|
|
1137
1228
|
}
|
|
1229
|
+
if (req.method === 'POST' && url === '/api/editor/motion-analyze') {
|
|
1230
|
+
await handleMotionAnalyze(req, res);
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
if (req.method === 'GET' && url === '/api/editor/motion-analyze-status') {
|
|
1234
|
+
handleMotionAnalyzeStatus(res);
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1138
1237
|
if (req.method === 'GET' && url === '/api/editor/motion-status') {
|
|
1139
1238
|
handleMotionStatus(res);
|
|
1140
1239
|
return;
|