vivid-editor-core 0.1.0 → 0.2.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: {
@@ -202,7 +235,7 @@ export interface SetMaskCommand {
202
235
  mask: MaskV2;
203
236
  };
204
237
  }
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;
238
+ 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
239
  export interface EditorSnapshot {
207
240
  timelineClips: TimelineClip[];
208
241
  canvasObjects: CanvasObject[];
@@ -230,6 +263,15 @@ export interface OrchestratorActions {
230
263
  setClipVolume: (id: string, volume: number) => void;
231
264
  setClipOpacity: (id: string, opacity: number) => void;
232
265
  setClipSpeed: (id: string, rate: number) => void;
266
+ setClipSpeedRamp: (id: string, ramp: SpeedKeyframe[] | undefined) => void;
267
+ /** Subtitle state lives outside the store (editor page / project extras) — optional wiring. */
268
+ setSubtitles?: (patch: {
269
+ cues?: import('./types/editor.js').TranscriptionCue[] | null;
270
+ templateId?: string;
271
+ position?: number;
272
+ styleOverrides?: Record<string, unknown>;
273
+ layout?: Partial<SubtitleLayout>;
274
+ }) => void;
233
275
  setSelectedClipId: (id: string | null) => void;
234
276
  splitClip: (splitAtMs: number) => void;
235
277
  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',
@@ -591,6 +594,62 @@ export function executeAiCommands(commands, snapshot, actions) {
591
594
  }
592
595
  break;
593
596
  }
597
+ // ── Speed ramp ────────────────────────────────
598
+ case 'SET_SPEED_RAMP': {
599
+ const { clipId, keyframes, preset } = cmd.payload;
600
+ const clip = snapshot.timelineClips.find((c) => c.id === clipId);
601
+ if (!clip) {
602
+ errors.push(`SET_SPEED_RAMP: clip "${clipId}" not found`);
603
+ break;
604
+ }
605
+ if (clip.mediaType === 'image' || clip.mediaType === 'color') {
606
+ errors.push(`SET_SPEED_RAMP: clip "${clipId}" is a ${clip.mediaType}, speed only applies to video/audio`);
607
+ break;
608
+ }
609
+ let ramp;
610
+ if (preset && preset !== 'none') {
611
+ const p = speedRampPreset(preset);
612
+ if (!p) {
613
+ errors.push(`SET_SPEED_RAMP: unknown preset "${preset}" (speed-up, slow-down, slow-mo-hit, punch-in, ease-in-out)`);
614
+ break;
615
+ }
616
+ ramp = p.build(clip.durationMs);
617
+ }
618
+ else if (Array.isArray(keyframes) && keyframes.length > 0) {
619
+ ramp = keyframes;
620
+ }
621
+ actions.setClipSpeedRamp(clipId, ramp);
622
+ applied++;
623
+ break;
624
+ }
625
+ // ── Subtitles ─────────────────────────────────
626
+ case 'SET_SUBTITLES': {
627
+ if (!actions.setSubtitles) {
628
+ errors.push('SET_SUBTITLES: not available in this editor context');
629
+ break;
630
+ }
631
+ const { cues, templateId, position, styleOverrides, layout } = cmd.payload;
632
+ const patch = {};
633
+ if (cues === null)
634
+ patch.cues = null;
635
+ else if (Array.isArray(cues)) {
636
+ const n = normalizeCues(cues);
637
+ if (n.warnings.length > 0)
638
+ console.warn('[ai-orchestrator] SET_SUBTITLES:', n.warnings.join('; '));
639
+ patch.cues = n.cues;
640
+ }
641
+ if (templateId !== undefined)
642
+ patch.templateId = templateId;
643
+ if (position !== undefined)
644
+ patch.position = Math.max(0, Math.min(100, position));
645
+ if (styleOverrides !== undefined)
646
+ patch.styleOverrides = styleOverrides;
647
+ if (layout !== undefined)
648
+ patch.layout = layout;
649
+ actions.setSubtitles(patch);
650
+ applied++;
651
+ break;
652
+ }
594
653
  case 'SET_MASK': {
595
654
  const { objectId, mask } = cmd.payload;
596
655
  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;
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 = [
@@ -353,16 +354,19 @@ export function createEditorStore(deps = {}) {
353
354
  sourceOffsetMs: 0,
354
355
  };
355
356
  }
356
- const rate = c.playbackRate ?? 1;
357
+ const rateAtStart = clipRateAt(c, 0);
357
358
  const maxTrim = c.durationMs - MIN_CLIP_MS;
358
359
  // sourceOffsetMs is in source-time, so convert from timeline-delta
359
- const minDelta = Math.max(-c.sourceOffsetMs / rate, -(c.startMs - prevClipEnd));
360
+ // (extending left plays the ramp's opening rate backwards).
361
+ const minDelta = Math.max(-c.sourceOffsetMs / rateAtStart, -(c.startMs - prevClipEnd));
360
362
  const actualDelta = clamp(deltaMs, minDelta, maxTrim);
363
+ const sourceDelta = actualDelta >= 0 ? clipSourceElapsedMs(c, actualDelta) : actualDelta * rateAtStart;
361
364
  return {
362
365
  ...c,
363
366
  startMs: c.startMs + actualDelta,
364
367
  durationMs: c.durationMs - actualDelta,
365
- sourceOffsetMs: c.sourceOffsetMs + actualDelta * rate,
368
+ sourceOffsetMs: c.sourceOffsetMs + sourceDelta,
369
+ ...(c.speedRamp && c.speedRamp.length > 0 ? { speedRamp: shiftSpeedRamp(c.speedRamp, actualDelta) } : {}),
366
370
  };
367
371
  }),
368
372
  }));
@@ -382,7 +386,9 @@ export function createEditorStore(deps = {}) {
382
386
  const newDuration = clamp(c.durationMs + deltaMs, MIN_CLIP_MS, maxDuration);
383
387
  return { ...c, durationMs: newDuration };
384
388
  }
385
- const maxExtend = c.originalDurationMs - c.sourceOffsetMs - c.durationMs;
389
+ // Source left after what the clip already consumes, converted back to timeline ms.
390
+ const sourceLeft = c.originalDurationMs - c.sourceOffsetMs - clipSourceElapsedMs(c, c.durationMs);
391
+ const maxExtend = sourceLeft <= 0 ? 0 : clipTimelineMsForSource(c, clipSourceElapsedMs(c, c.durationMs) + sourceLeft) - c.durationMs;
386
392
  const maxByNeighbor = maxDurationByTrack - c.durationMs;
387
393
  const newDuration = clamp(c.durationMs + deltaMs, MIN_CLIP_MS, c.durationMs + Math.min(maxExtend, maxByNeighbor));
388
394
  return { ...c, durationMs: newDuration };
@@ -421,11 +427,16 @@ export function createEditorStore(deps = {}) {
421
427
  id: rightId,
422
428
  startMs: splitAtMs,
423
429
  durationMs: clip.durationMs - offsetInClip,
424
- sourceOffsetMs: clip.mediaType === 'image' ? 0 : clip.sourceOffsetMs + offsetInClip * (clip.playbackRate ?? 1),
430
+ sourceOffsetMs: clip.mediaType === 'image' ? 0 : clip.sourceOffsetMs + clipSourceElapsedMs(clip, offsetInClip),
425
431
  isAudioDetached: clip.isAudioDetached,
426
432
  transitionIn: undefined, // cut point — no transition at the split
427
433
  transitionOut: clip.transitionOut,
428
434
  };
435
+ if (clip.speedRamp && clip.speedRamp.length > 0) {
436
+ const { left, right } = splitSpeedRamp(clip.speedRamp, offsetInClip);
437
+ leftClip.speedRamp = left;
438
+ rightClip.speedRamp = right;
439
+ }
429
440
  // Find the original canvas object and duplicate it for both halves
430
441
  const origObj = state.canvasObjects.find((o) => o.clipId === clip.id);
431
442
  const baseObj = origObj ?? { x: 0, y: 0, w: 1, h: 1, rotation: 0 };
@@ -623,18 +634,45 @@ export function createEditorStore(deps = {}) {
623
634
  if (!clip)
624
635
  return;
625
636
  const clampedRate = Math.max(0.25, Math.min(4, rate));
626
- const prevRate = clip.playbackRate ?? 1;
627
637
  // Adjust visual duration inversely with speed: a 10s clip at 2x shows as 5s
628
- const sourceDuration = clip.durationMs * prevRate; // recover source duration
638
+ const sourceDuration = clipSourceElapsedMs(clip, clip.durationMs); // recover source duration (ramp-aware)
629
639
  const newDuration = sourceDuration / clampedRate;
630
640
  state.pushUndo();
631
641
  set((s) => ({
632
642
  timelineClips: s.timelineClips.map((c) => c.id === clipId
633
- ? { ...c, playbackRate: clampedRate, durationMs: newDuration }
643
+ ? { ...c, playbackRate: clampedRate, durationMs: newDuration, speedRamp: undefined }
634
644
  : c),
635
645
  redoStack: [],
636
646
  }));
637
647
  },
648
+ setClipSpeedRamp: (clipId, ramp) => {
649
+ const state = get();
650
+ const clip = state.timelineClips.find((c) => c.id === clipId);
651
+ if (!clip)
652
+ return;
653
+ const normalized = normalizeSpeedRamp(ramp);
654
+ state.pushUndo();
655
+ if (normalized.length === 0) {
656
+ set((s) => ({
657
+ timelineClips: s.timelineClips.map((c) => (c.id === clipId ? { ...c, speedRamp: undefined } : c)),
658
+ redoStack: [],
659
+ }));
660
+ return;
661
+ }
662
+ // Keep the timeline duration; if the ramp would run past the end of the
663
+ // source, shorten the clip to what the source can feed.
664
+ let durationMs = clip.durationMs;
665
+ if (clip.mediaType !== 'image' && clip.mediaType !== 'color') {
666
+ const sourceAvailable = Math.max(0, clip.originalDurationMs - clip.sourceOffsetMs);
667
+ if (rampSourceElapsedMs(normalized, durationMs) > sourceAvailable) {
668
+ durationMs = Math.max(MIN_CLIP_MS, rampTimelineMsForSource(normalized, sourceAvailable));
669
+ }
670
+ }
671
+ set((s) => ({
672
+ timelineClips: s.timelineClips.map((c) => c.id === clipId ? { ...c, speedRamp: normalized, playbackRate: undefined, durationMs } : c),
673
+ redoStack: [],
674
+ }));
675
+ },
638
676
  setClipAnimation: (clipId, phase, config) => {
639
677
  set((s) => ({
640
678
  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) {
@@ -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.2.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",