vivid-editor-core 0.2.0 → 0.3.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.
@@ -156,7 +156,11 @@ export interface DetachAudioCommand {
156
156
  * `audioClipId` — id of an audio clip already in timeline. Must have a
157
157
  * cached BeatAnalysis (otherwise the orchestrator returns an error).
158
158
  *
159
- * `beatInterval` — how many beats between cuts. 4 = 1 cut per measure.
159
+ * `beatInterval` — how many beats (or peaks, with grid 'peaks') between
160
+ * cuts. 4 = 1 cut per measure.
161
+ *
162
+ * `grid` — 'beats' (default, constant BPM grid) or 'peaks' (detected
163
+ * transients from the server analysis; `minStrength` filters weak ones).
160
164
  *
161
165
  * `mode`:
162
166
  * - "redistribute" (default): take all visual clips, slice them up so
@@ -177,6 +181,15 @@ export interface CutToBeatCommand {
177
181
  beatInterval: number;
178
182
  mode?: 'redistribute' | 'snap';
179
183
  syncClips?: 'all' | string[];
184
+ /**
185
+ * Which grid to cut on. 'beats' (default) = the constant BPM grid.
186
+ * 'peaks' = detected transients (hits, accents) — cuts land on what the
187
+ * ear actually hears, also on music without a steady tempo or on voice.
188
+ * Needs `peaks` in the BeatAnalysis (server-side analysis).
189
+ */
190
+ grid?: 'beats' | 'peaks';
191
+ /** grid 'peaks' only: ignore peaks weaker than this (0–1, default 0). */
192
+ minStrength?: number;
180
193
  };
181
194
  }
182
195
  /** Set keyframe animations on one property of a canvas object or text overlay. */
@@ -309,9 +309,19 @@ export function executeAiCommands(commands, snapshot, actions) {
309
309
  }
310
310
  const analysis = snapshot.audioBeatsByAssetId?.[audioClip.assetId];
311
311
  if (!analysis || analysis === 'analyzing' || analysis === 'failed') {
312
- errors.push(`CUT_TO_BEAT: audio "${audioClip.name}" has no beat analysis (status: ${analysis ?? 'unknown'}). Wait a moment after dropping audio for analysis to complete.`);
312
+ errors.push(`CUT_TO_BEAT: audio "${audioClip.name}" has no beat analysis (status: ${analysis ?? 'unknown'}). In the browser wait a moment after dropping the audio; via the API run POST /api/ai/audio-analysis on the asset first.`);
313
313
  break;
314
314
  }
315
+ const grid = cmd.payload.grid ?? 'beats';
316
+ let gridMs = analysis.beats;
317
+ if (grid === 'peaks') {
318
+ if (!analysis.peaks || analysis.peaks.length === 0) {
319
+ errors.push(`CUT_TO_BEAT: audio "${audioClip.name}" has no peak analysis — peaks come from the server analysis (POST /api/ai/audio-analysis, mode 'peaks' or 'both'); use grid 'beats' or analyse the asset first.`);
320
+ break;
321
+ }
322
+ const minStrength = Math.max(0, Math.min(1, cmd.payload.minStrength ?? 0));
323
+ gridMs = analysis.peaks.filter((p) => p.strength >= minStrength).map((p) => p.ms).sort((a, b) => a - b);
324
+ }
315
325
  // Filter visual clips: those participating in the cut.
316
326
  const allVisualClips = snapshot.timelineClips.filter((c) => c.mediaType === 'video' || c.mediaType === 'image');
317
327
  const targetVisuals = syncClipsArg === 'all'
@@ -326,11 +336,11 @@ export function executeAiCommands(commands, snapshot, actions) {
326
336
  const audioEnd = audioClip.startMs + audioClip.durationMs;
327
337
  // Beats from analysis are offsets from start of source audio.
328
338
  // sourceOffsetMs accounts for trims at audio's head.
329
- const beatsAbsolute = analysis.beats
339
+ const beatsAbsolute = gridMs
330
340
  .map((bMs) => audioStart + (bMs - audioClip.sourceOffsetMs))
331
341
  .filter((t) => t >= audioStart && t <= audioEnd);
332
342
  if (beatsAbsolute.length < beatInterval + 1) {
333
- errors.push(`CUT_TO_BEAT: not enough beats (${beatsAbsolute.length}) for beatInterval ${beatInterval}`);
343
+ errors.push(`CUT_TO_BEAT: not enough ${grid} (${beatsAbsolute.length}) for beatInterval ${beatInterval}`);
334
344
  break;
335
345
  }
336
346
  // Cut points: every Nth beat, including audio start (first beat) and end.
@@ -338,6 +348,10 @@ export function executeAiCommands(commands, snapshot, actions) {
338
348
  for (let i = 0; i < beatsAbsolute.length; i += beatInterval) {
339
349
  cutPoints.push(beatsAbsolute[i]);
340
350
  }
351
+ // Pictures from the first note: the first beat is often a few ms in
352
+ // (60 ms on a MiniMax track), which left a black flash at the start.
353
+ if (mode === 'redistribute')
354
+ cutPoints[0] = audioStart;
341
355
  // Always end at audio end so the last segment fills the music.
342
356
  if (cutPoints[cutPoints.length - 1] < audioEnd - 50)
343
357
  cutPoints.push(audioEnd);
@@ -356,69 +370,70 @@ export function executeAiCommands(commands, snapshot, actions) {
356
370
  const newCanvasObjects = [];
357
371
  const trackId = targetVisuals[0].trackId;
358
372
  const now = Date.now();
359
- // Walk the tape with a moving cursor; for each cut interval,
360
- // peel off a segment of the tape equal to (next - current).
373
+ // Every cut interval gets exactly one piece of its full length, so
374
+ // nothing is left black between two cuts or before the end of the
375
+ // music. Walking the tape with a cursor:
376
+ // - the rest of the current segment covers the interval → take it;
377
+ // - it covers at least MIN_RATE of it → take it slowed down a little
378
+ // (a still image just stays on screen longer);
379
+ // - it is shorter → drop that leftover and try the next segment;
380
+ // - the tape runs out → start again from the first segment.
381
+ // If no segment can fill an interval even after a full loop, the
382
+ // longest one is slowed down as far as the player allows.
383
+ const MIN_RATE = 0.8;
384
+ const EPS = 1;
361
385
  let tapeIdx = 0;
362
- let tapeCursorInSegment = 0;
386
+ let cursor = 0;
387
+ const advance = () => { tapeIdx = (tapeIdx + 1) % tape.length; cursor = 0; };
388
+ const pick = (segmentMs) => {
389
+ for (let tries = 0; tries <= tape.length; tries++) {
390
+ const seg = tape[tapeIdx];
391
+ const avail = seg.durationMs - cursor;
392
+ const still = seg.sourceClip.mediaType === 'image';
393
+ if (avail >= segmentMs - EPS || (avail > EPS && (still || avail >= segmentMs * MIN_RATE))) {
394
+ const used = Math.min(avail, segmentMs);
395
+ const piece = {
396
+ seg,
397
+ offsetMs: seg.offsetMs + cursor,
398
+ rate: still || used >= segmentMs - EPS ? undefined : used / segmentMs,
399
+ };
400
+ cursor += used;
401
+ if (cursor >= seg.durationMs - EPS)
402
+ advance();
403
+ return piece;
404
+ }
405
+ advance();
406
+ }
407
+ const seg = tape.reduce((a, b) => (b.durationMs > a.durationMs ? b : a));
408
+ tapeIdx = tape.indexOf(seg);
409
+ advance();
410
+ return { seg, offsetMs: seg.offsetMs, rate: Math.max(0.25, seg.durationMs / segmentMs) };
411
+ };
363
412
  for (let i = 0; i < cutPoints.length - 1; i++) {
364
413
  const segmentMs = cutPoints[i + 1] - cutPoints[i];
365
414
  if (segmentMs <= 0)
366
415
  continue;
367
- // The piece we lay down references the current tape segment,
368
- // possibly clipping its tail if the tape segment is shorter
369
- // than the beat interval (then we step to next tape segment).
370
- let remaining = segmentMs;
371
- let placed = false;
372
- while (remaining > 0 && tapeIdx < tape.length) {
373
- const seg = tape[tapeIdx];
374
- const available = seg.durationMs - tapeCursorInSegment;
375
- if (available <= 0) {
376
- tapeIdx++;
377
- tapeCursorInSegment = 0;
378
- continue;
379
- }
380
- const take = Math.min(available, remaining);
381
- if (!placed) {
382
- // First piece of this beat segment — emit a clip starting at cutPoints[i].
383
- const newId = `clip-cb-${now}-${i}`;
384
- newClips.push({
385
- id: newId,
386
- assetId: seg.sourceClip.assetId,
387
- name: seg.sourceClip.name,
388
- url: seg.sourceClip.url,
389
- startMs: cutPoints[i],
390
- durationMs: take,
391
- originalDurationMs: seg.sourceClip.originalDurationMs,
392
- sourceOffsetMs: seg.offsetMs + tapeCursorInSegment,
393
- mediaType: seg.sourceClip.mediaType,
394
- trackId,
395
- });
396
- newCanvasObjects.push({
397
- id: `obj-${newId}`,
398
- clipId: newId,
399
- x: 0, y: 0, w: 1, h: 1,
400
- rotation: 0,
401
- });
402
- placed = true;
403
- }
404
- else {
405
- // Subsequent piece within the same beat — extend the
406
- // previous clip's duration if and only if it's the same
407
- // source asset; otherwise start a fresh clip nested in
408
- // the same beat. For simplicity, we only support a
409
- // single asset per beat segment (truncate trailing tape
410
- // and snap to next beat).
411
- break;
412
- }
413
- tapeCursorInSegment += take;
414
- remaining -= take;
415
- if (tapeCursorInSegment >= seg.durationMs) {
416
- tapeIdx++;
417
- tapeCursorInSegment = 0;
418
- }
419
- }
420
- if (tapeIdx >= tape.length)
421
- break; // tape exhausted, stop emitting
416
+ const { seg, offsetMs, rate } = pick(segmentMs);
417
+ const newId = `clip-cb-${now}-${i}`;
418
+ newClips.push({
419
+ id: newId,
420
+ assetId: seg.sourceClip.assetId,
421
+ name: seg.sourceClip.name,
422
+ url: seg.sourceClip.url,
423
+ startMs: cutPoints[i],
424
+ durationMs: segmentMs,
425
+ originalDurationMs: seg.sourceClip.originalDurationMs,
426
+ sourceOffsetMs: offsetMs,
427
+ mediaType: seg.sourceClip.mediaType,
428
+ trackId,
429
+ ...(rate !== undefined ? { playbackRate: rate } : {}),
430
+ });
431
+ newCanvasObjects.push({
432
+ id: `obj-${newId}`,
433
+ clipId: newId,
434
+ x: 0, y: 0, w: 1, h: 1,
435
+ rotation: 0,
436
+ });
422
437
  }
423
438
  // Preserve audio + non-target clips intact.
424
439
  const preservedClips = snapshot.timelineClips.filter((c) => c.mediaType === 'audio' || c.mediaType === 'color' ||
package/dist/store.d.ts CHANGED
@@ -262,4 +262,5 @@ export interface EditorState {
262
262
  reset: () => void;
263
263
  }
264
264
  export type EditorStore = StoreApi<EditorState>;
265
+ export declare function uniqueId(prefix: string): string;
265
266
  export declare function createEditorStore(deps?: EditorStoreDeps): EditorStore;
package/dist/store.js CHANGED
@@ -99,6 +99,17 @@ function timelineContentMs(clips) {
99
99
  function clamp(v, min, max) {
100
100
  return Math.max(min, Math.min(max, v));
101
101
  }
102
+ /**
103
+ * Timestamp-based ids collide when several clips are created in the same
104
+ * millisecond — which never happens from the UI but always does in a
105
+ * headless batch (vivid-mcp: ADD_CLIP × 3 in one call). The sequence suffix
106
+ * keeps them unique; the `clip-`/`obj-` prefixes stay as before.
107
+ */
108
+ let idSeq = 0;
109
+ export function uniqueId(prefix) {
110
+ idSeq = (idSeq + 1) % 1_000_000;
111
+ return `${prefix}-${Date.now()}-${idSeq.toString(36)}`;
112
+ }
102
113
  export function createEditorStore(deps = {}) {
103
114
  return createStore()((set, get) => ({
104
115
  // ── Initial state ──
@@ -247,7 +258,7 @@ export function createEditorStore(deps = {}) {
247
258
  const availableMs = MAX_TIMELINE_MS - startMs;
248
259
  if (availableMs <= 0)
249
260
  return;
250
- const clipId = `clip-${Date.now()}`;
261
+ const clipId = uniqueId('clip');
251
262
  const clip = {
252
263
  id: clipId,
253
264
  assetId: asset.id,
@@ -465,7 +476,7 @@ export function createEditorStore(deps = {}) {
465
476
  if (!clip)
466
477
  return;
467
478
  state.pushUndo();
468
- const newId = `clip-${Date.now()}`;
479
+ const newId = uniqueId('clip');
469
480
  const newStart = clip.startMs + clip.durationMs; // place right after original
470
481
  const newClip = {
471
482
  ...clip,
@@ -475,7 +486,7 @@ export function createEditorStore(deps = {}) {
475
486
  // Also duplicate canvas object if it's a visual clip
476
487
  const canvasObj = state.canvasObjects.find((o) => o.clipId === id);
477
488
  const newObjs = canvasObj
478
- ? [...state.canvasObjects, { ...canvasObj, id: `obj-${Date.now()}`, clipId: newId }]
489
+ ? [...state.canvasObjects, { ...canvasObj, id: uniqueId('obj'), clipId: newId }]
479
490
  : state.canvasObjects;
480
491
  set({
481
492
  timelineClips: [...state.timelineClips, newClip],
@@ -541,7 +552,7 @@ export function createEditorStore(deps = {}) {
541
552
  }
542
553
  if (startMs === null || targetTrackId === null)
543
554
  return;
544
- const clipId = `clip-color-${Date.now()}`;
555
+ const clipId = uniqueId('clip-color');
545
556
  const clip = {
546
557
  id: clipId,
547
558
  assetId: `color-${color.replace('#', '')}`,
@@ -598,7 +609,7 @@ export function createEditorStore(deps = {}) {
598
609
  // Ensure an audio track exists for this video's track
599
610
  const audioTrackId = clip.trackId.replace('V', 'A');
600
611
  state.ensureAudioTrack(clip.trackId);
601
- const audioClipId = `audio-detach-${Date.now()}`;
612
+ const audioClipId = uniqueId('audio-detach');
602
613
  const audioClip = {
603
614
  id: audioClipId,
604
615
  assetId: clip.assetId,
@@ -10,7 +10,20 @@ export interface BeatAnalysis {
10
10
  /** Downbeats — every 4th beat (1, 5, 9, ...) — in ms */
11
11
  downbeats: number[];
12
12
  /** Source of the BPM value */
13
- source: 'detected' | 'filename-hint' | 'detected+hint-validated' | 'envelope-fallback';
13
+ source: 'detected' | 'filename-hint' | 'detected+hint-validated' | 'envelope-fallback' | 'server';
14
14
  /** Duration of the analyzed audio, in ms */
15
15
  analyzedDurationMs: number;
16
+ /**
17
+ * Transient peaks (drum hits, accents, word starts) from the server-side
18
+ * analysis (POST /api/ai/audio-analysis), ms from the start of the source
19
+ * audio. Strength is 0–1 relative to the loudest onset. Optional: the
20
+ * browser-only analyzer does not compute them.
21
+ */
22
+ peaks?: AudioPeak[];
23
+ /** 0–1 peakedness of the tempo autocorrelation (server analysis only). */
24
+ bpmConfidence?: number;
25
+ }
26
+ export interface AudioPeak {
27
+ ms: number;
28
+ strength: number;
16
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vivid-editor-core",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "VIVID video editor core: project file schema, editor store and AI command orchestrator — shared by the vividai.tv editor and vivid-mcp.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",