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.
- package/dist/ai-orchestrator.d.ts +14 -1
- package/dist/ai-orchestrator.js +76 -61
- package/dist/store.d.ts +1 -0
- package/dist/store.js +16 -5
- package/dist/types/beat.d.ts +14 -1
- package/package.json +1 -1
|
@@ -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
|
|
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. */
|
package/dist/ai-orchestrator.js
CHANGED
|
@@ -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'}).
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
360
|
-
//
|
|
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
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
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 =
|
|
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 =
|
|
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:
|
|
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 =
|
|
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 =
|
|
612
|
+
const audioClipId = uniqueId('audio-detach');
|
|
602
613
|
const audioClip = {
|
|
603
614
|
id: audioClipId,
|
|
604
615
|
assetId: clip.assetId,
|
package/dist/types/beat.d.ts
CHANGED
|
@@ -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.
|
|
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",
|