@rajeev02/video-editor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/effects/index.d.ts +70 -0
- package/lib/effects/index.d.ts.map +1 -0
- package/lib/effects/index.js +105 -0
- package/lib/effects/index.js.map +1 -0
- package/lib/export/index.d.ts +92 -0
- package/lib/export/index.d.ts.map +1 -0
- package/lib/export/index.js +203 -0
- package/lib/export/index.js.map +1 -0
- package/lib/index.d.ts +15 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +23 -0
- package/lib/index.js.map +1 -0
- package/lib/timeline/index.d.ts +146 -0
- package/lib/timeline/index.d.ts.map +1 -0
- package/lib/timeline/index.js +193 -0
- package/lib/timeline/index.js.map +1 -0
- package/package.json +51 -0
- package/src/effects/index.ts +172 -0
- package/src/export/index.ts +288 -0
- package/src/index.ts +46 -0
- package/src/timeline/index.ts +317 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @rajeev02/video-editor — Export
|
|
3
|
+
* Render pipeline: resolution, format, codec, compression, watermark, progress
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type ExportFormat = "mp4" | "mov" | "webm" | "gif";
|
|
7
|
+
export type ExportCodec = "h264" | "h265" | "vp9" | "av1";
|
|
8
|
+
export type ExportQuality = "draft" | "standard" | "high" | "ultra";
|
|
9
|
+
export type ExportState =
|
|
10
|
+
| "idle"
|
|
11
|
+
| "preparing"
|
|
12
|
+
| "rendering"
|
|
13
|
+
| "encoding"
|
|
14
|
+
| "completed"
|
|
15
|
+
| "failed"
|
|
16
|
+
| "cancelled";
|
|
17
|
+
|
|
18
|
+
export interface ExportConfig {
|
|
19
|
+
format: ExportFormat;
|
|
20
|
+
codec: ExportCodec;
|
|
21
|
+
quality: ExportQuality;
|
|
22
|
+
resolution: { width: number; height: number };
|
|
23
|
+
fps: number;
|
|
24
|
+
/** Bitrate in kbps (0 = auto based on quality) */
|
|
25
|
+
videoBitrate: number;
|
|
26
|
+
audioBitrate: number;
|
|
27
|
+
/** Max output file size in bytes (0 = no limit) */
|
|
28
|
+
maxSizeBytes: number;
|
|
29
|
+
/** Add watermark */
|
|
30
|
+
watermark?: {
|
|
31
|
+
text?: string;
|
|
32
|
+
imageUri?: string;
|
|
33
|
+
position:
|
|
34
|
+
| "top_left"
|
|
35
|
+
| "top_right"
|
|
36
|
+
| "bottom_left"
|
|
37
|
+
| "bottom_right"
|
|
38
|
+
| "center";
|
|
39
|
+
opacity: number;
|
|
40
|
+
};
|
|
41
|
+
/** Output filename */
|
|
42
|
+
outputFilename: string;
|
|
43
|
+
/** Whether to include audio */
|
|
44
|
+
includeAudio: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ExportProgress {
|
|
48
|
+
state: ExportState;
|
|
49
|
+
/** 0-100 */
|
|
50
|
+
percent: number;
|
|
51
|
+
/** Current frame being processed */
|
|
52
|
+
currentFrame: number;
|
|
53
|
+
/** Total frames */
|
|
54
|
+
totalFrames: number;
|
|
55
|
+
/** Elapsed time in ms */
|
|
56
|
+
elapsedMs: number;
|
|
57
|
+
/** Estimated remaining time in ms */
|
|
58
|
+
estimatedRemainingMs: number;
|
|
59
|
+
/** Current output file size */
|
|
60
|
+
currentSizeBytes: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ExportResult {
|
|
64
|
+
success: boolean;
|
|
65
|
+
outputUri?: string;
|
|
66
|
+
outputSizeBytes?: number;
|
|
67
|
+
durationMs?: number;
|
|
68
|
+
renderTimeMs?: number;
|
|
69
|
+
error?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Export preset configurations */
|
|
73
|
+
export interface ExportPreset {
|
|
74
|
+
id: string;
|
|
75
|
+
name: string;
|
|
76
|
+
description: string;
|
|
77
|
+
config: Partial<ExportConfig>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getExportPresets(): ExportPreset[] {
|
|
81
|
+
return [
|
|
82
|
+
{
|
|
83
|
+
id: "social_story",
|
|
84
|
+
name: "Instagram/WhatsApp Story",
|
|
85
|
+
description: "1080x1920, 30fps, optimized for stories",
|
|
86
|
+
config: {
|
|
87
|
+
resolution: { width: 1080, height: 1920 },
|
|
88
|
+
fps: 30,
|
|
89
|
+
format: "mp4",
|
|
90
|
+
codec: "h264",
|
|
91
|
+
quality: "standard",
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: "social_reel",
|
|
96
|
+
name: "Reels/Shorts",
|
|
97
|
+
description: "1080x1920, 30fps, max 60s",
|
|
98
|
+
config: {
|
|
99
|
+
resolution: { width: 1080, height: 1920 },
|
|
100
|
+
fps: 30,
|
|
101
|
+
format: "mp4",
|
|
102
|
+
codec: "h264",
|
|
103
|
+
quality: "high",
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: "social_post",
|
|
108
|
+
name: "Social Post (Square)",
|
|
109
|
+
description: "1080x1080, 30fps",
|
|
110
|
+
config: {
|
|
111
|
+
resolution: { width: 1080, height: 1080 },
|
|
112
|
+
fps: 30,
|
|
113
|
+
format: "mp4",
|
|
114
|
+
codec: "h264",
|
|
115
|
+
quality: "standard",
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: "youtube_1080",
|
|
120
|
+
name: "YouTube 1080p",
|
|
121
|
+
description: "1920x1080, 30fps, high quality",
|
|
122
|
+
config: {
|
|
123
|
+
resolution: { width: 1920, height: 1080 },
|
|
124
|
+
fps: 30,
|
|
125
|
+
format: "mp4",
|
|
126
|
+
codec: "h264",
|
|
127
|
+
quality: "high",
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: "youtube_4k",
|
|
132
|
+
name: "YouTube 4K",
|
|
133
|
+
description: "3840x2160, 30fps, ultra quality",
|
|
134
|
+
config: {
|
|
135
|
+
resolution: { width: 3840, height: 2160 },
|
|
136
|
+
fps: 30,
|
|
137
|
+
format: "mp4",
|
|
138
|
+
codec: "h265",
|
|
139
|
+
quality: "ultra",
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: "whatsapp",
|
|
144
|
+
name: "WhatsApp Share",
|
|
145
|
+
description: "720p, compressed for sharing",
|
|
146
|
+
config: {
|
|
147
|
+
resolution: { width: 1280, height: 720 },
|
|
148
|
+
fps: 30,
|
|
149
|
+
format: "mp4",
|
|
150
|
+
codec: "h264",
|
|
151
|
+
quality: "draft",
|
|
152
|
+
maxSizeBytes: 16 * 1024 * 1024,
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: "gif",
|
|
157
|
+
name: "GIF",
|
|
158
|
+
description: "480p, 15fps animated GIF",
|
|
159
|
+
config: {
|
|
160
|
+
resolution: { width: 480, height: 480 },
|
|
161
|
+
fps: 15,
|
|
162
|
+
format: "gif",
|
|
163
|
+
codec: "h264",
|
|
164
|
+
quality: "draft",
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Export Controller — manages render pipeline
|
|
172
|
+
*/
|
|
173
|
+
export class ExportController {
|
|
174
|
+
private config: ExportConfig;
|
|
175
|
+
private state: ExportState = "idle";
|
|
176
|
+
private progress: ExportProgress;
|
|
177
|
+
private listeners: Set<(progress: ExportProgress) => void> = new Set();
|
|
178
|
+
|
|
179
|
+
constructor(config?: Partial<ExportConfig>) {
|
|
180
|
+
this.config = {
|
|
181
|
+
format: "mp4",
|
|
182
|
+
codec: "h264",
|
|
183
|
+
quality: "high",
|
|
184
|
+
resolution: { width: 1920, height: 1080 },
|
|
185
|
+
fps: 30,
|
|
186
|
+
videoBitrate: 0,
|
|
187
|
+
audioBitrate: 128,
|
|
188
|
+
maxSizeBytes: 0,
|
|
189
|
+
outputFilename: `video_${Date.now()}.mp4`,
|
|
190
|
+
includeAudio: true,
|
|
191
|
+
...config,
|
|
192
|
+
};
|
|
193
|
+
this.progress = {
|
|
194
|
+
state: "idle",
|
|
195
|
+
percent: 0,
|
|
196
|
+
currentFrame: 0,
|
|
197
|
+
totalFrames: 0,
|
|
198
|
+
elapsedMs: 0,
|
|
199
|
+
estimatedRemainingMs: 0,
|
|
200
|
+
currentSizeBytes: 0,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Start export (native layer performs actual rendering) */
|
|
205
|
+
startExport(): void {
|
|
206
|
+
this.state = "preparing";
|
|
207
|
+
this.progress.state = "preparing";
|
|
208
|
+
this.notifyProgress();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Update progress (called by native renderer) */
|
|
212
|
+
updateProgress(
|
|
213
|
+
currentFrame: number,
|
|
214
|
+
totalFrames: number,
|
|
215
|
+
sizeBytes: number,
|
|
216
|
+
): void {
|
|
217
|
+
this.state = "rendering";
|
|
218
|
+
this.progress = {
|
|
219
|
+
state: "rendering",
|
|
220
|
+
currentFrame,
|
|
221
|
+
totalFrames,
|
|
222
|
+
percent: totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0,
|
|
223
|
+
elapsedMs: 0,
|
|
224
|
+
estimatedRemainingMs: 0,
|
|
225
|
+
currentSizeBytes: sizeBytes,
|
|
226
|
+
};
|
|
227
|
+
this.notifyProgress();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Called when export completes */
|
|
231
|
+
onComplete(result: ExportResult): void {
|
|
232
|
+
this.state = result.success ? "completed" : "failed";
|
|
233
|
+
this.progress.state = this.state;
|
|
234
|
+
this.progress.percent = result.success ? 100 : this.progress.percent;
|
|
235
|
+
this.notifyProgress();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Cancel export */
|
|
239
|
+
cancel(): void {
|
|
240
|
+
this.state = "cancelled";
|
|
241
|
+
this.progress.state = "cancelled";
|
|
242
|
+
this.notifyProgress();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
getConfig(): ExportConfig {
|
|
246
|
+
return { ...this.config };
|
|
247
|
+
}
|
|
248
|
+
getProgress(): ExportProgress {
|
|
249
|
+
return { ...this.progress };
|
|
250
|
+
}
|
|
251
|
+
getState(): ExportState {
|
|
252
|
+
return this.state;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Estimate output file size in bytes */
|
|
256
|
+
estimateFileSize(durationMs: number): number {
|
|
257
|
+
const bitrateKbps = this.config.videoBitrate || this.getAutoVideoBitrate();
|
|
258
|
+
const audioBitrate = this.config.includeAudio
|
|
259
|
+
? this.config.audioBitrate
|
|
260
|
+
: 0;
|
|
261
|
+
return (((bitrateKbps + audioBitrate) * (durationMs / 1000)) / 8) * 1000;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
onProgress(listener: (progress: ExportProgress) => void): () => void {
|
|
265
|
+
this.listeners.add(listener);
|
|
266
|
+
return () => this.listeners.delete(listener);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private getAutoVideoBitrate(): number {
|
|
270
|
+
const pixels = this.config.resolution.width * this.config.resolution.height;
|
|
271
|
+
const qualityMultiplier = {
|
|
272
|
+
draft: 0.5,
|
|
273
|
+
standard: 1,
|
|
274
|
+
high: 1.5,
|
|
275
|
+
ultra: 2.5,
|
|
276
|
+
};
|
|
277
|
+
const baseBitrate = pixels * 0.003; // ~3 bits per pixel baseline
|
|
278
|
+
return baseBitrate * (qualityMultiplier[this.config.quality] ?? 1);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private notifyProgress(): void {
|
|
282
|
+
for (const l of this.listeners) {
|
|
283
|
+
try {
|
|
284
|
+
l(this.progress);
|
|
285
|
+
} catch {}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @rajeev02/video-editor
|
|
3
|
+
* Video Editor — timeline, effects, transitions, export
|
|
4
|
+
* Trim, merge, split, reorder, speed, filters, text, stickers, music, green screen
|
|
5
|
+
*
|
|
6
|
+
* @author Rajeev Kumar Joshi
|
|
7
|
+
* @license MIT
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Timeline
|
|
11
|
+
export { VideoTimeline } from "./timeline";
|
|
12
|
+
export type {
|
|
13
|
+
VideoClip,
|
|
14
|
+
AudioClip,
|
|
15
|
+
TextClip,
|
|
16
|
+
StickerClip,
|
|
17
|
+
Transition,
|
|
18
|
+
TransitionType,
|
|
19
|
+
TrackType,
|
|
20
|
+
TextAnimation,
|
|
21
|
+
TimelineEvent,
|
|
22
|
+
} from "./timeline";
|
|
23
|
+
|
|
24
|
+
// Effects
|
|
25
|
+
export { getVideoFilterPresets, getTransitionTypes } from "./effects";
|
|
26
|
+
export type {
|
|
27
|
+
VideoEffect,
|
|
28
|
+
ColorGrade,
|
|
29
|
+
SpeedRamp,
|
|
30
|
+
KenBurnsEffect,
|
|
31
|
+
ChromaKeyConfig,
|
|
32
|
+
VideoFilterPreset,
|
|
33
|
+
} from "./effects";
|
|
34
|
+
|
|
35
|
+
// Export
|
|
36
|
+
export { ExportController, getExportPresets } from "./export";
|
|
37
|
+
export type {
|
|
38
|
+
ExportConfig,
|
|
39
|
+
ExportProgress,
|
|
40
|
+
ExportResult,
|
|
41
|
+
ExportPreset,
|
|
42
|
+
ExportFormat,
|
|
43
|
+
ExportCodec,
|
|
44
|
+
ExportQuality,
|
|
45
|
+
ExportState,
|
|
46
|
+
} from "./export";
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @rajeev02/video-editor — Timeline
|
|
3
|
+
* Multi-track timeline with clips, trim, split, reorder, layers
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type TrackType = "video" | "audio" | "overlay" | "text" | "sticker";
|
|
7
|
+
|
|
8
|
+
export interface VideoClip {
|
|
9
|
+
id: string;
|
|
10
|
+
sourceUri: string;
|
|
11
|
+
/** Start time in source media (ms) */
|
|
12
|
+
sourceStartMs: number;
|
|
13
|
+
/** End time in source media (ms) */
|
|
14
|
+
sourceEndMs: number;
|
|
15
|
+
/** Position on timeline (ms) */
|
|
16
|
+
timelineStartMs: number;
|
|
17
|
+
/** Duration on timeline (ms) */
|
|
18
|
+
durationMs: number;
|
|
19
|
+
/** Playback speed multiplier */
|
|
20
|
+
speed: number;
|
|
21
|
+
/** Whether audio is muted */
|
|
22
|
+
muted: boolean;
|
|
23
|
+
/** Volume (0-1) */
|
|
24
|
+
volume: number;
|
|
25
|
+
/** Thumbnail URI */
|
|
26
|
+
thumbnailUri?: string;
|
|
27
|
+
/** Original file width/height */
|
|
28
|
+
width?: number;
|
|
29
|
+
height?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AudioClip {
|
|
33
|
+
id: string;
|
|
34
|
+
sourceUri: string;
|
|
35
|
+
title?: string;
|
|
36
|
+
sourceStartMs: number;
|
|
37
|
+
sourceEndMs: number;
|
|
38
|
+
timelineStartMs: number;
|
|
39
|
+
durationMs: number;
|
|
40
|
+
volume: number;
|
|
41
|
+
/** Fade in duration (ms) */
|
|
42
|
+
fadeInMs: number;
|
|
43
|
+
/** Fade out duration (ms) */
|
|
44
|
+
fadeOutMs: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TextClip {
|
|
48
|
+
id: string;
|
|
49
|
+
text: string;
|
|
50
|
+
fontFamily: string;
|
|
51
|
+
fontSize: number;
|
|
52
|
+
color: string;
|
|
53
|
+
backgroundColor?: string;
|
|
54
|
+
position: { x: number; y: number };
|
|
55
|
+
timelineStartMs: number;
|
|
56
|
+
durationMs: number;
|
|
57
|
+
animation?: TextAnimation;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type TextAnimation =
|
|
61
|
+
| "none"
|
|
62
|
+
| "fade_in"
|
|
63
|
+
| "fade_out"
|
|
64
|
+
| "slide_in_left"
|
|
65
|
+
| "slide_in_right"
|
|
66
|
+
| "slide_in_bottom"
|
|
67
|
+
| "typewriter"
|
|
68
|
+
| "bounce"
|
|
69
|
+
| "zoom_in"
|
|
70
|
+
| "scale_up";
|
|
71
|
+
|
|
72
|
+
export interface StickerClip {
|
|
73
|
+
id: string;
|
|
74
|
+
source: string;
|
|
75
|
+
position: { x: number; y: number };
|
|
76
|
+
scale: number;
|
|
77
|
+
rotation: number;
|
|
78
|
+
timelineStartMs: number;
|
|
79
|
+
durationMs: number;
|
|
80
|
+
animation?: "none" | "bounce" | "spin" | "pulse" | "shake";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type TransitionType =
|
|
84
|
+
| "none"
|
|
85
|
+
| "crossfade"
|
|
86
|
+
| "slide_left"
|
|
87
|
+
| "slide_right"
|
|
88
|
+
| "slide_up"
|
|
89
|
+
| "slide_down"
|
|
90
|
+
| "zoom_in"
|
|
91
|
+
| "zoom_out"
|
|
92
|
+
| "spin"
|
|
93
|
+
| "blur"
|
|
94
|
+
| "wipe_left"
|
|
95
|
+
| "wipe_right"
|
|
96
|
+
| "dissolve"
|
|
97
|
+
| "glitch";
|
|
98
|
+
|
|
99
|
+
export interface Transition {
|
|
100
|
+
id: string;
|
|
101
|
+
type: TransitionType;
|
|
102
|
+
durationMs: number;
|
|
103
|
+
/** Between which two clip IDs */
|
|
104
|
+
fromClipId: string;
|
|
105
|
+
toClipId: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Video Timeline — manages multi-track editing
|
|
110
|
+
*/
|
|
111
|
+
export class VideoTimeline {
|
|
112
|
+
private videoClips: VideoClip[] = [];
|
|
113
|
+
private audioClips: AudioClip[] = [];
|
|
114
|
+
private textClips: TextClip[] = [];
|
|
115
|
+
private stickerClips: StickerClip[] = [];
|
|
116
|
+
private transitions: Transition[] = [];
|
|
117
|
+
private currentTimeMs: number = 0;
|
|
118
|
+
private listeners: Set<(event: TimelineEvent) => void> = new Set();
|
|
119
|
+
|
|
120
|
+
/** Add a video clip */
|
|
121
|
+
addVideoClip(clip: Omit<VideoClip, "id" | "timelineStartMs">): string {
|
|
122
|
+
const id = `vclip_${Date.now()}_${Math.random().toString(36).substr(2, 4)}`;
|
|
123
|
+
const timelineStartMs = this.getTotalDuration();
|
|
124
|
+
const fullClip: VideoClip = {
|
|
125
|
+
...clip,
|
|
126
|
+
id,
|
|
127
|
+
timelineStartMs,
|
|
128
|
+
speed: clip.speed ?? 1,
|
|
129
|
+
muted: clip.muted ?? false,
|
|
130
|
+
volume: clip.volume ?? 1,
|
|
131
|
+
};
|
|
132
|
+
this.videoClips.push(fullClip);
|
|
133
|
+
this.emit({ type: "clip_added", clipId: id, trackType: "video" });
|
|
134
|
+
return id;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Add an audio track */
|
|
138
|
+
addAudioClip(clip: Omit<AudioClip, "id">): string {
|
|
139
|
+
const id = `aclip_${Date.now()}_${Math.random().toString(36).substr(2, 4)}`;
|
|
140
|
+
this.audioClips.push({
|
|
141
|
+
...clip,
|
|
142
|
+
id,
|
|
143
|
+
fadeInMs: clip.fadeInMs ?? 0,
|
|
144
|
+
fadeOutMs: clip.fadeOutMs ?? 0,
|
|
145
|
+
});
|
|
146
|
+
this.emit({ type: "clip_added", clipId: id, trackType: "audio" });
|
|
147
|
+
return id;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Add text overlay */
|
|
151
|
+
addTextClip(clip: Omit<TextClip, "id">): string {
|
|
152
|
+
const id = `tclip_${Date.now()}`;
|
|
153
|
+
this.textClips.push({ ...clip, id });
|
|
154
|
+
return id;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Add sticker overlay */
|
|
158
|
+
addStickerClip(clip: Omit<StickerClip, "id">): string {
|
|
159
|
+
const id = `sclip_${Date.now()}`;
|
|
160
|
+
this.stickerClips.push({ ...clip, id });
|
|
161
|
+
return id;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Trim a video clip */
|
|
165
|
+
trimClip(clipId: string, newStartMs: number, newEndMs: number): boolean {
|
|
166
|
+
const clip = this.videoClips.find((c) => c.id === clipId);
|
|
167
|
+
if (!clip) return false;
|
|
168
|
+
clip.sourceStartMs = newStartMs;
|
|
169
|
+
clip.sourceEndMs = newEndMs;
|
|
170
|
+
clip.durationMs = (newEndMs - newStartMs) / clip.speed;
|
|
171
|
+
this.recalculateTimeline();
|
|
172
|
+
this.emit({ type: "clip_trimmed", clipId });
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Split a clip at a given time */
|
|
177
|
+
splitClip(clipId: string, atTimeMs: number): string | null {
|
|
178
|
+
const idx = this.videoClips.findIndex((c) => c.id === clipId);
|
|
179
|
+
if (idx < 0) return null;
|
|
180
|
+
const clip = this.videoClips[idx];
|
|
181
|
+
const splitPoint =
|
|
182
|
+
clip.sourceStartMs + (atTimeMs - clip.timelineStartMs) * clip.speed;
|
|
183
|
+
if (splitPoint <= clip.sourceStartMs || splitPoint >= clip.sourceEndMs)
|
|
184
|
+
return null;
|
|
185
|
+
|
|
186
|
+
const newClipId = `vclip_${Date.now()}`;
|
|
187
|
+
const newClip: VideoClip = {
|
|
188
|
+
...clip,
|
|
189
|
+
id: newClipId,
|
|
190
|
+
sourceStartMs: splitPoint,
|
|
191
|
+
timelineStartMs: atTimeMs,
|
|
192
|
+
durationMs: (clip.sourceEndMs - splitPoint) / clip.speed,
|
|
193
|
+
};
|
|
194
|
+
clip.sourceEndMs = splitPoint;
|
|
195
|
+
clip.durationMs = (splitPoint - clip.sourceStartMs) / clip.speed;
|
|
196
|
+
|
|
197
|
+
this.videoClips.splice(idx + 1, 0, newClip);
|
|
198
|
+
this.recalculateTimeline();
|
|
199
|
+
this.emit({ type: "clip_split", clipId, newClipId });
|
|
200
|
+
return newClipId;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Remove a clip */
|
|
204
|
+
removeClip(clipId: string): boolean {
|
|
205
|
+
const before = this.videoClips.length + this.audioClips.length;
|
|
206
|
+
this.videoClips = this.videoClips.filter((c) => c.id !== clipId);
|
|
207
|
+
this.audioClips = this.audioClips.filter((c) => c.id !== clipId);
|
|
208
|
+
this.textClips = this.textClips.filter((c) => c.id !== clipId);
|
|
209
|
+
this.stickerClips = this.stickerClips.filter((c) => c.id !== clipId);
|
|
210
|
+
const removed = this.videoClips.length + this.audioClips.length < before;
|
|
211
|
+
if (removed) {
|
|
212
|
+
this.recalculateTimeline();
|
|
213
|
+
this.emit({ type: "clip_removed", clipId });
|
|
214
|
+
}
|
|
215
|
+
return removed;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Reorder video clips */
|
|
219
|
+
reorderClip(clipId: string, newIndex: number): boolean {
|
|
220
|
+
const idx = this.videoClips.findIndex((c) => c.id === clipId);
|
|
221
|
+
if (idx < 0 || newIndex < 0 || newIndex >= this.videoClips.length)
|
|
222
|
+
return false;
|
|
223
|
+
const [clip] = this.videoClips.splice(idx, 1);
|
|
224
|
+
this.videoClips.splice(newIndex, 0, clip);
|
|
225
|
+
this.recalculateTimeline();
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Set clip speed */
|
|
230
|
+
setClipSpeed(clipId: string, speed: number): void {
|
|
231
|
+
const clip = this.videoClips.find((c) => c.id === clipId);
|
|
232
|
+
if (clip) {
|
|
233
|
+
clip.speed = Math.max(0.1, Math.min(8, speed));
|
|
234
|
+
clip.durationMs = (clip.sourceEndMs - clip.sourceStartMs) / clip.speed;
|
|
235
|
+
this.recalculateTimeline();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Add transition between clips */
|
|
240
|
+
addTransition(
|
|
241
|
+
fromClipId: string,
|
|
242
|
+
toClipId: string,
|
|
243
|
+
type: TransitionType,
|
|
244
|
+
durationMs: number = 500,
|
|
245
|
+
): string {
|
|
246
|
+
const id = `trans_${Date.now()}`;
|
|
247
|
+
this.transitions.push({ id, type, durationMs, fromClipId, toClipId });
|
|
248
|
+
return id;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Get total duration of the timeline */
|
|
252
|
+
getTotalDuration(): number {
|
|
253
|
+
if (this.videoClips.length === 0) return 0;
|
|
254
|
+
const last = this.videoClips[this.videoClips.length - 1];
|
|
255
|
+
return last.timelineStartMs + last.durationMs;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Seek to position */
|
|
259
|
+
seek(timeMs: number): void {
|
|
260
|
+
this.currentTimeMs = Math.max(0, Math.min(timeMs, this.getTotalDuration()));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Get current time */
|
|
264
|
+
getCurrentTime(): number {
|
|
265
|
+
return this.currentTimeMs;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Get all clips */
|
|
269
|
+
getVideoClips(): VideoClip[] {
|
|
270
|
+
return [...this.videoClips];
|
|
271
|
+
}
|
|
272
|
+
getAudioClips(): AudioClip[] {
|
|
273
|
+
return [...this.audioClips];
|
|
274
|
+
}
|
|
275
|
+
getTextClips(): TextClip[] {
|
|
276
|
+
return [...this.textClips];
|
|
277
|
+
}
|
|
278
|
+
getStickerClips(): StickerClip[] {
|
|
279
|
+
return [...this.stickerClips];
|
|
280
|
+
}
|
|
281
|
+
getTransitions(): Transition[] {
|
|
282
|
+
return [...this.transitions];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Get clip count */
|
|
286
|
+
getClipCount(): number {
|
|
287
|
+
return this.videoClips.length + this.audioClips.length;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Subscribe to events */
|
|
291
|
+
on(listener: (event: TimelineEvent) => void): () => void {
|
|
292
|
+
this.listeners.add(listener);
|
|
293
|
+
return () => this.listeners.delete(listener);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private recalculateTimeline(): void {
|
|
297
|
+
let pos = 0;
|
|
298
|
+
for (const clip of this.videoClips) {
|
|
299
|
+
clip.timelineStartMs = pos;
|
|
300
|
+
pos += clip.durationMs;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private emit(event: TimelineEvent): void {
|
|
305
|
+
for (const l of this.listeners) {
|
|
306
|
+
try {
|
|
307
|
+
l(event);
|
|
308
|
+
} catch {}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export type TimelineEvent =
|
|
314
|
+
| { type: "clip_added"; clipId: string; trackType: TrackType }
|
|
315
|
+
| { type: "clip_removed"; clipId: string }
|
|
316
|
+
| { type: "clip_trimmed"; clipId: string }
|
|
317
|
+
| { type: "clip_split"; clipId: string; newClipId: string };
|