vivid-editor-core 0.2.0 → 0.3.0

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.
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.0",
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",