vivid-editor-core 0.1.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.
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Headless editor — open a ProjectFile, apply AiCommands with the SAME
3
+ * store + orchestrator the browser uses, serialize back. This is what
4
+ * vivid-mcp runs in Node; nothing here touches the DOM.
5
+ */
6
+ import { createEditorStore } from './store.js';
7
+ import { executeAiCommands } from './ai-orchestrator.js';
8
+ import { applyProject, serializeProject, timelineContentMs } from './project.js';
9
+ import { projectSchemaV2 } from './schemas.js';
10
+ import { migrateV1ToV2 } from './migrations/v1ToV2.js';
11
+ import { isV1Project } from './types/project.js';
12
+ export function snapshotOf(state) {
13
+ return {
14
+ timelineClips: state.timelineClips,
15
+ canvasObjects: state.canvasObjects,
16
+ tracks: state.tracks,
17
+ textOverlays: state.textOverlays,
18
+ assetLibrary: state.assetLibrary,
19
+ canvasPreset: state.canvasPreset,
20
+ videoDurationMs: Math.max(timelineContentMs(state.timelineClips), 5000),
21
+ audioBeatsByAssetId: state.audioBeatsByAssetId,
22
+ };
23
+ }
24
+ /** Same wiring as the browser AI panel (ai-chat-panel.tsx). */
25
+ export function actionsOf(state) {
26
+ return {
27
+ addTextOverlay: state.addTextOverlay,
28
+ updateTextOverlay: state.updateTextOverlay,
29
+ removeTextOverlay: state.removeTextOverlay,
30
+ addClipToTimeline: state.addClipToTimeline,
31
+ updateClipBounds: state.updateClipBounds,
32
+ deleteClipById: state.deleteClipById,
33
+ setClipVolume: state.setClipVolume,
34
+ setClipOpacity: state.setClipOpacity,
35
+ setClipSpeed: state.setClipSpeed,
36
+ setSelectedClipId: state.setSelectedClipId,
37
+ splitClip: state.splitClip,
38
+ duplicateClip: state.duplicateClip,
39
+ setClipTransition: state.setClipTransition,
40
+ setClipAnimation: state.setClipAnimation,
41
+ detachAudio: state.detachAudio,
42
+ updateCanvasObject: state.updateCanvasObject,
43
+ setCanvasPreset: state.setCanvasPreset,
44
+ setCinematicLook: state.setCinematicLook,
45
+ addTrack: state.addTrack,
46
+ replaceTimelineClips: state.replaceTimelineClips,
47
+ pushUndo: state.pushUndo,
48
+ };
49
+ }
50
+ /** One command at a time, fresh snapshot each; consumes ADD_CLIP's canvas patch. */
51
+ export function applyCommands(store, commands) {
52
+ const before = store.getState();
53
+ const seen = {
54
+ clips: new Set(before.timelineClips.map((c) => c.id)),
55
+ objects: new Set(before.canvasObjects.map((o) => o.id)),
56
+ texts: new Set(before.textOverlays.map((t) => t.id)),
57
+ tracks: new Set(before.tracks.map((t) => t.id)),
58
+ };
59
+ let applied = 0;
60
+ const errors = [];
61
+ for (const cmd of commands) {
62
+ const state = store.getState();
63
+ const objectsBefore = new Set(state.canvasObjects.map((o) => o.id));
64
+ const r = executeAiCommands([cmd], snapshotOf(state), actionsOf(state));
65
+ applied += r.applied;
66
+ errors.push(...r.errors);
67
+ // ADD_CLIP with a canvasObject: the orchestrator parks the transform on
68
+ // the command (the browser never consumed it); apply it to the object
69
+ // the clip just created.
70
+ const patch = cmd._pendingCanvasPatch;
71
+ if (patch) {
72
+ const after = store.getState();
73
+ const fresh = after.canvasObjects.filter((o) => !objectsBefore.has(o.id)).at(-1);
74
+ if (fresh)
75
+ after.updateCanvasObject(fresh.id, patch);
76
+ }
77
+ }
78
+ const after = store.getState();
79
+ return {
80
+ applied, errors,
81
+ created: {
82
+ clips: after.timelineClips.filter((c) => !seen.clips.has(c.id)).map((c) => c.id),
83
+ canvasObjects: after.canvasObjects.filter((o) => !seen.objects.has(o.id)).map((o) => o.id),
84
+ textOverlays: after.textOverlays.filter((t) => !seen.texts.has(t.id)).map((t) => t.id),
85
+ tracks: after.tracks.filter((t) => !seen.tracks.has(t.id)).map((t) => t.id),
86
+ },
87
+ };
88
+ }
89
+ /** Parse + validate (+ migrate v1) a project JSON as loaded from R2. */
90
+ export function parseProjectFile(raw) {
91
+ let candidate = raw;
92
+ let migrated = false;
93
+ if (isV1Project(raw)) {
94
+ candidate = migrateV1ToV2(raw);
95
+ migrated = true;
96
+ }
97
+ const parsed = projectSchemaV2.safeParse(candidate);
98
+ if (!parsed.success) {
99
+ throw new Error(`Invalid project file: ${parsed.error.issues.slice(0, 3).map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`);
100
+ }
101
+ return { file: parsed.data, migrated };
102
+ }
103
+ /** Empty project with sensible defaults (V1 track, first canvas preset). */
104
+ export function emptyProjectFile(name, canvasPreset) {
105
+ const store = createEditorStore();
106
+ const s = store.getState();
107
+ return {
108
+ version: 2, schemaVersion: 2, projectName: name, videoDurationMs: 0,
109
+ canvasPreset: canvasPreset ?? s.canvasPreset, tracks: s.tracks, timelineClips: [], canvasObjects: [], textOverlays: [], assets: [],
110
+ };
111
+ }
112
+ export function openHeadlessProject(raw, opts) {
113
+ const { file, migrated } = parseProjectFile(raw);
114
+ const store = createEditorStore();
115
+ const { missingAssets } = applyProject(store, file, opts);
116
+ const extras = {
117
+ cues: file.cues, templateId: file.templateId, styleOverrides: file.styleOverrides,
118
+ animation: file.animation, position: file.position, subtitleLayout: file.subtitleLayout,
119
+ };
120
+ return {
121
+ store, extras, missingAssets, migrated,
122
+ snapshot: () => snapshotOf(store.getState()),
123
+ apply: (commands) => applyCommands(store, commands),
124
+ toProjectFile: () => serializeProject(store.getState(), extras, file.videoDurationMs && file.videoDurationMs > timelineContentMs(store.getState().timelineClips) ? file.videoDurationMs : undefined),
125
+ };
126
+ }
@@ -0,0 +1,17 @@
1
+ export * from './types/editor.js';
2
+ export * from './types/animation.js';
3
+ export * from './types/effect.js';
4
+ export type { Mask, Mask as MaskV2, MaskType as MaskV2Type } from './types/mask.js';
5
+ export * from './types/audioReactive.js';
6
+ export * from './types/easing.js';
7
+ export * from './types/project.js';
8
+ export * from './types/beat.js';
9
+ export * from './store.js';
10
+ export * from './timeline-utils.js';
11
+ export { TEXT_PRESETS, createTextOverlay, type TextPresetId, type TextPreset } from './text-presets.js';
12
+ export * from './schemas.js';
13
+ export * from './migrations/v1ToV2.js';
14
+ export * from './ai-command-schemas.js';
15
+ export * from './ai-orchestrator.js';
16
+ export * from './project.js';
17
+ export * from './headless.js';
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ export * from './types/editor.js'; // note: its generic `TextPreset` is shadowed by text-presets' typed one below
2
+ export * from './types/animation.js';
3
+ export * from './types/effect.js';
4
+ export * from './types/audioReactive.js';
5
+ export * from './types/easing.js';
6
+ export * from './types/project.js';
7
+ export * from './types/beat.js';
8
+ export * from './store.js';
9
+ export * from './timeline-utils.js';
10
+ export { TEXT_PRESETS, createTextOverlay } from './text-presets.js';
11
+ export * from './schemas.js';
12
+ export * from './migrations/v1ToV2.js';
13
+ export * from './ai-command-schemas.js';
14
+ export * from './ai-orchestrator.js';
15
+ export * from './project.js';
16
+ export * from './headless.js';
@@ -0,0 +1,19 @@
1
+ /**
2
+ * V1 → V2 project file migration.
3
+ *
4
+ * The V1 project file already has `version: 2` (ad-hoc, predates the v2 spec).
5
+ * V2 uses `schemaVersion: 2` as a separate marker. A project is V1 if
6
+ * `schemaVersion` is missing or < 2.
7
+ *
8
+ * Migration is a pure function. Side-effects (R2 backup, network) live in
9
+ * projectLoader.ts.
10
+ *
11
+ * Per spec §10:
12
+ * - CanvasObject: x/y/w/h/rotation unchanged (already fractions + degrees).
13
+ * opacity stays on TimelineClip (no-op for canvas objects).
14
+ * - TextOverlay: legacy `animation` + `animationDurationMs` → `animations.{prop}`
15
+ * keyframes using the overlay's own startMs/endMs timestamps.
16
+ * - Legacy fields are deleted post-migration.
17
+ */
18
+ import type { ProjectFile } from '../types/project.js';
19
+ export declare function migrateV1ToV2(p: Record<string, unknown>): ProjectFile;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * V1 → V2 project file migration.
3
+ *
4
+ * The V1 project file already has `version: 2` (ad-hoc, predates the v2 spec).
5
+ * V2 uses `schemaVersion: 2` as a separate marker. A project is V1 if
6
+ * `schemaVersion` is missing or < 2.
7
+ *
8
+ * Migration is a pure function. Side-effects (R2 backup, network) live in
9
+ * projectLoader.ts.
10
+ *
11
+ * Per spec §10:
12
+ * - CanvasObject: x/y/w/h/rotation unchanged (already fractions + degrees).
13
+ * opacity stays on TimelineClip (no-op for canvas objects).
14
+ * - TextOverlay: legacy `animation` + `animationDurationMs` → `animations.{prop}`
15
+ * keyframes using the overlay's own startMs/endMs timestamps.
16
+ * - Legacy fields are deleted post-migration.
17
+ */
18
+ export function migrateV1ToV2(p) {
19
+ const canvasObjects = Array.isArray(p.canvasObjects)
20
+ ? p.canvasObjects.map(migrateCanvasObject)
21
+ : [];
22
+ const textOverlays = Array.isArray(p.textOverlays)
23
+ ? p.textOverlays.map(migrateTextOverlay)
24
+ : [];
25
+ // Pass everything else through unchanged. V2 is a superset of V1 shape.
26
+ return {
27
+ ...p,
28
+ schemaVersion: 2,
29
+ canvasObjects,
30
+ textOverlays,
31
+ };
32
+ }
33
+ function migrateCanvasObject(obj) {
34
+ // V1 coordinates are already fractions, rotation is already degrees, opacity
35
+ // lives on TimelineClip. Nothing to convert in V1→V2 terms; just pass through.
36
+ // New v2 fields (animations/effects/maskV2/audioReactive) default to undefined.
37
+ return { ...obj };
38
+ }
39
+ function migrateTextOverlay(textV1) {
40
+ const text = { ...textV1 };
41
+ const legacyType = textV1.animation;
42
+ const dur = textV1.animationDurationMs;
43
+ if (legacyType && legacyType !== 'none' && typeof dur === 'number' && dur > 0) {
44
+ const startMs = textV1.startMs;
45
+ const endMs = textV1.endMs;
46
+ const baseOpacity = typeof textV1.opacity === 'number' ? textV1.opacity : 1;
47
+ const baseX = typeof textV1.x === 'number' ? textV1.x : 0.5;
48
+ const baseY = typeof textV1.y === 'number' ? textV1.y : 0.5;
49
+ const baseScale = typeof textV1.scale === 'number' ? textV1.scale : 1;
50
+ const animations = { ...(text.animations ?? {}) };
51
+ switch (legacyType) {
52
+ case 'fade-in':
53
+ animations.opacity = twoKfAnim(startMs, 0, startMs + dur, baseOpacity, 'easeOutCubic');
54
+ break;
55
+ case 'fade-out':
56
+ animations.opacity = twoKfAnim(endMs - dur, baseOpacity, endMs, 0, 'easeInCubic');
57
+ break;
58
+ case 'fade-in-out':
59
+ animations.opacity = {
60
+ keyframes: [
61
+ { t: startMs, v: 0, easing: 'easeOutCubic' },
62
+ { t: startMs + dur, v: baseOpacity, easing: 'linear' },
63
+ { t: Math.max(startMs + dur, endMs - dur), v: baseOpacity, easing: 'easeInCubic' },
64
+ { t: endMs, v: 0 },
65
+ ],
66
+ };
67
+ break;
68
+ case 'slide-up':
69
+ animations.y = twoKfAnim(startMs, baseY + 0.15, startMs + dur, baseY, 'easeOutQuart');
70
+ animations.opacity = twoKfAnim(startMs, 0, startMs + dur, baseOpacity, 'easeOutCubic');
71
+ break;
72
+ case 'slide-down':
73
+ animations.y = twoKfAnim(startMs, baseY - 0.15, startMs + dur, baseY, 'easeOutQuart');
74
+ animations.opacity = twoKfAnim(startMs, 0, startMs + dur, baseOpacity, 'easeOutCubic');
75
+ break;
76
+ case 'slide-left':
77
+ animations.x = twoKfAnim(startMs, baseX + 0.2, startMs + dur, baseX, 'easeOutQuart');
78
+ animations.opacity = twoKfAnim(startMs, 0, startMs + dur, baseOpacity, 'easeOutCubic');
79
+ break;
80
+ case 'slide-right':
81
+ animations.x = twoKfAnim(startMs, baseX - 0.2, startMs + dur, baseX, 'easeOutQuart');
82
+ animations.opacity = twoKfAnim(startMs, 0, startMs + dur, baseOpacity, 'easeOutCubic');
83
+ break;
84
+ case 'pop':
85
+ case 'scale-up':
86
+ animations.scale = twoKfAnim(startMs, 0, startMs + dur, baseScale, 'easeOutBack');
87
+ animations.opacity = twoKfAnim(startMs, 0, startMs + dur, baseOpacity, 'easeOutCubic');
88
+ break;
89
+ case 'bounce':
90
+ animations.scale = {
91
+ keyframes: [
92
+ { t: startMs, v: 0, easing: 'easeOutBack' },
93
+ { t: startMs + dur * 0.7, v: baseScale * 1.1, easing: 'easeInOutCubic' },
94
+ { t: startMs + dur, v: baseScale },
95
+ ],
96
+ };
97
+ animations.opacity = twoKfAnim(startMs, 0, startMs + dur * 0.4, baseOpacity, 'easeOutCubic');
98
+ break;
99
+ case 'blur-in':
100
+ animations.blur = twoKfAnim(startMs, 20, startMs + dur, 0, 'easeOutCubic');
101
+ animations.opacity = twoKfAnim(startMs, 0, startMs + dur, baseOpacity, 'easeOutCubic');
102
+ break;
103
+ case 'typewriter':
104
+ // Typewriter is a text-rendering effect, not a numeric animation.
105
+ // Keep the legacy field in place — the renderer handles it inline
106
+ // until a proper per-character keyframe system exists (post-v2).
107
+ // Skip migration for this case.
108
+ return text;
109
+ }
110
+ text.animations = animations;
111
+ // Strip legacy fields post-migration. Cast via unknown because the base
112
+ // TextOverlay type has these as required.
113
+ const mutable = text;
114
+ delete mutable.animation;
115
+ delete mutable.animationDurationMs;
116
+ }
117
+ return text;
118
+ }
119
+ function twoKfAnim(tA, vA, tB, vB, easingA) {
120
+ return {
121
+ keyframes: [
122
+ { t: tA, v: vA, easing: easingA },
123
+ { t: tB, v: vB },
124
+ ],
125
+ };
126
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * ProjectFile v2 ⇄ editor store.
3
+ *
4
+ * Mirrors what the browser editor does on Save / Load (vivid-app
5
+ * `editor-inner.tsx`): assets are stored by `serverAssetId` (the VIVID asset
6
+ * id), clip URLs are dropped on save and resolved again on load. Subtitle
7
+ * state (cues, template…) lives outside the store and travels as `extras`.
8
+ */
9
+ import type { EditorState, EditorStore, AssetClip, TimelineClip, TrackDefinition } from './store.js';
10
+ import type { ProjectFile } from './types/project.js';
11
+ export declare const DEFAULT_TRACKS: TrackDefinition[];
12
+ export type ProjectExtras = Pick<ProjectFile, 'cues' | 'templateId' | 'styleOverrides' | 'animation' | 'position' | 'subtitleLayout'>;
13
+ /** End of the last clip on the timeline, in ms. */
14
+ export declare function timelineContentMs(clips: TimelineClip[]): number;
15
+ /**
16
+ * Store → ProjectFile. Assets without a `serverAssetId` are skipped (the
17
+ * browser uploads them first; a headless client must pass server-backed
18
+ * assets only).
19
+ */
20
+ export declare function serializeProject(state: EditorState, extras?: ProjectExtras, videoDurationMs?: number): ProjectFile;
21
+ export interface ApplyProjectOptions {
22
+ /** URL for a server asset (browser: blob URL after download; headless: the API download URL). */
23
+ resolveUrl: (serverAssetId: string, asset: Partial<AssetClip> & {
24
+ id: string;
25
+ }) => string | null;
26
+ }
27
+ /**
28
+ * ProjectFile → store (replaces timeline state, resets selection + undo).
29
+ * Returns the local ids of assets whose URL could not be resolved; their
30
+ * clips are dropped like the browser does.
31
+ */
32
+ export declare function applyProject(store: EditorStore, file: ProjectFile, opts: ApplyProjectOptions): {
33
+ missingAssets: string[];
34
+ };
@@ -0,0 +1,100 @@
1
+ /**
2
+ * ProjectFile v2 ⇄ editor store.
3
+ *
4
+ * Mirrors what the browser editor does on Save / Load (vivid-app
5
+ * `editor-inner.tsx`): assets are stored by `serverAssetId` (the VIVID asset
6
+ * id), clip URLs are dropped on save and resolved again on load. Subtitle
7
+ * state (cues, template…) lives outside the store and travels as `extras`.
8
+ */
9
+ export const DEFAULT_TRACKS = [
10
+ { id: 'V1', label: 'V1', type: 'visual', color: 'rgba(245, 101, 69, 0.6)' },
11
+ ];
12
+ /** End of the last clip on the timeline, in ms. */
13
+ export function timelineContentMs(clips) {
14
+ return clips.reduce((max, c) => Math.max(max, c.startMs + c.durationMs), 0);
15
+ }
16
+ /**
17
+ * Store → ProjectFile. Assets without a `serverAssetId` are skipped (the
18
+ * browser uploads them first; a headless client must pass server-backed
19
+ * assets only).
20
+ */
21
+ export function serializeProject(state, extras = {}, videoDurationMs) {
22
+ const assets = state.assetLibrary
23
+ .filter((a) => a.serverAssetId)
24
+ .map((a) => ({
25
+ id: a.id,
26
+ name: a.name,
27
+ durationMs: a.durationMs,
28
+ mediaType: a.mediaType,
29
+ intrinsicWidth: a.intrinsicWidth,
30
+ intrinsicHeight: a.intrinsicHeight,
31
+ serverAssetId: a.serverAssetId,
32
+ source: a.source,
33
+ ...(a.bpm && { bpm: a.bpm }),
34
+ }));
35
+ return {
36
+ version: 2,
37
+ schemaVersion: 2,
38
+ ...extras,
39
+ videoDurationMs: videoDurationMs ?? timelineContentMs(state.timelineClips),
40
+ assets,
41
+ timelineClips: state.timelineClips.map((c) => ({ ...c, url: undefined })),
42
+ canvasObjects: state.canvasObjects,
43
+ tracks: state.tracks,
44
+ canvasPreset: state.canvasPreset,
45
+ textOverlays: state.textOverlays,
46
+ projectName: state.projectName,
47
+ };
48
+ }
49
+ /**
50
+ * ProjectFile → store (replaces timeline state, resets selection + undo).
51
+ * Returns the local ids of assets whose URL could not be resolved; their
52
+ * clips are dropped like the browser does.
53
+ */
54
+ export function applyProject(store, file, opts) {
55
+ const missing = [];
56
+ const urlByLocalId = {};
57
+ const assets = [];
58
+ for (const sa of file.assets ?? []) {
59
+ const serverId = sa.serverAssetId;
60
+ const url = serverId ? opts.resolveUrl(serverId, sa) : null;
61
+ if (!url) {
62
+ missing.push(sa.id);
63
+ continue;
64
+ }
65
+ urlByLocalId[sa.id] = url;
66
+ assets.push({
67
+ id: sa.id,
68
+ name: sa.name ?? sa.id,
69
+ url,
70
+ durationMs: sa.durationMs ?? 0,
71
+ file: null,
72
+ mediaType: sa.mediaType ?? 'video',
73
+ intrinsicWidth: sa.intrinsicWidth,
74
+ intrinsicHeight: sa.intrinsicHeight,
75
+ serverAssetId: serverId,
76
+ source: sa.source,
77
+ ...(sa.bpm && { bpm: sa.bpm }),
78
+ });
79
+ }
80
+ const clips = (file.timelineClips ?? [])
81
+ .filter((c) => c.mediaType === 'color' || urlByLocalId[c.assetId])
82
+ .map((c) => ({ ...c, url: c.mediaType === 'color' ? '' : (urlByLocalId[c.assetId] ?? '') }));
83
+ store.setState({
84
+ assetLibrary: assets,
85
+ timelineClips: clips,
86
+ canvasObjects: (file.canvasObjects ?? []),
87
+ tracks: file.tracks?.length ? file.tracks : [...DEFAULT_TRACKS],
88
+ textOverlays: (file.textOverlays ?? []),
89
+ selectedClipId: null,
90
+ selectedObjectId: null,
91
+ selectedTextId: null,
92
+ playheadMs: 0,
93
+ isPlaying: false,
94
+ undoStack: [],
95
+ redoStack: [],
96
+ ...(file.canvasPreset ? { canvasPreset: file.canvasPreset } : {}),
97
+ ...(typeof file.projectName === 'string' ? { projectName: file.projectName } : {}),
98
+ });
99
+ return { missingAssets: missing };
100
+ }