vivid-editor-core 0.1.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.
- package/dist/ai-orchestrator.d.ts +57 -2
- package/dist/ai-orchestrator.js +72 -3
- package/dist/headless.d.ts +4 -2
- package/dist/headless.js +42 -4
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/speed-ramp.d.ts +59 -0
- package/dist/speed-ramp.js +158 -0
- package/dist/store.d.ts +10 -0
- package/dist/store.js +62 -13
- package/dist/subtitles.d.ts +42 -0
- package/dist/subtitles.js +81 -0
- package/dist/text-fill.d.ts +55 -0
- package/dist/text-fill.js +99 -0
- package/dist/text-presets.d.ts +1 -1
- package/dist/text-presets.js +28 -0
- package/dist/types/beat.d.ts +14 -1
- package/dist/types/editor.d.ts +22 -0
- package/dist/types/editor.js +6 -1
- package/package.json +1 -1
|
@@ -12,6 +12,9 @@ import type { Animations, Animation as AnimationCurve, AnimatableProp } from './
|
|
|
12
12
|
import type { Effect } from './types/effect.js';
|
|
13
13
|
import type { Mask as MaskV2 } from './types/mask.js';
|
|
14
14
|
import type { AudioReactiveModifier } from './types/audioReactive.js';
|
|
15
|
+
import { type SpeedKeyframe, type SpeedRampPresetId } from './speed-ramp.js';
|
|
16
|
+
import { type CueInput } from './subtitles.js';
|
|
17
|
+
import type { SubtitleLayout } from './types/editor.js';
|
|
15
18
|
export interface AddTextCommand {
|
|
16
19
|
type: 'ADD_TEXT';
|
|
17
20
|
payload: TextOverlay;
|
|
@@ -81,6 +84,36 @@ export interface SplitClipCommand {
|
|
|
81
84
|
splitAtMs: number;
|
|
82
85
|
};
|
|
83
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Variable speed inside a clip. Either explicit keyframes (t = ms from the
|
|
89
|
+
* clip start on the timeline, v = rate) or a preset built for the clip's
|
|
90
|
+
* duration. `preset: 'none'` / empty keyframes remove the ramp.
|
|
91
|
+
*/
|
|
92
|
+
export interface SetSpeedRampCommand {
|
|
93
|
+
type: 'SET_SPEED_RAMP';
|
|
94
|
+
payload: {
|
|
95
|
+
clipId: string;
|
|
96
|
+
keyframes?: SpeedKeyframe[] | null;
|
|
97
|
+
preset?: SpeedRampPresetId | 'none';
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Word-level subtitle state of the project (cues + template + placement). */
|
|
101
|
+
export interface SubtitlesPayload {
|
|
102
|
+
/** Cues with optional per-word timing; null clears the subtitles. */
|
|
103
|
+
cues?: CueInput[] | null;
|
|
104
|
+
/** One of SUBTITLE_TEMPLATE_IDS (outline-reveal, ugc-pop, karaoke-marker, cinematic-fade, neon-cyberpunk, playful-wiggle). */
|
|
105
|
+
templateId?: string;
|
|
106
|
+
/** Vertical position 0 (top) – 100 (bottom). */
|
|
107
|
+
position?: number;
|
|
108
|
+
/** Overrides on the template style (fontSize, color, highlightColor, …). */
|
|
109
|
+
styleOverrides?: Record<string, unknown>;
|
|
110
|
+
/** Canvas-space layout (x, y, scale, maxWidthPct, rotation), normalized 0-1. */
|
|
111
|
+
layout?: Partial<SubtitleLayout>;
|
|
112
|
+
}
|
|
113
|
+
export interface SetSubtitlesCommand {
|
|
114
|
+
type: 'SET_SUBTITLES';
|
|
115
|
+
payload: SubtitlesPayload;
|
|
116
|
+
}
|
|
84
117
|
export interface DuplicateClipCommand {
|
|
85
118
|
type: 'DUPLICATE_CLIP';
|
|
86
119
|
payload: {
|
|
@@ -123,7 +156,11 @@ export interface DetachAudioCommand {
|
|
|
123
156
|
* `audioClipId` — id of an audio clip already in timeline. Must have a
|
|
124
157
|
* cached BeatAnalysis (otherwise the orchestrator returns an error).
|
|
125
158
|
*
|
|
126
|
-
* `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).
|
|
127
164
|
*
|
|
128
165
|
* `mode`:
|
|
129
166
|
* - "redistribute" (default): take all visual clips, slice them up so
|
|
@@ -144,6 +181,15 @@ export interface CutToBeatCommand {
|
|
|
144
181
|
beatInterval: number;
|
|
145
182
|
mode?: 'redistribute' | 'snap';
|
|
146
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;
|
|
147
193
|
};
|
|
148
194
|
}
|
|
149
195
|
/** Set keyframe animations on one property of a canvas object or text overlay. */
|
|
@@ -202,7 +248,7 @@ export interface SetMaskCommand {
|
|
|
202
248
|
mask: MaskV2;
|
|
203
249
|
};
|
|
204
250
|
}
|
|
205
|
-
export type AiCommand = AddTextCommand | UpdateTextCommand | RemoveTextCommand | AddClipCommand | UpdateClipCommand | RemoveClipCommand | UpdateCanvasObjectCommand | SetCanvasPresetCommand | AddTrackCommand | SplitClipCommand | DuplicateClipCommand | SetTransitionCommand | SetClipAnimationCommand | SetCinematicLookCommand | DetachAudioCommand | CutToBeatCommand | SetObjectAnimationCommand | SetAnimationsCommand | AddEffectCommand | RemoveEffectCommand | AddAudioReactiveCommand | SetMaskCommand;
|
|
251
|
+
export type AiCommand = AddTextCommand | UpdateTextCommand | RemoveTextCommand | AddClipCommand | UpdateClipCommand | RemoveClipCommand | UpdateCanvasObjectCommand | SetCanvasPresetCommand | AddTrackCommand | SplitClipCommand | SetSpeedRampCommand | SetSubtitlesCommand | DuplicateClipCommand | SetTransitionCommand | SetClipAnimationCommand | SetCinematicLookCommand | DetachAudioCommand | CutToBeatCommand | SetObjectAnimationCommand | SetAnimationsCommand | AddEffectCommand | RemoveEffectCommand | AddAudioReactiveCommand | SetMaskCommand;
|
|
206
252
|
export interface EditorSnapshot {
|
|
207
253
|
timelineClips: TimelineClip[];
|
|
208
254
|
canvasObjects: CanvasObject[];
|
|
@@ -230,6 +276,15 @@ export interface OrchestratorActions {
|
|
|
230
276
|
setClipVolume: (id: string, volume: number) => void;
|
|
231
277
|
setClipOpacity: (id: string, opacity: number) => void;
|
|
232
278
|
setClipSpeed: (id: string, rate: number) => void;
|
|
279
|
+
setClipSpeedRamp: (id: string, ramp: SpeedKeyframe[] | undefined) => void;
|
|
280
|
+
/** Subtitle state lives outside the store (editor page / project extras) — optional wiring. */
|
|
281
|
+
setSubtitles?: (patch: {
|
|
282
|
+
cues?: import('./types/editor.js').TranscriptionCue[] | null;
|
|
283
|
+
templateId?: string;
|
|
284
|
+
position?: number;
|
|
285
|
+
styleOverrides?: Record<string, unknown>;
|
|
286
|
+
layout?: Partial<SubtitleLayout>;
|
|
287
|
+
}) => void;
|
|
233
288
|
setSelectedClipId: (id: string | null) => void;
|
|
234
289
|
splitClip: (splitAtMs: number) => void;
|
|
235
290
|
duplicateClip: (id: string) => void;
|
package/dist/ai-orchestrator.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* always creates both a TimelineClip AND a CanvasObject).
|
|
7
7
|
*/
|
|
8
8
|
import { createTextOverlay } from './text-presets.js';
|
|
9
|
+
import { speedRampPreset } from './speed-ramp.js';
|
|
10
|
+
import { normalizeCues } from './subtitles.js';
|
|
9
11
|
/**
|
|
10
12
|
* Take whatever the LLM produced for an ADD_TEXT payload and:
|
|
11
13
|
* - clamp x/y to [0, 1] so the overlay can't land off-canvas
|
|
@@ -68,6 +70,7 @@ export function executeAiCommands(commands, snapshot, actions) {
|
|
|
68
70
|
'ADD_CLIP', 'UPDATE_CLIP', 'REMOVE_CLIP',
|
|
69
71
|
'UPDATE_CANVAS_OBJECT', 'SET_CANVAS_PRESET',
|
|
70
72
|
'ADD_TRACK', 'SPLIT_CLIP', 'DUPLICATE_CLIP',
|
|
73
|
+
'SET_SPEED_RAMP', 'SET_SUBTITLES',
|
|
71
74
|
'SET_TRANSITION', 'SET_CLIP_ANIMATION',
|
|
72
75
|
'SET_CINEMATIC_LOOK', 'DETACH_AUDIO',
|
|
73
76
|
'CUT_TO_BEAT',
|
|
@@ -306,9 +309,19 @@ export function executeAiCommands(commands, snapshot, actions) {
|
|
|
306
309
|
}
|
|
307
310
|
const analysis = snapshot.audioBeatsByAssetId?.[audioClip.assetId];
|
|
308
311
|
if (!analysis || analysis === 'analyzing' || analysis === 'failed') {
|
|
309
|
-
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.`);
|
|
310
313
|
break;
|
|
311
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
|
+
}
|
|
312
325
|
// Filter visual clips: those participating in the cut.
|
|
313
326
|
const allVisualClips = snapshot.timelineClips.filter((c) => c.mediaType === 'video' || c.mediaType === 'image');
|
|
314
327
|
const targetVisuals = syncClipsArg === 'all'
|
|
@@ -323,11 +336,11 @@ export function executeAiCommands(commands, snapshot, actions) {
|
|
|
323
336
|
const audioEnd = audioClip.startMs + audioClip.durationMs;
|
|
324
337
|
// Beats from analysis are offsets from start of source audio.
|
|
325
338
|
// sourceOffsetMs accounts for trims at audio's head.
|
|
326
|
-
const beatsAbsolute =
|
|
339
|
+
const beatsAbsolute = gridMs
|
|
327
340
|
.map((bMs) => audioStart + (bMs - audioClip.sourceOffsetMs))
|
|
328
341
|
.filter((t) => t >= audioStart && t <= audioEnd);
|
|
329
342
|
if (beatsAbsolute.length < beatInterval + 1) {
|
|
330
|
-
errors.push(`CUT_TO_BEAT: not enough
|
|
343
|
+
errors.push(`CUT_TO_BEAT: not enough ${grid} (${beatsAbsolute.length}) for beatInterval ${beatInterval}`);
|
|
331
344
|
break;
|
|
332
345
|
}
|
|
333
346
|
// Cut points: every Nth beat, including audio start (first beat) and end.
|
|
@@ -591,6 +604,62 @@ export function executeAiCommands(commands, snapshot, actions) {
|
|
|
591
604
|
}
|
|
592
605
|
break;
|
|
593
606
|
}
|
|
607
|
+
// ── Speed ramp ────────────────────────────────
|
|
608
|
+
case 'SET_SPEED_RAMP': {
|
|
609
|
+
const { clipId, keyframes, preset } = cmd.payload;
|
|
610
|
+
const clip = snapshot.timelineClips.find((c) => c.id === clipId);
|
|
611
|
+
if (!clip) {
|
|
612
|
+
errors.push(`SET_SPEED_RAMP: clip "${clipId}" not found`);
|
|
613
|
+
break;
|
|
614
|
+
}
|
|
615
|
+
if (clip.mediaType === 'image' || clip.mediaType === 'color') {
|
|
616
|
+
errors.push(`SET_SPEED_RAMP: clip "${clipId}" is a ${clip.mediaType}, speed only applies to video/audio`);
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
let ramp;
|
|
620
|
+
if (preset && preset !== 'none') {
|
|
621
|
+
const p = speedRampPreset(preset);
|
|
622
|
+
if (!p) {
|
|
623
|
+
errors.push(`SET_SPEED_RAMP: unknown preset "${preset}" (speed-up, slow-down, slow-mo-hit, punch-in, ease-in-out)`);
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
ramp = p.build(clip.durationMs);
|
|
627
|
+
}
|
|
628
|
+
else if (Array.isArray(keyframes) && keyframes.length > 0) {
|
|
629
|
+
ramp = keyframes;
|
|
630
|
+
}
|
|
631
|
+
actions.setClipSpeedRamp(clipId, ramp);
|
|
632
|
+
applied++;
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
635
|
+
// ── Subtitles ─────────────────────────────────
|
|
636
|
+
case 'SET_SUBTITLES': {
|
|
637
|
+
if (!actions.setSubtitles) {
|
|
638
|
+
errors.push('SET_SUBTITLES: not available in this editor context');
|
|
639
|
+
break;
|
|
640
|
+
}
|
|
641
|
+
const { cues, templateId, position, styleOverrides, layout } = cmd.payload;
|
|
642
|
+
const patch = {};
|
|
643
|
+
if (cues === null)
|
|
644
|
+
patch.cues = null;
|
|
645
|
+
else if (Array.isArray(cues)) {
|
|
646
|
+
const n = normalizeCues(cues);
|
|
647
|
+
if (n.warnings.length > 0)
|
|
648
|
+
console.warn('[ai-orchestrator] SET_SUBTITLES:', n.warnings.join('; '));
|
|
649
|
+
patch.cues = n.cues;
|
|
650
|
+
}
|
|
651
|
+
if (templateId !== undefined)
|
|
652
|
+
patch.templateId = templateId;
|
|
653
|
+
if (position !== undefined)
|
|
654
|
+
patch.position = Math.max(0, Math.min(100, position));
|
|
655
|
+
if (styleOverrides !== undefined)
|
|
656
|
+
patch.styleOverrides = styleOverrides;
|
|
657
|
+
if (layout !== undefined)
|
|
658
|
+
patch.layout = layout;
|
|
659
|
+
actions.setSubtitles(patch);
|
|
660
|
+
applied++;
|
|
661
|
+
break;
|
|
662
|
+
}
|
|
594
663
|
case 'SET_MASK': {
|
|
595
664
|
const { objectId, mask } = cmd.payload;
|
|
596
665
|
const obj = snapshot.canvasObjects.find((o) => o.id === objectId);
|
package/dist/headless.d.ts
CHANGED
|
@@ -8,8 +8,10 @@ import { type AiCommand, type EditorSnapshot, type OrchestratorActions } from '.
|
|
|
8
8
|
import { type ApplyProjectOptions, type ProjectExtras } from './project.js';
|
|
9
9
|
import { type ProjectFile } from './types/project.js';
|
|
10
10
|
export declare function snapshotOf(state: EditorState): EditorSnapshot;
|
|
11
|
+
/** Subtitle writer over the project extras (the browser keeps this state in the editor page). */
|
|
12
|
+
export declare function subtitleWriter(extras: ProjectExtras): NonNullable<OrchestratorActions['setSubtitles']>;
|
|
11
13
|
/** Same wiring as the browser AI panel (ai-chat-panel.tsx). */
|
|
12
|
-
export declare function actionsOf(state: EditorState): OrchestratorActions;
|
|
14
|
+
export declare function actionsOf(state: EditorState, extras?: ProjectExtras): OrchestratorActions;
|
|
13
15
|
export interface ApplyResult {
|
|
14
16
|
applied: number;
|
|
15
17
|
errors: string[];
|
|
@@ -22,7 +24,7 @@ export interface ApplyResult {
|
|
|
22
24
|
};
|
|
23
25
|
}
|
|
24
26
|
/** One command at a time, fresh snapshot each; consumes ADD_CLIP's canvas patch. */
|
|
25
|
-
export declare function applyCommands(store: EditorStore, commands: AiCommand[]): ApplyResult;
|
|
27
|
+
export declare function applyCommands(store: EditorStore, commands: AiCommand[], extras?: ProjectExtras): ApplyResult;
|
|
26
28
|
export interface HeadlessEditor {
|
|
27
29
|
store: EditorStore;
|
|
28
30
|
/** Subtitle & template state carried through untouched. */
|
package/dist/headless.js
CHANGED
|
@@ -9,6 +9,7 @@ import { applyProject, serializeProject, timelineContentMs } from './project.js'
|
|
|
9
9
|
import { projectSchemaV2 } from './schemas.js';
|
|
10
10
|
import { migrateV1ToV2 } from './migrations/v1ToV2.js';
|
|
11
11
|
import { isV1Project } from './types/project.js';
|
|
12
|
+
import { SUBTITLE_TEMPLATE_IDS } from './types/editor.js';
|
|
12
13
|
export function snapshotOf(state) {
|
|
13
14
|
return {
|
|
14
15
|
timelineClips: state.timelineClips,
|
|
@@ -21,8 +22,43 @@ export function snapshotOf(state) {
|
|
|
21
22
|
audioBeatsByAssetId: state.audioBeatsByAssetId,
|
|
22
23
|
};
|
|
23
24
|
}
|
|
25
|
+
/** Subtitle writer over the project extras (the browser keeps this state in the editor page). */
|
|
26
|
+
export function subtitleWriter(extras) {
|
|
27
|
+
return (patch) => {
|
|
28
|
+
if (patch.cues === null) {
|
|
29
|
+
extras.cues = undefined;
|
|
30
|
+
}
|
|
31
|
+
else if (patch.cues) {
|
|
32
|
+
extras.cues = patch.cues;
|
|
33
|
+
}
|
|
34
|
+
if (patch.templateId !== undefined) {
|
|
35
|
+
if (!SUBTITLE_TEMPLATE_IDS.includes(patch.templateId)) {
|
|
36
|
+
throw new Error(`unknown subtitle template "${patch.templateId}" — use one of ${SUBTITLE_TEMPLATE_IDS.join(', ')}`);
|
|
37
|
+
}
|
|
38
|
+
extras.templateId = patch.templateId;
|
|
39
|
+
// The template carries its own animation; the page re-derives it from templateId on load.
|
|
40
|
+
extras.animation = undefined;
|
|
41
|
+
extras.styleOverrides = patch.styleOverrides ?? {};
|
|
42
|
+
}
|
|
43
|
+
else if (patch.styleOverrides !== undefined) {
|
|
44
|
+
extras.styleOverrides = { ...(extras.styleOverrides ?? {}), ...patch.styleOverrides };
|
|
45
|
+
}
|
|
46
|
+
if (!extras.templateId && (extras.cues?.length ?? 0) > 0)
|
|
47
|
+
extras.templateId = SUBTITLE_TEMPLATE_IDS[0];
|
|
48
|
+
const prevLayout = extras.subtitleLayout ?? {};
|
|
49
|
+
if (patch.position !== undefined) {
|
|
50
|
+
extras.position = patch.position;
|
|
51
|
+
extras.subtitleLayout = { x: 0.5, scale: 1, rotation: 0, maxWidthPct: 0.85, ...prevLayout, y: patch.position / 100 };
|
|
52
|
+
}
|
|
53
|
+
if (patch.layout) {
|
|
54
|
+
const merged = { x: 0.5, y: (extras.position ?? 50) / 100, scale: 1, rotation: 0, maxWidthPct: 0.85, ...prevLayout, ...(extras.subtitleLayout ?? {}), ...patch.layout };
|
|
55
|
+
extras.subtitleLayout = merged;
|
|
56
|
+
extras.position = Math.round(merged.y * 100);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
24
60
|
/** Same wiring as the browser AI panel (ai-chat-panel.tsx). */
|
|
25
|
-
export function actionsOf(state) {
|
|
61
|
+
export function actionsOf(state, extras) {
|
|
26
62
|
return {
|
|
27
63
|
addTextOverlay: state.addTextOverlay,
|
|
28
64
|
updateTextOverlay: state.updateTextOverlay,
|
|
@@ -33,6 +69,8 @@ export function actionsOf(state) {
|
|
|
33
69
|
setClipVolume: state.setClipVolume,
|
|
34
70
|
setClipOpacity: state.setClipOpacity,
|
|
35
71
|
setClipSpeed: state.setClipSpeed,
|
|
72
|
+
setClipSpeedRamp: state.setClipSpeedRamp,
|
|
73
|
+
setSubtitles: extras ? subtitleWriter(extras) : undefined,
|
|
36
74
|
setSelectedClipId: state.setSelectedClipId,
|
|
37
75
|
splitClip: state.splitClip,
|
|
38
76
|
duplicateClip: state.duplicateClip,
|
|
@@ -48,7 +86,7 @@ export function actionsOf(state) {
|
|
|
48
86
|
};
|
|
49
87
|
}
|
|
50
88
|
/** One command at a time, fresh snapshot each; consumes ADD_CLIP's canvas patch. */
|
|
51
|
-
export function applyCommands(store, commands) {
|
|
89
|
+
export function applyCommands(store, commands, extras) {
|
|
52
90
|
const before = store.getState();
|
|
53
91
|
const seen = {
|
|
54
92
|
clips: new Set(before.timelineClips.map((c) => c.id)),
|
|
@@ -61,7 +99,7 @@ export function applyCommands(store, commands) {
|
|
|
61
99
|
for (const cmd of commands) {
|
|
62
100
|
const state = store.getState();
|
|
63
101
|
const objectsBefore = new Set(state.canvasObjects.map((o) => o.id));
|
|
64
|
-
const r = executeAiCommands([cmd], snapshotOf(state), actionsOf(state));
|
|
102
|
+
const r = executeAiCommands([cmd], snapshotOf(state), actionsOf(state, extras));
|
|
65
103
|
applied += r.applied;
|
|
66
104
|
errors.push(...r.errors);
|
|
67
105
|
// ADD_CLIP with a canvasObject: the orchestrator parks the transform on
|
|
@@ -120,7 +158,7 @@ export function openHeadlessProject(raw, opts) {
|
|
|
120
158
|
return {
|
|
121
159
|
store, extras, missingAssets, migrated,
|
|
122
160
|
snapshot: () => snapshotOf(store.getState()),
|
|
123
|
-
apply: (commands) => applyCommands(store, commands),
|
|
161
|
+
apply: (commands) => applyCommands(store, commands, extras),
|
|
124
162
|
toProjectFile: () => serializeProject(store.getState(), extras, file.videoDurationMs && file.videoDurationMs > timelineContentMs(store.getState().timelineClips) ? file.videoDurationMs : undefined),
|
|
125
163
|
};
|
|
126
164
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,9 @@ export * from './types/project.js';
|
|
|
8
8
|
export * from './types/beat.js';
|
|
9
9
|
export * from './store.js';
|
|
10
10
|
export * from './timeline-utils.js';
|
|
11
|
+
export * from './speed-ramp.js';
|
|
12
|
+
export * from './text-fill.js';
|
|
13
|
+
export * from './subtitles.js';
|
|
11
14
|
export { TEXT_PRESETS, createTextOverlay, type TextPresetId, type TextPreset } from './text-presets.js';
|
|
12
15
|
export * from './schemas.js';
|
|
13
16
|
export * from './migrations/v1ToV2.js';
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,9 @@ export * from './types/project.js';
|
|
|
7
7
|
export * from './types/beat.js';
|
|
8
8
|
export * from './store.js';
|
|
9
9
|
export * from './timeline-utils.js';
|
|
10
|
+
export * from './speed-ramp.js';
|
|
11
|
+
export * from './text-fill.js';
|
|
12
|
+
export * from './subtitles.js';
|
|
10
13
|
export { TEXT_PRESETS, createTextOverlay } from './text-presets.js';
|
|
11
14
|
export * from './schemas.js';
|
|
12
15
|
export * from './migrations/v1ToV2.js';
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speed ramp — variable playback speed inside one clip.
|
|
3
|
+
*
|
|
4
|
+
* A ramp is a list of keyframes `{ t, v }`: `t` is the offset in ms from the
|
|
5
|
+
* clip's start ON THE TIMELINE, `v` the playback rate at that point. Between
|
|
6
|
+
* keyframes the rate is linear; before the first / after the last it holds.
|
|
7
|
+
*
|
|
8
|
+
* The clip's source time at timeline offset `x` is
|
|
9
|
+
* `sourceOffsetMs + ∫₀ˣ rate(τ) dτ` — because the rate is piecewise linear
|
|
10
|
+
* the integral is exact (trapezoids), no per-frame accumulation needed, so
|
|
11
|
+
* preview, export and audio scheduling all agree to the sample.
|
|
12
|
+
*/
|
|
13
|
+
import type { TimelineClip } from './store.js';
|
|
14
|
+
export interface SpeedKeyframe {
|
|
15
|
+
/** ms from the clip start on the timeline */
|
|
16
|
+
t: number;
|
|
17
|
+
/** playback rate (1 = normal, 0.5 = half speed, 2 = double) */
|
|
18
|
+
v: number;
|
|
19
|
+
}
|
|
20
|
+
export type SpeedRamp = SpeedKeyframe[];
|
|
21
|
+
export declare const SPEED_RAMP_MIN_RATE = 0.1;
|
|
22
|
+
export declare const SPEED_RAMP_MAX_RATE = 8;
|
|
23
|
+
/** Sorted by t, rates clamped, duplicate t collapsed (last wins). Empty → []. */
|
|
24
|
+
export declare function normalizeSpeedRamp(ramp: SpeedRamp | undefined | null): SpeedRamp;
|
|
25
|
+
/** Rate at timeline offset `offsetMs` (holds outside the keyframe range). */
|
|
26
|
+
export declare function rampRateAt(ramp: SpeedRamp, offsetMs: number): number;
|
|
27
|
+
/** Source ms consumed between timeline offsets 0 and `offsetMs` (exact for the piecewise-linear rate). */
|
|
28
|
+
export declare function rampSourceElapsedMs(ramp: SpeedRamp, offsetMs: number): number;
|
|
29
|
+
/** Timeline ms needed to consume `sourceMs` of source under the ramp (bisection, ±0.5 ms). */
|
|
30
|
+
export declare function rampTimelineMsForSource(ramp: SpeedRamp, sourceMs: number): number;
|
|
31
|
+
/** Split a ramp at `atMs`: left keeps its keyframes, right is re-based to 0 starting at the rate of the cut. */
|
|
32
|
+
export declare function splitSpeedRamp(ramp: SpeedRamp, atMs: number): {
|
|
33
|
+
left: SpeedRamp;
|
|
34
|
+
right: SpeedRamp;
|
|
35
|
+
};
|
|
36
|
+
/** Re-base a ramp after the clip's start moved by `deltaMs` on the timeline (trim). */
|
|
37
|
+
export declare function shiftSpeedRamp(ramp: SpeedRamp, deltaMs: number): SpeedRamp;
|
|
38
|
+
/** Ramp that plays the whole clip at `rate` — used to fold a constant speed into ramp math. */
|
|
39
|
+
export declare function constantRamp(rate: number): SpeedRamp;
|
|
40
|
+
/** Playback rate of `clip` at timeline offset `offsetMs` from its start. */
|
|
41
|
+
export declare function clipRateAt(clip: Pick<TimelineClip, 'playbackRate' | 'speedRamp'>, offsetMs: number): number;
|
|
42
|
+
/** Source ms consumed by the first `offsetMs` of the clip on the timeline. */
|
|
43
|
+
export declare function clipSourceElapsedMs(clip: Pick<TimelineClip, 'playbackRate' | 'speedRamp'>, offsetMs: number): number;
|
|
44
|
+
/** Absolute source time (ms) of the clip at timeline position `playheadMs`. */
|
|
45
|
+
export declare function clipSourceTimeMs(clip: Pick<TimelineClip, 'startMs' | 'sourceOffsetMs' | 'playbackRate' | 'speedRamp'>, playheadMs: number): number;
|
|
46
|
+
/** Timeline ms the clip needs to consume `sourceMs` of source. */
|
|
47
|
+
export declare function clipTimelineMsForSource(clip: Pick<TimelineClip, 'playbackRate' | 'speedRamp'>, sourceMs: number): number;
|
|
48
|
+
/** Average rate over the clip (what a "speed" badge should show). */
|
|
49
|
+
export declare function clipAverageRate(clip: Pick<TimelineClip, 'durationMs' | 'playbackRate' | 'speedRamp'>): number;
|
|
50
|
+
export type SpeedRampPresetId = 'speed-up' | 'slow-down' | 'slow-mo-hit' | 'punch-in' | 'ease-in-out';
|
|
51
|
+
export interface SpeedRampPreset {
|
|
52
|
+
id: SpeedRampPresetId;
|
|
53
|
+
label: string;
|
|
54
|
+
description: string;
|
|
55
|
+
/** Keyframes for a clip of `durationMs` on the timeline. */
|
|
56
|
+
build: (durationMs: number) => SpeedRamp;
|
|
57
|
+
}
|
|
58
|
+
export declare const SPEED_RAMP_PRESETS: SpeedRampPreset[];
|
|
59
|
+
export declare function speedRampPreset(id: string): SpeedRampPreset | undefined;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speed ramp — variable playback speed inside one clip.
|
|
3
|
+
*
|
|
4
|
+
* A ramp is a list of keyframes `{ t, v }`: `t` is the offset in ms from the
|
|
5
|
+
* clip's start ON THE TIMELINE, `v` the playback rate at that point. Between
|
|
6
|
+
* keyframes the rate is linear; before the first / after the last it holds.
|
|
7
|
+
*
|
|
8
|
+
* The clip's source time at timeline offset `x` is
|
|
9
|
+
* `sourceOffsetMs + ∫₀ˣ rate(τ) dτ` — because the rate is piecewise linear
|
|
10
|
+
* the integral is exact (trapezoids), no per-frame accumulation needed, so
|
|
11
|
+
* preview, export and audio scheduling all agree to the sample.
|
|
12
|
+
*/
|
|
13
|
+
export const SPEED_RAMP_MIN_RATE = 0.1;
|
|
14
|
+
export const SPEED_RAMP_MAX_RATE = 8;
|
|
15
|
+
/** Sorted by t, rates clamped, duplicate t collapsed (last wins). Empty → []. */
|
|
16
|
+
export function normalizeSpeedRamp(ramp) {
|
|
17
|
+
if (!ramp || ramp.length === 0)
|
|
18
|
+
return [];
|
|
19
|
+
const byT = new Map();
|
|
20
|
+
for (const k of ramp) {
|
|
21
|
+
if (!Number.isFinite(k.t) || !Number.isFinite(k.v))
|
|
22
|
+
continue;
|
|
23
|
+
byT.set(Math.max(0, Math.round(k.t)), Math.min(SPEED_RAMP_MAX_RATE, Math.max(SPEED_RAMP_MIN_RATE, k.v)));
|
|
24
|
+
}
|
|
25
|
+
return [...byT.entries()].sort((a, b) => a[0] - b[0]).map(([t, v]) => ({ t, v }));
|
|
26
|
+
}
|
|
27
|
+
/** Rate at timeline offset `offsetMs` (holds outside the keyframe range). */
|
|
28
|
+
export function rampRateAt(ramp, offsetMs) {
|
|
29
|
+
if (ramp.length === 0)
|
|
30
|
+
return 1;
|
|
31
|
+
if (offsetMs <= ramp[0].t)
|
|
32
|
+
return ramp[0].v;
|
|
33
|
+
const last = ramp[ramp.length - 1];
|
|
34
|
+
if (offsetMs >= last.t)
|
|
35
|
+
return last.v;
|
|
36
|
+
for (let i = 1; i < ramp.length; i++) {
|
|
37
|
+
const a = ramp[i - 1];
|
|
38
|
+
const b = ramp[i];
|
|
39
|
+
if (offsetMs <= b.t) {
|
|
40
|
+
const span = b.t - a.t;
|
|
41
|
+
return span <= 0 ? b.v : a.v + (b.v - a.v) * (offsetMs - a.t) / span;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return last.v;
|
|
45
|
+
}
|
|
46
|
+
/** Source ms consumed between timeline offsets 0 and `offsetMs` (exact for the piecewise-linear rate). */
|
|
47
|
+
export function rampSourceElapsedMs(ramp, offsetMs) {
|
|
48
|
+
if (ramp.length === 0)
|
|
49
|
+
return offsetMs;
|
|
50
|
+
if (offsetMs <= 0)
|
|
51
|
+
return rampRateAt(ramp, 0) * offsetMs;
|
|
52
|
+
let total = 0;
|
|
53
|
+
let cursor = 0;
|
|
54
|
+
// Segment before the first keyframe: constant rate.
|
|
55
|
+
const first = ramp[0];
|
|
56
|
+
if (offsetMs <= first.t)
|
|
57
|
+
return first.v * offsetMs;
|
|
58
|
+
total += first.v * first.t;
|
|
59
|
+
cursor = first.t;
|
|
60
|
+
for (let i = 1; i < ramp.length; i++) {
|
|
61
|
+
const b = ramp[i];
|
|
62
|
+
if (b.t <= cursor)
|
|
63
|
+
continue;
|
|
64
|
+
const end = Math.min(offsetMs, b.t);
|
|
65
|
+
if (end > cursor) {
|
|
66
|
+
const va = rampRateAt(ramp, cursor);
|
|
67
|
+
const vb = rampRateAt(ramp, end);
|
|
68
|
+
total += (end - cursor) * (va + vb) / 2;
|
|
69
|
+
cursor = end;
|
|
70
|
+
}
|
|
71
|
+
if (cursor >= offsetMs)
|
|
72
|
+
return total;
|
|
73
|
+
}
|
|
74
|
+
// Past the last keyframe: constant rate.
|
|
75
|
+
if (offsetMs > cursor)
|
|
76
|
+
total += ramp[ramp.length - 1].v * (offsetMs - cursor);
|
|
77
|
+
return total;
|
|
78
|
+
}
|
|
79
|
+
/** Timeline ms needed to consume `sourceMs` of source under the ramp (bisection, ±0.5 ms). */
|
|
80
|
+
export function rampTimelineMsForSource(ramp, sourceMs) {
|
|
81
|
+
if (sourceMs <= 0)
|
|
82
|
+
return 0;
|
|
83
|
+
if (ramp.length === 0)
|
|
84
|
+
return sourceMs;
|
|
85
|
+
let lo = 0;
|
|
86
|
+
let hi = sourceMs / SPEED_RAMP_MIN_RATE;
|
|
87
|
+
for (let i = 0; i < 60 && hi - lo > 0.5; i++) {
|
|
88
|
+
const mid = (lo + hi) / 2;
|
|
89
|
+
if (rampSourceElapsedMs(ramp, mid) < sourceMs)
|
|
90
|
+
lo = mid;
|
|
91
|
+
else
|
|
92
|
+
hi = mid;
|
|
93
|
+
}
|
|
94
|
+
return Math.round((lo + hi) / 2);
|
|
95
|
+
}
|
|
96
|
+
/** Split a ramp at `atMs`: left keeps its keyframes, right is re-based to 0 starting at the rate of the cut. */
|
|
97
|
+
export function splitSpeedRamp(ramp, atMs) {
|
|
98
|
+
const rateAtCut = rampRateAt(ramp, atMs);
|
|
99
|
+
const left = [...ramp.filter((k) => k.t < atMs), { t: atMs, v: rateAtCut }];
|
|
100
|
+
const right = [{ t: 0, v: rateAtCut }, ...ramp.filter((k) => k.t > atMs).map((k) => ({ t: k.t - atMs, v: k.v }))];
|
|
101
|
+
return { left: normalizeSpeedRamp(left), right: normalizeSpeedRamp(right) };
|
|
102
|
+
}
|
|
103
|
+
/** Re-base a ramp after the clip's start moved by `deltaMs` on the timeline (trim). */
|
|
104
|
+
export function shiftSpeedRamp(ramp, deltaMs) {
|
|
105
|
+
if (ramp.length === 0 || deltaMs === 0)
|
|
106
|
+
return ramp;
|
|
107
|
+
const rateAtNewStart = rampRateAt(ramp, deltaMs);
|
|
108
|
+
return normalizeSpeedRamp([{ t: 0, v: rateAtNewStart }, ...ramp.filter((k) => k.t > deltaMs).map((k) => ({ t: k.t - deltaMs, v: k.v }))]);
|
|
109
|
+
}
|
|
110
|
+
/** Ramp that plays the whole clip at `rate` — used to fold a constant speed into ramp math. */
|
|
111
|
+
export function constantRamp(rate) {
|
|
112
|
+
return [{ t: 0, v: rate }];
|
|
113
|
+
}
|
|
114
|
+
// ── Clip helpers (constant speed or ramp, one code path) ─────────────────
|
|
115
|
+
/** Playback rate of `clip` at timeline offset `offsetMs` from its start. */
|
|
116
|
+
export function clipRateAt(clip, offsetMs) {
|
|
117
|
+
if (clip.speedRamp && clip.speedRamp.length > 0)
|
|
118
|
+
return rampRateAt(clip.speedRamp, offsetMs);
|
|
119
|
+
return clip.playbackRate ?? 1;
|
|
120
|
+
}
|
|
121
|
+
/** Source ms consumed by the first `offsetMs` of the clip on the timeline. */
|
|
122
|
+
export function clipSourceElapsedMs(clip, offsetMs) {
|
|
123
|
+
if (clip.speedRamp && clip.speedRamp.length > 0)
|
|
124
|
+
return rampSourceElapsedMs(clip.speedRamp, offsetMs);
|
|
125
|
+
return offsetMs * (clip.playbackRate ?? 1);
|
|
126
|
+
}
|
|
127
|
+
/** Absolute source time (ms) of the clip at timeline position `playheadMs`. */
|
|
128
|
+
export function clipSourceTimeMs(clip, playheadMs) {
|
|
129
|
+
return clip.sourceOffsetMs + clipSourceElapsedMs(clip, playheadMs - clip.startMs);
|
|
130
|
+
}
|
|
131
|
+
/** Timeline ms the clip needs to consume `sourceMs` of source. */
|
|
132
|
+
export function clipTimelineMsForSource(clip, sourceMs) {
|
|
133
|
+
if (clip.speedRamp && clip.speedRamp.length > 0)
|
|
134
|
+
return rampTimelineMsForSource(clip.speedRamp, sourceMs);
|
|
135
|
+
return sourceMs / (clip.playbackRate ?? 1);
|
|
136
|
+
}
|
|
137
|
+
/** Average rate over the clip (what a "speed" badge should show). */
|
|
138
|
+
export function clipAverageRate(clip) {
|
|
139
|
+
if (!clip.speedRamp || clip.speedRamp.length === 0)
|
|
140
|
+
return clip.playbackRate ?? 1;
|
|
141
|
+
return clip.durationMs > 0 ? rampSourceElapsedMs(clip.speedRamp, clip.durationMs) / clip.durationMs : 1;
|
|
142
|
+
}
|
|
143
|
+
export const SPEED_RAMP_PRESETS = [
|
|
144
|
+
{ id: 'speed-up', label: 'Speed up', description: 'Normal → 3× by the end', build: (d) => [{ t: 0, v: 1 }, { t: d, v: 3 }] },
|
|
145
|
+
{ id: 'slow-down', label: 'Slow down', description: 'Normal → 0.3× by the end', build: (d) => [{ t: 0, v: 1 }, { t: d, v: 0.3 }] },
|
|
146
|
+
{ id: 'slow-mo-hit', label: 'Slow-mo hit', description: 'Fast, dips to 0.25× in the middle, fast again', build: (d) => [
|
|
147
|
+
{ t: 0, v: 2 }, { t: d * 0.35, v: 2 }, { t: d * 0.45, v: 0.25 }, { t: d * 0.6, v: 0.25 }, { t: d * 0.7, v: 2 }, { t: d, v: 2 },
|
|
148
|
+
] },
|
|
149
|
+
{ id: 'punch-in', label: 'Punch in', description: 'Starts at 0.4×, whips to 3× then settles at 1×', build: (d) => [
|
|
150
|
+
{ t: 0, v: 0.4 }, { t: d * 0.25, v: 3 }, { t: d * 0.5, v: 1 }, { t: d, v: 1 },
|
|
151
|
+
] },
|
|
152
|
+
{ id: 'ease-in-out', label: 'Ease in-out', description: 'Slow at both ends, 2× in the middle', build: (d) => [
|
|
153
|
+
{ t: 0, v: 0.5 }, { t: d * 0.4, v: 2 }, { t: d * 0.6, v: 2 }, { t: d, v: 0.5 },
|
|
154
|
+
] },
|
|
155
|
+
];
|
|
156
|
+
export function speedRampPreset(id) {
|
|
157
|
+
return SPEED_RAMP_PRESETS.find((p) => p.id === id);
|
|
158
|
+
}
|
package/dist/store.d.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* and defaults to a no-op.
|
|
9
9
|
*/
|
|
10
10
|
import { type StoreApi } from 'zustand/vanilla';
|
|
11
|
+
import { type SpeedKeyframe } from './speed-ramp.js';
|
|
11
12
|
import type { TextOverlay } from './types/editor.js';
|
|
12
13
|
import type { Animations } from './types/animation.js';
|
|
13
14
|
import type { Effect } from './types/effect.js';
|
|
@@ -107,6 +108,12 @@ export interface TimelineClip {
|
|
|
107
108
|
opacity?: number;
|
|
108
109
|
/** Playback speed multiplier (default 1.0). Changes visual duration on timeline. */
|
|
109
110
|
playbackRate?: number;
|
|
111
|
+
/**
|
|
112
|
+
* Speed ramp: variable rate along the clip (keyframes in ms from the clip
|
|
113
|
+
* start on the timeline). Takes precedence over playbackRate when set —
|
|
114
|
+
* see speed-ramp.ts for the source-time math.
|
|
115
|
+
*/
|
|
116
|
+
speedRamp?: SpeedKeyframe[];
|
|
110
117
|
}
|
|
111
118
|
export type MaskType = 'none' | 'rounded' | 'circle' | 'star' | 'hexagon';
|
|
112
119
|
export type MaskAspectPreset = 'free' | '1:1' | '4:3' | '3:4' | '16:9' | '9:16';
|
|
@@ -220,6 +227,8 @@ export interface EditorState {
|
|
|
220
227
|
detachAudio: (clipId: string) => void;
|
|
221
228
|
setClipOpacity: (clipId: string, opacity: number) => void;
|
|
222
229
|
setClipSpeed: (clipId: string, rate: number) => void;
|
|
230
|
+
/** Set (or clear with undefined/[]) a speed ramp; the clip keeps its timeline duration unless the source runs out. */
|
|
231
|
+
setClipSpeedRamp: (clipId: string, ramp: SpeedKeyframe[] | undefined) => void;
|
|
223
232
|
setClipAnimation: (clipId: string, phase: 'in' | 'out', config: ClipAnimationConfig | undefined) => void;
|
|
224
233
|
addTrack: (type: TrackType) => TrackDefinition;
|
|
225
234
|
removeEmptyTracks: () => void;
|
|
@@ -253,4 +262,5 @@ export interface EditorState {
|
|
|
253
262
|
reset: () => void;
|
|
254
263
|
}
|
|
255
264
|
export type EditorStore = StoreApi<EditorState>;
|
|
265
|
+
export declare function uniqueId(prefix: string): string;
|
|
256
266
|
export declare function createEditorStore(deps?: EditorStoreDeps): EditorStore;
|
package/dist/store.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* and defaults to a no-op.
|
|
9
9
|
*/
|
|
10
10
|
import { createStore } from 'zustand/vanilla';
|
|
11
|
+
import { clipRateAt, clipSourceElapsedMs, clipTimelineMsForSource, normalizeSpeedRamp, rampSourceElapsedMs, rampTimelineMsForSource, shiftSpeedRamp, splitSpeedRamp } from './speed-ramp.js';
|
|
11
12
|
import { findFirstGap, findNearestValidPosition } from './timeline-utils.js';
|
|
12
13
|
// Color palettes for dynamic track assignment
|
|
13
14
|
const VISUAL_COLORS = [
|
|
@@ -98,6 +99,17 @@ function timelineContentMs(clips) {
|
|
|
98
99
|
function clamp(v, min, max) {
|
|
99
100
|
return Math.max(min, Math.min(max, v));
|
|
100
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
|
+
}
|
|
101
113
|
export function createEditorStore(deps = {}) {
|
|
102
114
|
return createStore()((set, get) => ({
|
|
103
115
|
// ── Initial state ──
|
|
@@ -246,7 +258,7 @@ export function createEditorStore(deps = {}) {
|
|
|
246
258
|
const availableMs = MAX_TIMELINE_MS - startMs;
|
|
247
259
|
if (availableMs <= 0)
|
|
248
260
|
return;
|
|
249
|
-
const clipId =
|
|
261
|
+
const clipId = uniqueId('clip');
|
|
250
262
|
const clip = {
|
|
251
263
|
id: clipId,
|
|
252
264
|
assetId: asset.id,
|
|
@@ -353,16 +365,19 @@ export function createEditorStore(deps = {}) {
|
|
|
353
365
|
sourceOffsetMs: 0,
|
|
354
366
|
};
|
|
355
367
|
}
|
|
356
|
-
const
|
|
368
|
+
const rateAtStart = clipRateAt(c, 0);
|
|
357
369
|
const maxTrim = c.durationMs - MIN_CLIP_MS;
|
|
358
370
|
// sourceOffsetMs is in source-time, so convert from timeline-delta
|
|
359
|
-
|
|
371
|
+
// (extending left plays the ramp's opening rate backwards).
|
|
372
|
+
const minDelta = Math.max(-c.sourceOffsetMs / rateAtStart, -(c.startMs - prevClipEnd));
|
|
360
373
|
const actualDelta = clamp(deltaMs, minDelta, maxTrim);
|
|
374
|
+
const sourceDelta = actualDelta >= 0 ? clipSourceElapsedMs(c, actualDelta) : actualDelta * rateAtStart;
|
|
361
375
|
return {
|
|
362
376
|
...c,
|
|
363
377
|
startMs: c.startMs + actualDelta,
|
|
364
378
|
durationMs: c.durationMs - actualDelta,
|
|
365
|
-
sourceOffsetMs: c.sourceOffsetMs +
|
|
379
|
+
sourceOffsetMs: c.sourceOffsetMs + sourceDelta,
|
|
380
|
+
...(c.speedRamp && c.speedRamp.length > 0 ? { speedRamp: shiftSpeedRamp(c.speedRamp, actualDelta) } : {}),
|
|
366
381
|
};
|
|
367
382
|
}),
|
|
368
383
|
}));
|
|
@@ -382,7 +397,9 @@ export function createEditorStore(deps = {}) {
|
|
|
382
397
|
const newDuration = clamp(c.durationMs + deltaMs, MIN_CLIP_MS, maxDuration);
|
|
383
398
|
return { ...c, durationMs: newDuration };
|
|
384
399
|
}
|
|
385
|
-
|
|
400
|
+
// Source left after what the clip already consumes, converted back to timeline ms.
|
|
401
|
+
const sourceLeft = c.originalDurationMs - c.sourceOffsetMs - clipSourceElapsedMs(c, c.durationMs);
|
|
402
|
+
const maxExtend = sourceLeft <= 0 ? 0 : clipTimelineMsForSource(c, clipSourceElapsedMs(c, c.durationMs) + sourceLeft) - c.durationMs;
|
|
386
403
|
const maxByNeighbor = maxDurationByTrack - c.durationMs;
|
|
387
404
|
const newDuration = clamp(c.durationMs + deltaMs, MIN_CLIP_MS, c.durationMs + Math.min(maxExtend, maxByNeighbor));
|
|
388
405
|
return { ...c, durationMs: newDuration };
|
|
@@ -421,11 +438,16 @@ export function createEditorStore(deps = {}) {
|
|
|
421
438
|
id: rightId,
|
|
422
439
|
startMs: splitAtMs,
|
|
423
440
|
durationMs: clip.durationMs - offsetInClip,
|
|
424
|
-
sourceOffsetMs: clip.mediaType === 'image' ? 0 : clip.sourceOffsetMs +
|
|
441
|
+
sourceOffsetMs: clip.mediaType === 'image' ? 0 : clip.sourceOffsetMs + clipSourceElapsedMs(clip, offsetInClip),
|
|
425
442
|
isAudioDetached: clip.isAudioDetached,
|
|
426
443
|
transitionIn: undefined, // cut point — no transition at the split
|
|
427
444
|
transitionOut: clip.transitionOut,
|
|
428
445
|
};
|
|
446
|
+
if (clip.speedRamp && clip.speedRamp.length > 0) {
|
|
447
|
+
const { left, right } = splitSpeedRamp(clip.speedRamp, offsetInClip);
|
|
448
|
+
leftClip.speedRamp = left;
|
|
449
|
+
rightClip.speedRamp = right;
|
|
450
|
+
}
|
|
429
451
|
// Find the original canvas object and duplicate it for both halves
|
|
430
452
|
const origObj = state.canvasObjects.find((o) => o.clipId === clip.id);
|
|
431
453
|
const baseObj = origObj ?? { x: 0, y: 0, w: 1, h: 1, rotation: 0 };
|
|
@@ -454,7 +476,7 @@ export function createEditorStore(deps = {}) {
|
|
|
454
476
|
if (!clip)
|
|
455
477
|
return;
|
|
456
478
|
state.pushUndo();
|
|
457
|
-
const newId =
|
|
479
|
+
const newId = uniqueId('clip');
|
|
458
480
|
const newStart = clip.startMs + clip.durationMs; // place right after original
|
|
459
481
|
const newClip = {
|
|
460
482
|
...clip,
|
|
@@ -464,7 +486,7 @@ export function createEditorStore(deps = {}) {
|
|
|
464
486
|
// Also duplicate canvas object if it's a visual clip
|
|
465
487
|
const canvasObj = state.canvasObjects.find((o) => o.clipId === id);
|
|
466
488
|
const newObjs = canvasObj
|
|
467
|
-
? [...state.canvasObjects, { ...canvasObj, id:
|
|
489
|
+
? [...state.canvasObjects, { ...canvasObj, id: uniqueId('obj'), clipId: newId }]
|
|
468
490
|
: state.canvasObjects;
|
|
469
491
|
set({
|
|
470
492
|
timelineClips: [...state.timelineClips, newClip],
|
|
@@ -530,7 +552,7 @@ export function createEditorStore(deps = {}) {
|
|
|
530
552
|
}
|
|
531
553
|
if (startMs === null || targetTrackId === null)
|
|
532
554
|
return;
|
|
533
|
-
const clipId =
|
|
555
|
+
const clipId = uniqueId('clip-color');
|
|
534
556
|
const clip = {
|
|
535
557
|
id: clipId,
|
|
536
558
|
assetId: `color-${color.replace('#', '')}`,
|
|
@@ -587,7 +609,7 @@ export function createEditorStore(deps = {}) {
|
|
|
587
609
|
// Ensure an audio track exists for this video's track
|
|
588
610
|
const audioTrackId = clip.trackId.replace('V', 'A');
|
|
589
611
|
state.ensureAudioTrack(clip.trackId);
|
|
590
|
-
const audioClipId =
|
|
612
|
+
const audioClipId = uniqueId('audio-detach');
|
|
591
613
|
const audioClip = {
|
|
592
614
|
id: audioClipId,
|
|
593
615
|
assetId: clip.assetId,
|
|
@@ -623,18 +645,45 @@ export function createEditorStore(deps = {}) {
|
|
|
623
645
|
if (!clip)
|
|
624
646
|
return;
|
|
625
647
|
const clampedRate = Math.max(0.25, Math.min(4, rate));
|
|
626
|
-
const prevRate = clip.playbackRate ?? 1;
|
|
627
648
|
// Adjust visual duration inversely with speed: a 10s clip at 2x shows as 5s
|
|
628
|
-
const sourceDuration = clip.durationMs
|
|
649
|
+
const sourceDuration = clipSourceElapsedMs(clip, clip.durationMs); // recover source duration (ramp-aware)
|
|
629
650
|
const newDuration = sourceDuration / clampedRate;
|
|
630
651
|
state.pushUndo();
|
|
631
652
|
set((s) => ({
|
|
632
653
|
timelineClips: s.timelineClips.map((c) => c.id === clipId
|
|
633
|
-
? { ...c, playbackRate: clampedRate, durationMs: newDuration }
|
|
654
|
+
? { ...c, playbackRate: clampedRate, durationMs: newDuration, speedRamp: undefined }
|
|
634
655
|
: c),
|
|
635
656
|
redoStack: [],
|
|
636
657
|
}));
|
|
637
658
|
},
|
|
659
|
+
setClipSpeedRamp: (clipId, ramp) => {
|
|
660
|
+
const state = get();
|
|
661
|
+
const clip = state.timelineClips.find((c) => c.id === clipId);
|
|
662
|
+
if (!clip)
|
|
663
|
+
return;
|
|
664
|
+
const normalized = normalizeSpeedRamp(ramp);
|
|
665
|
+
state.pushUndo();
|
|
666
|
+
if (normalized.length === 0) {
|
|
667
|
+
set((s) => ({
|
|
668
|
+
timelineClips: s.timelineClips.map((c) => (c.id === clipId ? { ...c, speedRamp: undefined } : c)),
|
|
669
|
+
redoStack: [],
|
|
670
|
+
}));
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
// Keep the timeline duration; if the ramp would run past the end of the
|
|
674
|
+
// source, shorten the clip to what the source can feed.
|
|
675
|
+
let durationMs = clip.durationMs;
|
|
676
|
+
if (clip.mediaType !== 'image' && clip.mediaType !== 'color') {
|
|
677
|
+
const sourceAvailable = Math.max(0, clip.originalDurationMs - clip.sourceOffsetMs);
|
|
678
|
+
if (rampSourceElapsedMs(normalized, durationMs) > sourceAvailable) {
|
|
679
|
+
durationMs = Math.max(MIN_CLIP_MS, rampTimelineMsForSource(normalized, sourceAvailable));
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
set((s) => ({
|
|
683
|
+
timelineClips: s.timelineClips.map((c) => c.id === clipId ? { ...c, speedRamp: normalized, playbackRate: undefined, durationMs } : c),
|
|
684
|
+
redoStack: [],
|
|
685
|
+
}));
|
|
686
|
+
},
|
|
638
687
|
setClipAnimation: (clipId, phase, config) => {
|
|
639
688
|
set((s) => ({
|
|
640
689
|
timelineClips: s.timelineClips.map((c) => c.id === clipId
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subtitle cues — shared helpers for headless callers (vivid-mcp) that get
|
|
3
|
+
* words from a transcription and want CapCut-style word-level captions.
|
|
4
|
+
* The web editor keeps the same shapes in its own state and renders them
|
|
5
|
+
* with the templates in src/lib/editor/templates.ts.
|
|
6
|
+
*/
|
|
7
|
+
import type { TranscriptionCue, TranscriptionWord } from './types/editor.js';
|
|
8
|
+
export interface CueInput {
|
|
9
|
+
id?: string;
|
|
10
|
+
text: string;
|
|
11
|
+
startMs: number;
|
|
12
|
+
endMs: number;
|
|
13
|
+
/** Optional per-word timing; synthesized evenly across the cue when missing. */
|
|
14
|
+
words?: Array<{
|
|
15
|
+
word: string;
|
|
16
|
+
startMs: number;
|
|
17
|
+
endMs: number;
|
|
18
|
+
confidence?: number;
|
|
19
|
+
}>;
|
|
20
|
+
}
|
|
21
|
+
export interface CuesFromWordsOptions {
|
|
22
|
+
/** Max words per cue (default 4 — short lines read best on 9:16). */
|
|
23
|
+
maxWords?: number;
|
|
24
|
+
/** Max cue length in ms (default 2500). */
|
|
25
|
+
maxDurationMs?: number;
|
|
26
|
+
/** A silence longer than this starts a new cue (default 600). */
|
|
27
|
+
maxGapMs?: number;
|
|
28
|
+
}
|
|
29
|
+
/** Words are synthesized evenly across the cue when the input has none. */
|
|
30
|
+
export declare function synthesizeWords(text: string, startMs: number, endMs: number): TranscriptionWord[];
|
|
31
|
+
/** Validate + fill a list of cues: ids, integer ms, sorted, words present. */
|
|
32
|
+
export declare function normalizeCues(input: CueInput[]): {
|
|
33
|
+
cues: TranscriptionCue[];
|
|
34
|
+
warnings: string[];
|
|
35
|
+
};
|
|
36
|
+
/** Group raw transcription words into short cues (what the Worker's /transcribe does, reusable on any word list). */
|
|
37
|
+
export declare function cuesFromWords(words: Array<{
|
|
38
|
+
word: string;
|
|
39
|
+
startMs: number;
|
|
40
|
+
endMs: number;
|
|
41
|
+
confidence?: number;
|
|
42
|
+
}>, opts?: CuesFromWordsOptions): TranscriptionCue[];
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subtitle cues — shared helpers for headless callers (vivid-mcp) that get
|
|
3
|
+
* words from a transcription and want CapCut-style word-level captions.
|
|
4
|
+
* The web editor keeps the same shapes in its own state and renders them
|
|
5
|
+
* with the templates in src/lib/editor/templates.ts.
|
|
6
|
+
*/
|
|
7
|
+
/** Words are synthesized evenly across the cue when the input has none. */
|
|
8
|
+
export function synthesizeWords(text, startMs, endMs) {
|
|
9
|
+
const tokens = text.split(/\s+/).filter(Boolean);
|
|
10
|
+
if (tokens.length === 0)
|
|
11
|
+
return [];
|
|
12
|
+
const span = Math.max(1, endMs - startMs);
|
|
13
|
+
const per = span / tokens.length;
|
|
14
|
+
return tokens.map((word, i) => ({
|
|
15
|
+
word,
|
|
16
|
+
startMs: Math.round(startMs + i * per),
|
|
17
|
+
endMs: Math.round(startMs + (i + 1) * per),
|
|
18
|
+
confidence: 1,
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
/** Validate + fill a list of cues: ids, integer ms, sorted, words present. */
|
|
22
|
+
export function normalizeCues(input) {
|
|
23
|
+
const warnings = [];
|
|
24
|
+
const cues = [];
|
|
25
|
+
input.forEach((c, i) => {
|
|
26
|
+
const text = (c.text ?? '').trim();
|
|
27
|
+
const startMs = Math.round(Number(c.startMs));
|
|
28
|
+
const endMs = Math.round(Number(c.endMs));
|
|
29
|
+
if (!text) {
|
|
30
|
+
warnings.push(`cue ${i + 1}: empty text, skipped`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) {
|
|
34
|
+
warnings.push(`cue ${i + 1}: invalid timing, skipped`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
let words;
|
|
38
|
+
if (c.words && c.words.length > 0) {
|
|
39
|
+
words = c.words
|
|
40
|
+
.filter((w) => w.word && Number.isFinite(w.startMs) && Number.isFinite(w.endMs))
|
|
41
|
+
.map((w) => ({ word: String(w.word), startMs: Math.round(w.startMs), endMs: Math.round(w.endMs), confidence: w.confidence ?? 1 }));
|
|
42
|
+
if (words.length === 0)
|
|
43
|
+
words = synthesizeWords(text, startMs, endMs);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
words = synthesizeWords(text, startMs, endMs);
|
|
47
|
+
}
|
|
48
|
+
cues.push({ id: c.id || `cue-${i + 1}-${startMs}`, text, startMs, endMs, words });
|
|
49
|
+
});
|
|
50
|
+
cues.sort((a, b) => a.startMs - b.startMs);
|
|
51
|
+
return { cues, warnings };
|
|
52
|
+
}
|
|
53
|
+
/** Group raw transcription words into short cues (what the Worker's /transcribe does, reusable on any word list). */
|
|
54
|
+
export function cuesFromWords(words, opts = {}) {
|
|
55
|
+
const maxWords = opts.maxWords ?? 4;
|
|
56
|
+
const maxDurationMs = opts.maxDurationMs ?? 2500;
|
|
57
|
+
const maxGapMs = opts.maxGapMs ?? 600;
|
|
58
|
+
const cues = [];
|
|
59
|
+
let current = [];
|
|
60
|
+
const flush = () => {
|
|
61
|
+
if (current.length === 0)
|
|
62
|
+
return;
|
|
63
|
+
const startMs = current[0].startMs;
|
|
64
|
+
const endMs = current[current.length - 1].endMs;
|
|
65
|
+
cues.push({ id: `cue-${cues.length + 1}-${startMs}`, text: current.map((w) => w.word).join(' '), startMs, endMs, words: current });
|
|
66
|
+
current = [];
|
|
67
|
+
};
|
|
68
|
+
for (const w of words) {
|
|
69
|
+
const word = { word: w.word, startMs: Math.round(w.startMs), endMs: Math.round(w.endMs), confidence: w.confidence ?? 1 };
|
|
70
|
+
if (current.length > 0) {
|
|
71
|
+
const gap = word.startMs - current[current.length - 1].endMs;
|
|
72
|
+
const span = word.endMs - current[0].startMs;
|
|
73
|
+
const endsSentence = /[.!?…]$/.test(current[current.length - 1].word);
|
|
74
|
+
if (current.length >= maxWords || gap > maxGapMs || span > maxDurationMs || endsSentence)
|
|
75
|
+
flush();
|
|
76
|
+
}
|
|
77
|
+
current.push(word);
|
|
78
|
+
}
|
|
79
|
+
flush();
|
|
80
|
+
return cues;
|
|
81
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text fill — gradient (optionally animated) fill for text overlays.
|
|
3
|
+
*
|
|
4
|
+
* Both the DOM preview (`background-clip: text`) and the Canvas2D export
|
|
5
|
+
* (`createLinearGradient`) draw the same thing from these helpers: a gradient
|
|
6
|
+
* of `colors` at `angle`, tiled 3× so it can scroll seamlessly, offset by a
|
|
7
|
+
* phase that depends only on the overlay's elapsed time — deterministic, so
|
|
8
|
+
* preview and export match frame for frame.
|
|
9
|
+
*/
|
|
10
|
+
import type { TextGradientFill, TextOverlay } from './types/editor.js';
|
|
11
|
+
export type TextFillPresetId = 'iridescent' | 'sunset' | 'ocean' | 'gold' | 'candy';
|
|
12
|
+
export interface TextFillPreset {
|
|
13
|
+
id: TextFillPresetId;
|
|
14
|
+
label: string;
|
|
15
|
+
fill: TextGradientFill;
|
|
16
|
+
}
|
|
17
|
+
export declare const TEXT_FILL_PRESETS: TextFillPreset[];
|
|
18
|
+
export declare function textFillPreset(id: string): TextFillPreset | undefined;
|
|
19
|
+
export declare function isGradientFill(fill: TextOverlay['fill']): fill is TextGradientFill;
|
|
20
|
+
/** Colors with the first one repeated at the end so a scrolling gradient wraps seamlessly. */
|
|
21
|
+
export declare function wrappedFillColors(fill: TextGradientFill): string[];
|
|
22
|
+
/**
|
|
23
|
+
* Scroll phase in [0, 1) at `elapsedMs` since the overlay appeared — the
|
|
24
|
+
* fraction of one gradient period the pattern has moved. 0 for static fills.
|
|
25
|
+
*/
|
|
26
|
+
export declare function textFillPhase(fill: TextGradientFill, elapsedMs: number): number;
|
|
27
|
+
/**
|
|
28
|
+
* Gradient geometry for a box `w × h` centred at the origin: the direction
|
|
29
|
+
* vector of CSS `linear-gradient(angle)` (0deg = up, 90deg = right) and the
|
|
30
|
+
* half-length that covers the box corners. Used by the Canvas2D export.
|
|
31
|
+
*/
|
|
32
|
+
export declare function gradientAxis(angleDeg: number, w: number, h: number): {
|
|
33
|
+
dx: number;
|
|
34
|
+
dy: number;
|
|
35
|
+
halfLength: number;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Color stops for a Canvas2D gradient that spans 3 periods (offset -1..2 in
|
|
39
|
+
* period units) shifted by `phase`, expressed as [offset 0..1, color] over a
|
|
40
|
+
* gradient line 3× the box length. Callers create the gradient from
|
|
41
|
+
* (-halfLength*3 … +halfLength*3) on the axis and add these stops.
|
|
42
|
+
*/
|
|
43
|
+
export declare function shiftedGradientStops(fill: TextGradientFill, phase: number): Array<[number, string]>;
|
|
44
|
+
/**
|
|
45
|
+
* CSS for the DOM preview of a text box `w × h` px: a 3×3 background whose
|
|
46
|
+
* gradient line carries the same three tiles as the Canvas2D export, centred
|
|
47
|
+
* on the box and slid toward the gradient start by `phase` periods. Pair it
|
|
48
|
+
* with `-webkit-background-clip: text; color: transparent`.
|
|
49
|
+
*/
|
|
50
|
+
export declare function textFillCss(fill: TextGradientFill, phase: number, w: number, h: number): {
|
|
51
|
+
backgroundImage: string;
|
|
52
|
+
backgroundSize: string;
|
|
53
|
+
backgroundPosition: string;
|
|
54
|
+
backgroundRepeat: string;
|
|
55
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text fill — gradient (optionally animated) fill for text overlays.
|
|
3
|
+
*
|
|
4
|
+
* Both the DOM preview (`background-clip: text`) and the Canvas2D export
|
|
5
|
+
* (`createLinearGradient`) draw the same thing from these helpers: a gradient
|
|
6
|
+
* of `colors` at `angle`, tiled 3× so it can scroll seamlessly, offset by a
|
|
7
|
+
* phase that depends only on the overlay's elapsed time — deterministic, so
|
|
8
|
+
* preview and export match frame for frame.
|
|
9
|
+
*/
|
|
10
|
+
export const TEXT_FILL_PRESETS = [
|
|
11
|
+
{ id: 'iridescent', label: 'Iridescent', fill: { type: 'gradient', colors: ['#ff6ec4', '#7873f5', '#4ade80', '#facc15', '#ff6ec4'], angle: 100, animate: 'shift', speed: 0.35 } },
|
|
12
|
+
{ id: 'sunset', label: 'Sunset', fill: { type: 'gradient', colors: ['#f97316', '#ec4899', '#8b5cf6', '#f97316'], angle: 90, animate: 'shift', speed: 0.25 } },
|
|
13
|
+
{ id: 'ocean', label: 'Ocean', fill: { type: 'gradient', colors: ['#22d3ee', '#3b82f6', '#a78bfa', '#22d3ee'], angle: 120, animate: 'shift', speed: 0.3 } },
|
|
14
|
+
{ id: 'gold', label: 'Gold', fill: { type: 'gradient', colors: ['#fde68a', '#d97706', '#fef3c7', '#b45309', '#fde68a'], angle: 75, animate: 'shift', speed: 0.5 } },
|
|
15
|
+
{ id: 'candy', label: 'Candy', fill: { type: 'gradient', colors: ['#f472b6', '#fb7185', '#fbbf24', '#f472b6'], angle: 45, animate: 'none' } },
|
|
16
|
+
];
|
|
17
|
+
export function textFillPreset(id) {
|
|
18
|
+
return TEXT_FILL_PRESETS.find((p) => p.id === id);
|
|
19
|
+
}
|
|
20
|
+
export function isGradientFill(fill) {
|
|
21
|
+
return !!fill && fill.type === 'gradient' && Array.isArray(fill.colors) && fill.colors.length >= 2;
|
|
22
|
+
}
|
|
23
|
+
/** Colors with the first one repeated at the end so a scrolling gradient wraps seamlessly. */
|
|
24
|
+
export function wrappedFillColors(fill) {
|
|
25
|
+
const c = fill.colors;
|
|
26
|
+
return c[0] === c[c.length - 1] ? c : [...c, c[0]];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Scroll phase in [0, 1) at `elapsedMs` since the overlay appeared — the
|
|
30
|
+
* fraction of one gradient period the pattern has moved. 0 for static fills.
|
|
31
|
+
*/
|
|
32
|
+
export function textFillPhase(fill, elapsedMs) {
|
|
33
|
+
if ((fill.animate ?? 'none') !== 'shift')
|
|
34
|
+
return 0;
|
|
35
|
+
const cyclesPerSecond = fill.speed ?? 0.35;
|
|
36
|
+
const phase = (elapsedMs / 1000) * cyclesPerSecond;
|
|
37
|
+
return phase - Math.floor(phase);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Gradient geometry for a box `w × h` centred at the origin: the direction
|
|
41
|
+
* vector of CSS `linear-gradient(angle)` (0deg = up, 90deg = right) and the
|
|
42
|
+
* half-length that covers the box corners. Used by the Canvas2D export.
|
|
43
|
+
*/
|
|
44
|
+
export function gradientAxis(angleDeg, w, h) {
|
|
45
|
+
const rad = (angleDeg * Math.PI) / 180;
|
|
46
|
+
const dx = Math.sin(rad);
|
|
47
|
+
const dy = -Math.cos(rad);
|
|
48
|
+
// Same extent CSS uses: the gradient line spans the box's projection.
|
|
49
|
+
const halfLength = (Math.abs(w * dx) + Math.abs(h * dy)) / 2;
|
|
50
|
+
return { dx, dy, halfLength };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Color stops for a Canvas2D gradient that spans 3 periods (offset -1..2 in
|
|
54
|
+
* period units) shifted by `phase`, expressed as [offset 0..1, color] over a
|
|
55
|
+
* gradient line 3× the box length. Callers create the gradient from
|
|
56
|
+
* (-halfLength*3 … +halfLength*3) on the axis and add these stops.
|
|
57
|
+
*/
|
|
58
|
+
export function shiftedGradientStops(fill, phase) {
|
|
59
|
+
const colors = wrappedFillColors(fill);
|
|
60
|
+
const n = colors.length - 1;
|
|
61
|
+
const stops = [];
|
|
62
|
+
// Three tiles: t = -1, 0, 1 (period units), each tile spans 1/3 of the line.
|
|
63
|
+
for (let tile = -1; tile <= 1; tile++) {
|
|
64
|
+
for (let i = 0; i <= n; i++) {
|
|
65
|
+
const u = tile + i / n - phase; // period units, shifted left as phase grows
|
|
66
|
+
const offset = (u + 1) / 3; // map [-1, 2] → [0, 1]
|
|
67
|
+
if (offset < 0 || offset > 1)
|
|
68
|
+
continue;
|
|
69
|
+
stops.push([offset, colors[i]]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
stops.sort((a, b) => a[0] - b[0]);
|
|
73
|
+
// Ensure ends are covered.
|
|
74
|
+
if (stops.length === 0 || stops[0][0] > 0)
|
|
75
|
+
stops.unshift([0, colors[0]]);
|
|
76
|
+
if (stops[stops.length - 1][0] < 1)
|
|
77
|
+
stops.push([1, colors[n]]);
|
|
78
|
+
return stops;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* CSS for the DOM preview of a text box `w × h` px: a 3×3 background whose
|
|
82
|
+
* gradient line carries the same three tiles as the Canvas2D export, centred
|
|
83
|
+
* on the box and slid toward the gradient start by `phase` periods. Pair it
|
|
84
|
+
* with `-webkit-background-clip: text; color: transparent`.
|
|
85
|
+
*/
|
|
86
|
+
export function textFillCss(fill, phase, w, h) {
|
|
87
|
+
const stops = shiftedGradientStops(fill, 0).map(([o, c]) => `${c} ${(o * 100).toFixed(3)}%`);
|
|
88
|
+
const { dx, dy, halfLength } = gradientAxis(fill.angle, w, h);
|
|
89
|
+
const period = halfLength * 2;
|
|
90
|
+
// Base: centre the 3× image on the box; then slide one period per phase toward the start.
|
|
91
|
+
const x = -w - phase * period * dx;
|
|
92
|
+
const y = -h - phase * period * dy;
|
|
93
|
+
return {
|
|
94
|
+
backgroundImage: `linear-gradient(${fill.angle}deg, ${stops.join(', ')})`,
|
|
95
|
+
backgroundSize: '300% 300%',
|
|
96
|
+
backgroundRepeat: 'no-repeat',
|
|
97
|
+
backgroundPosition: `${x.toFixed(2)}px ${y.toFixed(2)}px`,
|
|
98
|
+
};
|
|
99
|
+
}
|
package/dist/text-presets.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TextOverlay } from './types/editor.js';
|
|
2
|
-
export type TextPresetId = 'title' | 'subtitle' | 'cta' | 'price' | 'lower-third' | 'minimal';
|
|
2
|
+
export type TextPresetId = 'title' | 'subtitle' | 'cta' | 'price' | 'lower-third' | 'minimal' | 'iridescent';
|
|
3
3
|
export interface TextPreset {
|
|
4
4
|
id: TextPresetId;
|
|
5
5
|
name: string;
|
package/dist/text-presets.js
CHANGED
|
@@ -163,6 +163,34 @@ export const TEXT_PRESETS = [
|
|
|
163
163
|
opacity: 1,
|
|
164
164
|
},
|
|
165
165
|
},
|
|
166
|
+
{
|
|
167
|
+
id: 'iridescent',
|
|
168
|
+
name: 'Iridescent',
|
|
169
|
+
icon: '✦',
|
|
170
|
+
defaults: {
|
|
171
|
+
text: 'Shine',
|
|
172
|
+
fontFamily: "'Archivo Black', sans-serif",
|
|
173
|
+
fontSize: 64,
|
|
174
|
+
fontWeight: 400,
|
|
175
|
+
color: '#FFFFFF',
|
|
176
|
+
fill: { type: 'gradient', colors: ['#ff6ec4', '#7873f5', '#4ade80', '#facc15', '#ff6ec4'], angle: 100, animate: 'shift', speed: 0.35 },
|
|
177
|
+
backgroundColor: 'transparent',
|
|
178
|
+
backgroundOpacity: 0,
|
|
179
|
+
strokeColor: '#000000',
|
|
180
|
+
strokeWidth: 0,
|
|
181
|
+
shadowColor: '#000000',
|
|
182
|
+
shadowBlur: 12,
|
|
183
|
+
shadowOffsetX: 0,
|
|
184
|
+
shadowOffsetY: 4,
|
|
185
|
+
textTransform: 'uppercase',
|
|
186
|
+
textAlign: 'center',
|
|
187
|
+
animation: 'pop',
|
|
188
|
+
animationDurationMs: 400,
|
|
189
|
+
padding: 8,
|
|
190
|
+
borderRadius: 0,
|
|
191
|
+
opacity: 1,
|
|
192
|
+
},
|
|
193
|
+
},
|
|
166
194
|
];
|
|
167
195
|
/** Default values for a new text overlay */
|
|
168
196
|
export function createTextOverlay(preset) {
|
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/dist/types/editor.d.ts
CHANGED
|
@@ -17,6 +17,13 @@ export interface TranscriptionResult {
|
|
|
17
17
|
language: string;
|
|
18
18
|
}
|
|
19
19
|
export type SubtitleAnimation = 'fade' | 'pop' | 'slide' | 'typewriter' | 'word-highlight' | 'word-popup' | 'outline-reveal' | 'ugc-pop' | 'karaoke-marker' | 'cinematic-fade' | 'neon-cyberpunk' | 'playful-wiggle';
|
|
20
|
+
/**
|
|
21
|
+
* Ids of the word-level subtitle templates shipped by the web editor
|
|
22
|
+
* (src/lib/editor/templates.ts). Kept here so headless callers (vivid-mcp)
|
|
23
|
+
* can validate a `templateId` without the browser template table.
|
|
24
|
+
*/
|
|
25
|
+
export declare const SUBTITLE_TEMPLATE_IDS: readonly ["outline-reveal", "ugc-pop", "karaoke-marker", "cinematic-fade", "neon-cyberpunk", "playful-wiggle"];
|
|
26
|
+
export type SubtitleTemplateId = typeof SUBTITLE_TEMPLATE_IDS[number];
|
|
20
27
|
/** Vertical position as percentage from top (0 = top, 100 = bottom) */
|
|
21
28
|
export type SubtitlePosition = number;
|
|
22
29
|
export interface SubtitleStyleConfig {
|
|
@@ -60,6 +67,19 @@ export type TextAnimation = 'none' | 'fade-in' | 'fade-out' | 'fade-in-out' | 's
|
|
|
60
67
|
import type { Animations } from './animation.js';
|
|
61
68
|
import type { Effect } from './effect.js';
|
|
62
69
|
import type { AudioReactiveModifier } from './audioReactive.js';
|
|
70
|
+
/** Gradient fill for a text overlay — replaces `color` for the glyphs when set. */
|
|
71
|
+
export interface TextGradientFill {
|
|
72
|
+
type: 'gradient';
|
|
73
|
+
/** 2+ CSS colors. Repeat the first at the end (or let the renderer do it) for a seamless scroll. */
|
|
74
|
+
colors: string[];
|
|
75
|
+
/** CSS convention: 0 = bottom→top, 90 = left→right */
|
|
76
|
+
angle: number;
|
|
77
|
+
/** 'shift' scrolls the gradient across the text over time; 'none' keeps it static */
|
|
78
|
+
animate?: 'none' | 'shift';
|
|
79
|
+
/** gradient periods per second when animated (default 0.35) */
|
|
80
|
+
speed?: number;
|
|
81
|
+
}
|
|
82
|
+
export type TextFill = TextGradientFill;
|
|
63
83
|
/** Text overlay on the canvas with timing, position, style, and animation */
|
|
64
84
|
export interface TextOverlay {
|
|
65
85
|
id: string;
|
|
@@ -76,6 +96,8 @@ export interface TextOverlay {
|
|
|
76
96
|
fontStyle?: 'normal' | 'italic';
|
|
77
97
|
textDecoration?: 'none' | 'underline' | 'line-through' | 'overline';
|
|
78
98
|
color: string;
|
|
99
|
+
/** Optional gradient fill for the glyphs (see TextGradientFill); `color` stays as the fallback. */
|
|
100
|
+
fill?: TextFill;
|
|
79
101
|
backgroundColor: string;
|
|
80
102
|
backgroundOpacity: number;
|
|
81
103
|
strokeColor: string;
|
package/dist/types/editor.js
CHANGED
|
@@ -1 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Ids of the word-level subtitle templates shipped by the web editor
|
|
3
|
+
* (src/lib/editor/templates.ts). Kept here so headless callers (vivid-mcp)
|
|
4
|
+
* can validate a `templateId` without the browser template table.
|
|
5
|
+
*/
|
|
6
|
+
export const SUBTITLE_TEMPLATE_IDS = ['outline-reveal', 'ugc-pop', 'karaoke-marker', 'cinematic-fade', 'neon-cyberpunk', 'playful-wiggle'];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vivid-editor-core",
|
|
3
|
-
"version": "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",
|