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.
- package/dist/ai-orchestrator.d.ts +43 -1
- package/dist/ai-orchestrator.js +59 -0
- package/dist/headless.d.ts +4 -2
- package/dist/headless.js +42 -4
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/speed-ramp.d.ts +59 -0
- package/dist/speed-ramp.js +158 -0
- package/dist/store.d.ts +9 -0
- package/dist/store.js +46 -8
- package/dist/subtitles.d.ts +42 -0
- package/dist/subtitles.js +81 -0
- package/dist/text-fill.d.ts +55 -0
- package/dist/text-fill.js +99 -0
- package/dist/text-presets.d.ts +1 -1
- package/dist/text-presets.js +28 -0
- package/dist/types/editor.d.ts +22 -0
- package/dist/types/editor.js +6 -1
- package/package.json +1 -1
|
@@ -12,6 +12,9 @@ import type { Animations, Animation as AnimationCurve, AnimatableProp } from './
|
|
|
12
12
|
import type { Effect } from './types/effect.js';
|
|
13
13
|
import type { Mask as MaskV2 } from './types/mask.js';
|
|
14
14
|
import type { AudioReactiveModifier } from './types/audioReactive.js';
|
|
15
|
+
import { type SpeedKeyframe, type SpeedRampPresetId } from './speed-ramp.js';
|
|
16
|
+
import { type CueInput } from './subtitles.js';
|
|
17
|
+
import type { SubtitleLayout } from './types/editor.js';
|
|
15
18
|
export interface AddTextCommand {
|
|
16
19
|
type: 'ADD_TEXT';
|
|
17
20
|
payload: TextOverlay;
|
|
@@ -81,6 +84,36 @@ export interface SplitClipCommand {
|
|
|
81
84
|
splitAtMs: number;
|
|
82
85
|
};
|
|
83
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Variable speed inside a clip. Either explicit keyframes (t = ms from the
|
|
89
|
+
* clip start on the timeline, v = rate) or a preset built for the clip's
|
|
90
|
+
* duration. `preset: 'none'` / empty keyframes remove the ramp.
|
|
91
|
+
*/
|
|
92
|
+
export interface SetSpeedRampCommand {
|
|
93
|
+
type: 'SET_SPEED_RAMP';
|
|
94
|
+
payload: {
|
|
95
|
+
clipId: string;
|
|
96
|
+
keyframes?: SpeedKeyframe[] | null;
|
|
97
|
+
preset?: SpeedRampPresetId | 'none';
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Word-level subtitle state of the project (cues + template + placement). */
|
|
101
|
+
export interface SubtitlesPayload {
|
|
102
|
+
/** Cues with optional per-word timing; null clears the subtitles. */
|
|
103
|
+
cues?: CueInput[] | null;
|
|
104
|
+
/** One of SUBTITLE_TEMPLATE_IDS (outline-reveal, ugc-pop, karaoke-marker, cinematic-fade, neon-cyberpunk, playful-wiggle). */
|
|
105
|
+
templateId?: string;
|
|
106
|
+
/** Vertical position 0 (top) – 100 (bottom). */
|
|
107
|
+
position?: number;
|
|
108
|
+
/** Overrides on the template style (fontSize, color, highlightColor, …). */
|
|
109
|
+
styleOverrides?: Record<string, unknown>;
|
|
110
|
+
/** Canvas-space layout (x, y, scale, maxWidthPct, rotation), normalized 0-1. */
|
|
111
|
+
layout?: Partial<SubtitleLayout>;
|
|
112
|
+
}
|
|
113
|
+
export interface SetSubtitlesCommand {
|
|
114
|
+
type: 'SET_SUBTITLES';
|
|
115
|
+
payload: SubtitlesPayload;
|
|
116
|
+
}
|
|
84
117
|
export interface DuplicateClipCommand {
|
|
85
118
|
type: 'DUPLICATE_CLIP';
|
|
86
119
|
payload: {
|
|
@@ -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;
|
package/dist/ai-orchestrator.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* always creates both a TimelineClip AND a CanvasObject).
|
|
7
7
|
*/
|
|
8
8
|
import { createTextOverlay } from './text-presets.js';
|
|
9
|
+
import { speedRampPreset } from './speed-ramp.js';
|
|
10
|
+
import { normalizeCues } from './subtitles.js';
|
|
9
11
|
/**
|
|
10
12
|
* Take whatever the LLM produced for an ADD_TEXT payload and:
|
|
11
13
|
* - clamp x/y to [0, 1] so the overlay can't land off-canvas
|
|
@@ -68,6 +70,7 @@ export function executeAiCommands(commands, snapshot, actions) {
|
|
|
68
70
|
'ADD_CLIP', 'UPDATE_CLIP', 'REMOVE_CLIP',
|
|
69
71
|
'UPDATE_CANVAS_OBJECT', 'SET_CANVAS_PRESET',
|
|
70
72
|
'ADD_TRACK', 'SPLIT_CLIP', 'DUPLICATE_CLIP',
|
|
73
|
+
'SET_SPEED_RAMP', 'SET_SUBTITLES',
|
|
71
74
|
'SET_TRANSITION', 'SET_CLIP_ANIMATION',
|
|
72
75
|
'SET_CINEMATIC_LOOK', 'DETACH_AUDIO',
|
|
73
76
|
'CUT_TO_BEAT',
|
|
@@ -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);
|
package/dist/headless.d.ts
CHANGED
|
@@ -8,8 +8,10 @@ import { type AiCommand, type EditorSnapshot, type OrchestratorActions } from '.
|
|
|
8
8
|
import { type ApplyProjectOptions, type ProjectExtras } from './project.js';
|
|
9
9
|
import { type ProjectFile } from './types/project.js';
|
|
10
10
|
export declare function snapshotOf(state: EditorState): EditorSnapshot;
|
|
11
|
+
/** Subtitle writer over the project extras (the browser keeps this state in the editor page). */
|
|
12
|
+
export declare function subtitleWriter(extras: ProjectExtras): NonNullable<OrchestratorActions['setSubtitles']>;
|
|
11
13
|
/** Same wiring as the browser AI panel (ai-chat-panel.tsx). */
|
|
12
|
-
export declare function actionsOf(state: EditorState): OrchestratorActions;
|
|
14
|
+
export declare function actionsOf(state: EditorState, extras?: ProjectExtras): OrchestratorActions;
|
|
13
15
|
export interface ApplyResult {
|
|
14
16
|
applied: number;
|
|
15
17
|
errors: string[];
|
|
@@ -22,7 +24,7 @@ export interface ApplyResult {
|
|
|
22
24
|
};
|
|
23
25
|
}
|
|
24
26
|
/** One command at a time, fresh snapshot each; consumes ADD_CLIP's canvas patch. */
|
|
25
|
-
export declare function applyCommands(store: EditorStore, commands: AiCommand[]): ApplyResult;
|
|
27
|
+
export declare function applyCommands(store: EditorStore, commands: AiCommand[], extras?: ProjectExtras): ApplyResult;
|
|
26
28
|
export interface HeadlessEditor {
|
|
27
29
|
store: EditorStore;
|
|
28
30
|
/** Subtitle & template state carried through untouched. */
|
package/dist/headless.js
CHANGED
|
@@ -9,6 +9,7 @@ import { applyProject, serializeProject, timelineContentMs } from './project.js'
|
|
|
9
9
|
import { projectSchemaV2 } from './schemas.js';
|
|
10
10
|
import { migrateV1ToV2 } from './migrations/v1ToV2.js';
|
|
11
11
|
import { isV1Project } from './types/project.js';
|
|
12
|
+
import { SUBTITLE_TEMPLATE_IDS } from './types/editor.js';
|
|
12
13
|
export function snapshotOf(state) {
|
|
13
14
|
return {
|
|
14
15
|
timelineClips: state.timelineClips,
|
|
@@ -21,8 +22,43 @@ export function snapshotOf(state) {
|
|
|
21
22
|
audioBeatsByAssetId: state.audioBeatsByAssetId,
|
|
22
23
|
};
|
|
23
24
|
}
|
|
25
|
+
/** Subtitle writer over the project extras (the browser keeps this state in the editor page). */
|
|
26
|
+
export function subtitleWriter(extras) {
|
|
27
|
+
return (patch) => {
|
|
28
|
+
if (patch.cues === null) {
|
|
29
|
+
extras.cues = undefined;
|
|
30
|
+
}
|
|
31
|
+
else if (patch.cues) {
|
|
32
|
+
extras.cues = patch.cues;
|
|
33
|
+
}
|
|
34
|
+
if (patch.templateId !== undefined) {
|
|
35
|
+
if (!SUBTITLE_TEMPLATE_IDS.includes(patch.templateId)) {
|
|
36
|
+
throw new Error(`unknown subtitle template "${patch.templateId}" — use one of ${SUBTITLE_TEMPLATE_IDS.join(', ')}`);
|
|
37
|
+
}
|
|
38
|
+
extras.templateId = patch.templateId;
|
|
39
|
+
// The template carries its own animation; the page re-derives it from templateId on load.
|
|
40
|
+
extras.animation = undefined;
|
|
41
|
+
extras.styleOverrides = patch.styleOverrides ?? {};
|
|
42
|
+
}
|
|
43
|
+
else if (patch.styleOverrides !== undefined) {
|
|
44
|
+
extras.styleOverrides = { ...(extras.styleOverrides ?? {}), ...patch.styleOverrides };
|
|
45
|
+
}
|
|
46
|
+
if (!extras.templateId && (extras.cues?.length ?? 0) > 0)
|
|
47
|
+
extras.templateId = SUBTITLE_TEMPLATE_IDS[0];
|
|
48
|
+
const prevLayout = extras.subtitleLayout ?? {};
|
|
49
|
+
if (patch.position !== undefined) {
|
|
50
|
+
extras.position = patch.position;
|
|
51
|
+
extras.subtitleLayout = { x: 0.5, scale: 1, rotation: 0, maxWidthPct: 0.85, ...prevLayout, y: patch.position / 100 };
|
|
52
|
+
}
|
|
53
|
+
if (patch.layout) {
|
|
54
|
+
const merged = { x: 0.5, y: (extras.position ?? 50) / 100, scale: 1, rotation: 0, maxWidthPct: 0.85, ...prevLayout, ...(extras.subtitleLayout ?? {}), ...patch.layout };
|
|
55
|
+
extras.subtitleLayout = merged;
|
|
56
|
+
extras.position = Math.round(merged.y * 100);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
24
60
|
/** Same wiring as the browser AI panel (ai-chat-panel.tsx). */
|
|
25
|
-
export function actionsOf(state) {
|
|
61
|
+
export function actionsOf(state, extras) {
|
|
26
62
|
return {
|
|
27
63
|
addTextOverlay: state.addTextOverlay,
|
|
28
64
|
updateTextOverlay: state.updateTextOverlay,
|
|
@@ -33,6 +69,8 @@ export function actionsOf(state) {
|
|
|
33
69
|
setClipVolume: state.setClipVolume,
|
|
34
70
|
setClipOpacity: state.setClipOpacity,
|
|
35
71
|
setClipSpeed: state.setClipSpeed,
|
|
72
|
+
setClipSpeedRamp: state.setClipSpeedRamp,
|
|
73
|
+
setSubtitles: extras ? subtitleWriter(extras) : undefined,
|
|
36
74
|
setSelectedClipId: state.setSelectedClipId,
|
|
37
75
|
splitClip: state.splitClip,
|
|
38
76
|
duplicateClip: state.duplicateClip,
|
|
@@ -48,7 +86,7 @@ export function actionsOf(state) {
|
|
|
48
86
|
};
|
|
49
87
|
}
|
|
50
88
|
/** One command at a time, fresh snapshot each; consumes ADD_CLIP's canvas patch. */
|
|
51
|
-
export function applyCommands(store, commands) {
|
|
89
|
+
export function applyCommands(store, commands, extras) {
|
|
52
90
|
const before = store.getState();
|
|
53
91
|
const seen = {
|
|
54
92
|
clips: new Set(before.timelineClips.map((c) => c.id)),
|
|
@@ -61,7 +99,7 @@ export function applyCommands(store, commands) {
|
|
|
61
99
|
for (const cmd of commands) {
|
|
62
100
|
const state = store.getState();
|
|
63
101
|
const objectsBefore = new Set(state.canvasObjects.map((o) => o.id));
|
|
64
|
-
const r = executeAiCommands([cmd], snapshotOf(state), actionsOf(state));
|
|
102
|
+
const r = executeAiCommands([cmd], snapshotOf(state), actionsOf(state, extras));
|
|
65
103
|
applied += r.applied;
|
|
66
104
|
errors.push(...r.errors);
|
|
67
105
|
// ADD_CLIP with a canvasObject: the orchestrator parks the transform on
|
|
@@ -120,7 +158,7 @@ export function openHeadlessProject(raw, opts) {
|
|
|
120
158
|
return {
|
|
121
159
|
store, extras, missingAssets, migrated,
|
|
122
160
|
snapshot: () => snapshotOf(store.getState()),
|
|
123
|
-
apply: (commands) => applyCommands(store, commands),
|
|
161
|
+
apply: (commands) => applyCommands(store, commands, extras),
|
|
124
162
|
toProjectFile: () => serializeProject(store.getState(), extras, file.videoDurationMs && file.videoDurationMs > timelineContentMs(store.getState().timelineClips) ? file.videoDurationMs : undefined),
|
|
125
163
|
};
|
|
126
164
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,9 @@ export * from './types/project.js';
|
|
|
8
8
|
export * from './types/beat.js';
|
|
9
9
|
export * from './store.js';
|
|
10
10
|
export * from './timeline-utils.js';
|
|
11
|
+
export * from './speed-ramp.js';
|
|
12
|
+
export * from './text-fill.js';
|
|
13
|
+
export * from './subtitles.js';
|
|
11
14
|
export { TEXT_PRESETS, createTextOverlay, type TextPresetId, type TextPreset } from './text-presets.js';
|
|
12
15
|
export * from './schemas.js';
|
|
13
16
|
export * from './migrations/v1ToV2.js';
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,9 @@ export * from './types/project.js';
|
|
|
7
7
|
export * from './types/beat.js';
|
|
8
8
|
export * from './store.js';
|
|
9
9
|
export * from './timeline-utils.js';
|
|
10
|
+
export * from './speed-ramp.js';
|
|
11
|
+
export * from './text-fill.js';
|
|
12
|
+
export * from './subtitles.js';
|
|
10
13
|
export { TEXT_PRESETS, createTextOverlay } from './text-presets.js';
|
|
11
14
|
export * from './schemas.js';
|
|
12
15
|
export * from './migrations/v1ToV2.js';
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speed ramp — variable playback speed inside one clip.
|
|
3
|
+
*
|
|
4
|
+
* A ramp is a list of keyframes `{ t, v }`: `t` is the offset in ms from the
|
|
5
|
+
* clip's start ON THE TIMELINE, `v` the playback rate at that point. Between
|
|
6
|
+
* keyframes the rate is linear; before the first / after the last it holds.
|
|
7
|
+
*
|
|
8
|
+
* The clip's source time at timeline offset `x` is
|
|
9
|
+
* `sourceOffsetMs + ∫₀ˣ rate(τ) dτ` — because the rate is piecewise linear
|
|
10
|
+
* the integral is exact (trapezoids), no per-frame accumulation needed, so
|
|
11
|
+
* preview, export and audio scheduling all agree to the sample.
|
|
12
|
+
*/
|
|
13
|
+
import type { TimelineClip } from './store.js';
|
|
14
|
+
export interface SpeedKeyframe {
|
|
15
|
+
/** ms from the clip start on the timeline */
|
|
16
|
+
t: number;
|
|
17
|
+
/** playback rate (1 = normal, 0.5 = half speed, 2 = double) */
|
|
18
|
+
v: number;
|
|
19
|
+
}
|
|
20
|
+
export type SpeedRamp = SpeedKeyframe[];
|
|
21
|
+
export declare const SPEED_RAMP_MIN_RATE = 0.1;
|
|
22
|
+
export declare const SPEED_RAMP_MAX_RATE = 8;
|
|
23
|
+
/** Sorted by t, rates clamped, duplicate t collapsed (last wins). Empty → []. */
|
|
24
|
+
export declare function normalizeSpeedRamp(ramp: SpeedRamp | undefined | null): SpeedRamp;
|
|
25
|
+
/** Rate at timeline offset `offsetMs` (holds outside the keyframe range). */
|
|
26
|
+
export declare function rampRateAt(ramp: SpeedRamp, offsetMs: number): number;
|
|
27
|
+
/** Source ms consumed between timeline offsets 0 and `offsetMs` (exact for the piecewise-linear rate). */
|
|
28
|
+
export declare function rampSourceElapsedMs(ramp: SpeedRamp, offsetMs: number): number;
|
|
29
|
+
/** Timeline ms needed to consume `sourceMs` of source under the ramp (bisection, ±0.5 ms). */
|
|
30
|
+
export declare function rampTimelineMsForSource(ramp: SpeedRamp, sourceMs: number): number;
|
|
31
|
+
/** Split a ramp at `atMs`: left keeps its keyframes, right is re-based to 0 starting at the rate of the cut. */
|
|
32
|
+
export declare function splitSpeedRamp(ramp: SpeedRamp, atMs: number): {
|
|
33
|
+
left: SpeedRamp;
|
|
34
|
+
right: SpeedRamp;
|
|
35
|
+
};
|
|
36
|
+
/** Re-base a ramp after the clip's start moved by `deltaMs` on the timeline (trim). */
|
|
37
|
+
export declare function shiftSpeedRamp(ramp: SpeedRamp, deltaMs: number): SpeedRamp;
|
|
38
|
+
/** Ramp that plays the whole clip at `rate` — used to fold a constant speed into ramp math. */
|
|
39
|
+
export declare function constantRamp(rate: number): SpeedRamp;
|
|
40
|
+
/** Playback rate of `clip` at timeline offset `offsetMs` from its start. */
|
|
41
|
+
export declare function clipRateAt(clip: Pick<TimelineClip, 'playbackRate' | 'speedRamp'>, offsetMs: number): number;
|
|
42
|
+
/** Source ms consumed by the first `offsetMs` of the clip on the timeline. */
|
|
43
|
+
export declare function clipSourceElapsedMs(clip: Pick<TimelineClip, 'playbackRate' | 'speedRamp'>, offsetMs: number): number;
|
|
44
|
+
/** Absolute source time (ms) of the clip at timeline position `playheadMs`. */
|
|
45
|
+
export declare function clipSourceTimeMs(clip: Pick<TimelineClip, 'startMs' | 'sourceOffsetMs' | 'playbackRate' | 'speedRamp'>, playheadMs: number): number;
|
|
46
|
+
/** Timeline ms the clip needs to consume `sourceMs` of source. */
|
|
47
|
+
export declare function clipTimelineMsForSource(clip: Pick<TimelineClip, 'playbackRate' | 'speedRamp'>, sourceMs: number): number;
|
|
48
|
+
/** Average rate over the clip (what a "speed" badge should show). */
|
|
49
|
+
export declare function clipAverageRate(clip: Pick<TimelineClip, 'durationMs' | 'playbackRate' | 'speedRamp'>): number;
|
|
50
|
+
export type SpeedRampPresetId = 'speed-up' | 'slow-down' | 'slow-mo-hit' | 'punch-in' | 'ease-in-out';
|
|
51
|
+
export interface SpeedRampPreset {
|
|
52
|
+
id: SpeedRampPresetId;
|
|
53
|
+
label: string;
|
|
54
|
+
description: string;
|
|
55
|
+
/** Keyframes for a clip of `durationMs` on the timeline. */
|
|
56
|
+
build: (durationMs: number) => SpeedRamp;
|
|
57
|
+
}
|
|
58
|
+
export declare const SPEED_RAMP_PRESETS: SpeedRampPreset[];
|
|
59
|
+
export declare function speedRampPreset(id: string): SpeedRampPreset | undefined;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speed ramp — variable playback speed inside one clip.
|
|
3
|
+
*
|
|
4
|
+
* A ramp is a list of keyframes `{ t, v }`: `t` is the offset in ms from the
|
|
5
|
+
* clip's start ON THE TIMELINE, `v` the playback rate at that point. Between
|
|
6
|
+
* keyframes the rate is linear; before the first / after the last it holds.
|
|
7
|
+
*
|
|
8
|
+
* The clip's source time at timeline offset `x` is
|
|
9
|
+
* `sourceOffsetMs + ∫₀ˣ rate(τ) dτ` — because the rate is piecewise linear
|
|
10
|
+
* the integral is exact (trapezoids), no per-frame accumulation needed, so
|
|
11
|
+
* preview, export and audio scheduling all agree to the sample.
|
|
12
|
+
*/
|
|
13
|
+
export const SPEED_RAMP_MIN_RATE = 0.1;
|
|
14
|
+
export const SPEED_RAMP_MAX_RATE = 8;
|
|
15
|
+
/** Sorted by t, rates clamped, duplicate t collapsed (last wins). Empty → []. */
|
|
16
|
+
export function normalizeSpeedRamp(ramp) {
|
|
17
|
+
if (!ramp || ramp.length === 0)
|
|
18
|
+
return [];
|
|
19
|
+
const byT = new Map();
|
|
20
|
+
for (const k of ramp) {
|
|
21
|
+
if (!Number.isFinite(k.t) || !Number.isFinite(k.v))
|
|
22
|
+
continue;
|
|
23
|
+
byT.set(Math.max(0, Math.round(k.t)), Math.min(SPEED_RAMP_MAX_RATE, Math.max(SPEED_RAMP_MIN_RATE, k.v)));
|
|
24
|
+
}
|
|
25
|
+
return [...byT.entries()].sort((a, b) => a[0] - b[0]).map(([t, v]) => ({ t, v }));
|
|
26
|
+
}
|
|
27
|
+
/** Rate at timeline offset `offsetMs` (holds outside the keyframe range). */
|
|
28
|
+
export function rampRateAt(ramp, offsetMs) {
|
|
29
|
+
if (ramp.length === 0)
|
|
30
|
+
return 1;
|
|
31
|
+
if (offsetMs <= ramp[0].t)
|
|
32
|
+
return ramp[0].v;
|
|
33
|
+
const last = ramp[ramp.length - 1];
|
|
34
|
+
if (offsetMs >= last.t)
|
|
35
|
+
return last.v;
|
|
36
|
+
for (let i = 1; i < ramp.length; i++) {
|
|
37
|
+
const a = ramp[i - 1];
|
|
38
|
+
const b = ramp[i];
|
|
39
|
+
if (offsetMs <= b.t) {
|
|
40
|
+
const span = b.t - a.t;
|
|
41
|
+
return span <= 0 ? b.v : a.v + (b.v - a.v) * (offsetMs - a.t) / span;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return last.v;
|
|
45
|
+
}
|
|
46
|
+
/** Source ms consumed between timeline offsets 0 and `offsetMs` (exact for the piecewise-linear rate). */
|
|
47
|
+
export function rampSourceElapsedMs(ramp, offsetMs) {
|
|
48
|
+
if (ramp.length === 0)
|
|
49
|
+
return offsetMs;
|
|
50
|
+
if (offsetMs <= 0)
|
|
51
|
+
return rampRateAt(ramp, 0) * offsetMs;
|
|
52
|
+
let total = 0;
|
|
53
|
+
let cursor = 0;
|
|
54
|
+
// Segment before the first keyframe: constant rate.
|
|
55
|
+
const first = ramp[0];
|
|
56
|
+
if (offsetMs <= first.t)
|
|
57
|
+
return first.v * offsetMs;
|
|
58
|
+
total += first.v * first.t;
|
|
59
|
+
cursor = first.t;
|
|
60
|
+
for (let i = 1; i < ramp.length; i++) {
|
|
61
|
+
const b = ramp[i];
|
|
62
|
+
if (b.t <= cursor)
|
|
63
|
+
continue;
|
|
64
|
+
const end = Math.min(offsetMs, b.t);
|
|
65
|
+
if (end > cursor) {
|
|
66
|
+
const va = rampRateAt(ramp, cursor);
|
|
67
|
+
const vb = rampRateAt(ramp, end);
|
|
68
|
+
total += (end - cursor) * (va + vb) / 2;
|
|
69
|
+
cursor = end;
|
|
70
|
+
}
|
|
71
|
+
if (cursor >= offsetMs)
|
|
72
|
+
return total;
|
|
73
|
+
}
|
|
74
|
+
// Past the last keyframe: constant rate.
|
|
75
|
+
if (offsetMs > cursor)
|
|
76
|
+
total += ramp[ramp.length - 1].v * (offsetMs - cursor);
|
|
77
|
+
return total;
|
|
78
|
+
}
|
|
79
|
+
/** Timeline ms needed to consume `sourceMs` of source under the ramp (bisection, ±0.5 ms). */
|
|
80
|
+
export function rampTimelineMsForSource(ramp, sourceMs) {
|
|
81
|
+
if (sourceMs <= 0)
|
|
82
|
+
return 0;
|
|
83
|
+
if (ramp.length === 0)
|
|
84
|
+
return sourceMs;
|
|
85
|
+
let lo = 0;
|
|
86
|
+
let hi = sourceMs / SPEED_RAMP_MIN_RATE;
|
|
87
|
+
for (let i = 0; i < 60 && hi - lo > 0.5; i++) {
|
|
88
|
+
const mid = (lo + hi) / 2;
|
|
89
|
+
if (rampSourceElapsedMs(ramp, mid) < sourceMs)
|
|
90
|
+
lo = mid;
|
|
91
|
+
else
|
|
92
|
+
hi = mid;
|
|
93
|
+
}
|
|
94
|
+
return Math.round((lo + hi) / 2);
|
|
95
|
+
}
|
|
96
|
+
/** Split a ramp at `atMs`: left keeps its keyframes, right is re-based to 0 starting at the rate of the cut. */
|
|
97
|
+
export function splitSpeedRamp(ramp, atMs) {
|
|
98
|
+
const rateAtCut = rampRateAt(ramp, atMs);
|
|
99
|
+
const left = [...ramp.filter((k) => k.t < atMs), { t: atMs, v: rateAtCut }];
|
|
100
|
+
const right = [{ t: 0, v: rateAtCut }, ...ramp.filter((k) => k.t > atMs).map((k) => ({ t: k.t - atMs, v: k.v }))];
|
|
101
|
+
return { left: normalizeSpeedRamp(left), right: normalizeSpeedRamp(right) };
|
|
102
|
+
}
|
|
103
|
+
/** Re-base a ramp after the clip's start moved by `deltaMs` on the timeline (trim). */
|
|
104
|
+
export function shiftSpeedRamp(ramp, deltaMs) {
|
|
105
|
+
if (ramp.length === 0 || deltaMs === 0)
|
|
106
|
+
return ramp;
|
|
107
|
+
const rateAtNewStart = rampRateAt(ramp, deltaMs);
|
|
108
|
+
return normalizeSpeedRamp([{ t: 0, v: rateAtNewStart }, ...ramp.filter((k) => k.t > deltaMs).map((k) => ({ t: k.t - deltaMs, v: k.v }))]);
|
|
109
|
+
}
|
|
110
|
+
/** Ramp that plays the whole clip at `rate` — used to fold a constant speed into ramp math. */
|
|
111
|
+
export function constantRamp(rate) {
|
|
112
|
+
return [{ t: 0, v: rate }];
|
|
113
|
+
}
|
|
114
|
+
// ── Clip helpers (constant speed or ramp, one code path) ─────────────────
|
|
115
|
+
/** Playback rate of `clip` at timeline offset `offsetMs` from its start. */
|
|
116
|
+
export function clipRateAt(clip, offsetMs) {
|
|
117
|
+
if (clip.speedRamp && clip.speedRamp.length > 0)
|
|
118
|
+
return rampRateAt(clip.speedRamp, offsetMs);
|
|
119
|
+
return clip.playbackRate ?? 1;
|
|
120
|
+
}
|
|
121
|
+
/** Source ms consumed by the first `offsetMs` of the clip on the timeline. */
|
|
122
|
+
export function clipSourceElapsedMs(clip, offsetMs) {
|
|
123
|
+
if (clip.speedRamp && clip.speedRamp.length > 0)
|
|
124
|
+
return rampSourceElapsedMs(clip.speedRamp, offsetMs);
|
|
125
|
+
return offsetMs * (clip.playbackRate ?? 1);
|
|
126
|
+
}
|
|
127
|
+
/** Absolute source time (ms) of the clip at timeline position `playheadMs`. */
|
|
128
|
+
export function clipSourceTimeMs(clip, playheadMs) {
|
|
129
|
+
return clip.sourceOffsetMs + clipSourceElapsedMs(clip, playheadMs - clip.startMs);
|
|
130
|
+
}
|
|
131
|
+
/** Timeline ms the clip needs to consume `sourceMs` of source. */
|
|
132
|
+
export function clipTimelineMsForSource(clip, sourceMs) {
|
|
133
|
+
if (clip.speedRamp && clip.speedRamp.length > 0)
|
|
134
|
+
return rampTimelineMsForSource(clip.speedRamp, sourceMs);
|
|
135
|
+
return sourceMs / (clip.playbackRate ?? 1);
|
|
136
|
+
}
|
|
137
|
+
/** Average rate over the clip (what a "speed" badge should show). */
|
|
138
|
+
export function clipAverageRate(clip) {
|
|
139
|
+
if (!clip.speedRamp || clip.speedRamp.length === 0)
|
|
140
|
+
return clip.playbackRate ?? 1;
|
|
141
|
+
return clip.durationMs > 0 ? rampSourceElapsedMs(clip.speedRamp, clip.durationMs) / clip.durationMs : 1;
|
|
142
|
+
}
|
|
143
|
+
export const SPEED_RAMP_PRESETS = [
|
|
144
|
+
{ id: 'speed-up', label: 'Speed up', description: 'Normal → 3× by the end', build: (d) => [{ t: 0, v: 1 }, { t: d, v: 3 }] },
|
|
145
|
+
{ id: 'slow-down', label: 'Slow down', description: 'Normal → 0.3× by the end', build: (d) => [{ t: 0, v: 1 }, { t: d, v: 0.3 }] },
|
|
146
|
+
{ id: 'slow-mo-hit', label: 'Slow-mo hit', description: 'Fast, dips to 0.25× in the middle, fast again', build: (d) => [
|
|
147
|
+
{ t: 0, v: 2 }, { t: d * 0.35, v: 2 }, { t: d * 0.45, v: 0.25 }, { t: d * 0.6, v: 0.25 }, { t: d * 0.7, v: 2 }, { t: d, v: 2 },
|
|
148
|
+
] },
|
|
149
|
+
{ id: 'punch-in', label: 'Punch in', description: 'Starts at 0.4×, whips to 3× then settles at 1×', build: (d) => [
|
|
150
|
+
{ t: 0, v: 0.4 }, { t: d * 0.25, v: 3 }, { t: d * 0.5, v: 1 }, { t: d, v: 1 },
|
|
151
|
+
] },
|
|
152
|
+
{ id: 'ease-in-out', label: 'Ease in-out', description: 'Slow at both ends, 2× in the middle', build: (d) => [
|
|
153
|
+
{ t: 0, v: 0.5 }, { t: d * 0.4, v: 2 }, { t: d * 0.6, v: 2 }, { t: d, v: 0.5 },
|
|
154
|
+
] },
|
|
155
|
+
];
|
|
156
|
+
export function speedRampPreset(id) {
|
|
157
|
+
return SPEED_RAMP_PRESETS.find((p) => p.id === id);
|
|
158
|
+
}
|
package/dist/store.d.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* and defaults to a no-op.
|
|
9
9
|
*/
|
|
10
10
|
import { type StoreApi } from 'zustand/vanilla';
|
|
11
|
+
import { type SpeedKeyframe } from './speed-ramp.js';
|
|
11
12
|
import type { TextOverlay } from './types/editor.js';
|
|
12
13
|
import type { Animations } from './types/animation.js';
|
|
13
14
|
import type { Effect } from './types/effect.js';
|
|
@@ -107,6 +108,12 @@ export interface TimelineClip {
|
|
|
107
108
|
opacity?: number;
|
|
108
109
|
/** Playback speed multiplier (default 1.0). Changes visual duration on timeline. */
|
|
109
110
|
playbackRate?: number;
|
|
111
|
+
/**
|
|
112
|
+
* Speed ramp: variable rate along the clip (keyframes in ms from the clip
|
|
113
|
+
* start on the timeline). Takes precedence over playbackRate when set —
|
|
114
|
+
* see speed-ramp.ts for the source-time math.
|
|
115
|
+
*/
|
|
116
|
+
speedRamp?: SpeedKeyframe[];
|
|
110
117
|
}
|
|
111
118
|
export type MaskType = 'none' | 'rounded' | 'circle' | 'star' | 'hexagon';
|
|
112
119
|
export type MaskAspectPreset = 'free' | '1:1' | '4:3' | '3:4' | '16:9' | '9:16';
|
|
@@ -220,6 +227,8 @@ export interface EditorState {
|
|
|
220
227
|
detachAudio: (clipId: string) => void;
|
|
221
228
|
setClipOpacity: (clipId: string, opacity: number) => void;
|
|
222
229
|
setClipSpeed: (clipId: string, rate: number) => void;
|
|
230
|
+
/** Set (or clear with undefined/[]) a speed ramp; the clip keeps its timeline duration unless the source runs out. */
|
|
231
|
+
setClipSpeedRamp: (clipId: string, ramp: SpeedKeyframe[] | undefined) => void;
|
|
223
232
|
setClipAnimation: (clipId: string, phase: 'in' | 'out', config: ClipAnimationConfig | undefined) => void;
|
|
224
233
|
addTrack: (type: TrackType) => TrackDefinition;
|
|
225
234
|
removeEmptyTracks: () => void;
|
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
|
|
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
|
-
|
|
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 +
|
|
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
|
-
|
|
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 +
|
|
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
|
|
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
|
+
}
|
package/dist/text-presets.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TextOverlay } from './types/editor.js';
|
|
2
|
-
export type TextPresetId = 'title' | 'subtitle' | 'cta' | 'price' | 'lower-third' | 'minimal';
|
|
2
|
+
export type TextPresetId = 'title' | 'subtitle' | 'cta' | 'price' | 'lower-third' | 'minimal' | 'iridescent';
|
|
3
3
|
export interface TextPreset {
|
|
4
4
|
id: TextPresetId;
|
|
5
5
|
name: string;
|
package/dist/text-presets.js
CHANGED
|
@@ -163,6 +163,34 @@ export const TEXT_PRESETS = [
|
|
|
163
163
|
opacity: 1,
|
|
164
164
|
},
|
|
165
165
|
},
|
|
166
|
+
{
|
|
167
|
+
id: 'iridescent',
|
|
168
|
+
name: 'Iridescent',
|
|
169
|
+
icon: '✦',
|
|
170
|
+
defaults: {
|
|
171
|
+
text: 'Shine',
|
|
172
|
+
fontFamily: "'Archivo Black', sans-serif",
|
|
173
|
+
fontSize: 64,
|
|
174
|
+
fontWeight: 400,
|
|
175
|
+
color: '#FFFFFF',
|
|
176
|
+
fill: { type: 'gradient', colors: ['#ff6ec4', '#7873f5', '#4ade80', '#facc15', '#ff6ec4'], angle: 100, animate: 'shift', speed: 0.35 },
|
|
177
|
+
backgroundColor: 'transparent',
|
|
178
|
+
backgroundOpacity: 0,
|
|
179
|
+
strokeColor: '#000000',
|
|
180
|
+
strokeWidth: 0,
|
|
181
|
+
shadowColor: '#000000',
|
|
182
|
+
shadowBlur: 12,
|
|
183
|
+
shadowOffsetX: 0,
|
|
184
|
+
shadowOffsetY: 4,
|
|
185
|
+
textTransform: 'uppercase',
|
|
186
|
+
textAlign: 'center',
|
|
187
|
+
animation: 'pop',
|
|
188
|
+
animationDurationMs: 400,
|
|
189
|
+
padding: 8,
|
|
190
|
+
borderRadius: 0,
|
|
191
|
+
opacity: 1,
|
|
192
|
+
},
|
|
193
|
+
},
|
|
166
194
|
];
|
|
167
195
|
/** Default values for a new text overlay */
|
|
168
196
|
export function createTextOverlay(preset) {
|
package/dist/types/editor.d.ts
CHANGED
|
@@ -17,6 +17,13 @@ export interface TranscriptionResult {
|
|
|
17
17
|
language: string;
|
|
18
18
|
}
|
|
19
19
|
export type SubtitleAnimation = 'fade' | 'pop' | 'slide' | 'typewriter' | 'word-highlight' | 'word-popup' | 'outline-reveal' | 'ugc-pop' | 'karaoke-marker' | 'cinematic-fade' | 'neon-cyberpunk' | 'playful-wiggle';
|
|
20
|
+
/**
|
|
21
|
+
* Ids of the word-level subtitle templates shipped by the web editor
|
|
22
|
+
* (src/lib/editor/templates.ts). Kept here so headless callers (vivid-mcp)
|
|
23
|
+
* can validate a `templateId` without the browser template table.
|
|
24
|
+
*/
|
|
25
|
+
export declare const SUBTITLE_TEMPLATE_IDS: readonly ["outline-reveal", "ugc-pop", "karaoke-marker", "cinematic-fade", "neon-cyberpunk", "playful-wiggle"];
|
|
26
|
+
export type SubtitleTemplateId = typeof SUBTITLE_TEMPLATE_IDS[number];
|
|
20
27
|
/** Vertical position as percentage from top (0 = top, 100 = bottom) */
|
|
21
28
|
export type SubtitlePosition = number;
|
|
22
29
|
export interface SubtitleStyleConfig {
|
|
@@ -60,6 +67,19 @@ export type TextAnimation = 'none' | 'fade-in' | 'fade-out' | 'fade-in-out' | 's
|
|
|
60
67
|
import type { Animations } from './animation.js';
|
|
61
68
|
import type { Effect } from './effect.js';
|
|
62
69
|
import type { AudioReactiveModifier } from './audioReactive.js';
|
|
70
|
+
/** Gradient fill for a text overlay — replaces `color` for the glyphs when set. */
|
|
71
|
+
export interface TextGradientFill {
|
|
72
|
+
type: 'gradient';
|
|
73
|
+
/** 2+ CSS colors. Repeat the first at the end (or let the renderer do it) for a seamless scroll. */
|
|
74
|
+
colors: string[];
|
|
75
|
+
/** CSS convention: 0 = bottom→top, 90 = left→right */
|
|
76
|
+
angle: number;
|
|
77
|
+
/** 'shift' scrolls the gradient across the text over time; 'none' keeps it static */
|
|
78
|
+
animate?: 'none' | 'shift';
|
|
79
|
+
/** gradient periods per second when animated (default 0.35) */
|
|
80
|
+
speed?: number;
|
|
81
|
+
}
|
|
82
|
+
export type TextFill = TextGradientFill;
|
|
63
83
|
/** Text overlay on the canvas with timing, position, style, and animation */
|
|
64
84
|
export interface TextOverlay {
|
|
65
85
|
id: string;
|
|
@@ -76,6 +96,8 @@ export interface TextOverlay {
|
|
|
76
96
|
fontStyle?: 'normal' | 'italic';
|
|
77
97
|
textDecoration?: 'none' | 'underline' | 'line-through' | 'overline';
|
|
78
98
|
color: string;
|
|
99
|
+
/** Optional gradient fill for the glyphs (see TextGradientFill); `color` stays as the fallback. */
|
|
100
|
+
fill?: TextFill;
|
|
79
101
|
backgroundColor: string;
|
|
80
102
|
backgroundOpacity: number;
|
|
81
103
|
strokeColor: string;
|
package/dist/types/editor.js
CHANGED
|
@@ -1 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Ids of the word-level subtitle templates shipped by the web editor
|
|
3
|
+
* (src/lib/editor/templates.ts). Kept here so headless callers (vivid-mcp)
|
|
4
|
+
* can validate a `templateId` without the browser template table.
|
|
5
|
+
*/
|
|
6
|
+
export const SUBTITLE_TEMPLATE_IDS = ['outline-reveal', 'ugc-pop', 'karaoke-marker', 'cinematic-fade', 'neon-cyberpunk', 'playful-wiggle'];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vivid-editor-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.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",
|