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.
@@ -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 between cuts. 4 = 1 cut per measure.
159
+ * `beatInterval` — how many beats (or peaks, with grid 'peaks') between
160
+ * cuts. 4 = 1 cut per measure.
161
+ *
162
+ * `grid` — 'beats' (default, constant BPM grid) or 'peaks' (detected
163
+ * transients from the server analysis; `minStrength` filters weak ones).
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;
@@ -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'}). Wait a moment after dropping audio for analysis to complete.`);
312
+ errors.push(`CUT_TO_BEAT: audio "${audioClip.name}" has no beat analysis (status: ${analysis ?? 'unknown'}). In the browser wait a moment after dropping the audio; via the API run POST /api/ai/audio-analysis on the asset first.`);
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 = analysis.beats
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 beats (${beatsAbsolute.length}) for beatInterval ${beatInterval}`);
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);
@@ -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 = `clip-${Date.now()}`;
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 rate = c.playbackRate ?? 1;
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
- const minDelta = Math.max(-c.sourceOffsetMs / rate, -(c.startMs - prevClipEnd));
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 + actualDelta * rate,
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
- const maxExtend = c.originalDurationMs - c.sourceOffsetMs - c.durationMs;
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 + offsetInClip * (clip.playbackRate ?? 1),
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 = `clip-${Date.now()}`;
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: `obj-${Date.now()}`, clipId: newId }]
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 = `clip-color-${Date.now()}`;
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 = `audio-detach-${Date.now()}`;
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 * prevRate; // recover source duration
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
+ }
@@ -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;
@@ -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) {
@@ -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
  }
@@ -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;
@@ -1 +1,6 @@
1
- export {};
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.1.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",