@bitmagic/cli 0.1.59 → 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.
@@ -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&ndash;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
- + '&autoTrim=' + (motionFromRecording ? '1' : '0');
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmC/B,CAAC;AAEF,MAAM,CAAC,MAAM,iBAAiB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8ChC,CAAC;AAEF;;;GAGG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0U9B,CAAC"}
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 (recorded clips; not file picks). */
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
- if (request.autoTrim)
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":"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"}
1
+ {"version":3,"file":"motion-sink.js","sourceRoot":"","sources":["../../src/editor/motion-sink.ts"],"names":[],"mappings":"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"}
@@ -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 { MOTION_BODY_LIMIT_BYTES, motionCommand, motionDoneMessage, parseMotionVideoRequest } from './motion-sink.js';
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;