varg.ai-sdk 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/.claude/settings.local.json +7 -0
- package/.env.example +24 -0
- package/CLAUDE.md +118 -0
- package/README.md +231 -0
- package/SKILLS.md +157 -0
- package/STRUCTURE.md +92 -0
- package/TEST_RESULTS.md +122 -0
- package/action/captions/SKILL.md +170 -0
- package/action/captions/index.ts +227 -0
- package/action/edit/SKILL.md +235 -0
- package/action/edit/index.ts +493 -0
- package/action/image/SKILL.md +140 -0
- package/action/image/index.ts +112 -0
- package/action/sync/SKILL.md +136 -0
- package/action/sync/index.ts +187 -0
- package/action/transcribe/SKILL.md +179 -0
- package/action/transcribe/index.ts +227 -0
- package/action/video/SKILL.md +116 -0
- package/action/video/index.ts +135 -0
- package/action/voice/SKILL.md +125 -0
- package/action/voice/index.ts +201 -0
- package/biome.json +33 -0
- package/index.ts +38 -0
- package/lib/README.md +144 -0
- package/lib/ai-sdk/fal.ts +106 -0
- package/lib/ai-sdk/replicate.ts +107 -0
- package/lib/elevenlabs.ts +382 -0
- package/lib/fal.ts +478 -0
- package/lib/ffmpeg.ts +467 -0
- package/lib/fireworks.ts +235 -0
- package/lib/groq.ts +246 -0
- package/lib/higgsfield.ts +176 -0
- package/lib/remotion/SKILL.md +823 -0
- package/lib/remotion/cli.ts +115 -0
- package/lib/remotion/functions.ts +283 -0
- package/lib/remotion/index.ts +19 -0
- package/lib/remotion/templates.ts +73 -0
- package/lib/replicate.ts +304 -0
- package/output.txt +1 -0
- package/package.json +35 -0
- package/pipeline/cookbooks/SKILL.md +285 -0
- package/pipeline/cookbooks/remotion-video.md +585 -0
- package/pipeline/cookbooks/round-video-character.md +337 -0
- package/pipeline/cookbooks/talking-character.md +59 -0
- package/test-import.ts +7 -0
- package/test-services.ts +97 -0
- package/tsconfig.json +29 -0
- package/utilities/s3.ts +147 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: video-editing
|
|
3
|
+
description: edit videos with ffmpeg operations including resize, trim, concat, social media optimization, and montage creation. use for video editing, format conversion, social media prep, merging clips, or batch video operations.
|
|
4
|
+
allowed-tools: Read, Bash
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# video editing
|
|
8
|
+
|
|
9
|
+
comprehensive video editing service combining ffmpeg operations into common workflows.
|
|
10
|
+
|
|
11
|
+
## commands
|
|
12
|
+
|
|
13
|
+
### prepare for social media
|
|
14
|
+
```bash
|
|
15
|
+
bun run service/edit.ts social <input> <output> <platform> [audioPath]
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
automatically resize and optimize for platform specs:
|
|
19
|
+
- **tiktok**: 1080x1920 (9:16)
|
|
20
|
+
- **instagram**: 1080x1920 (9:16)
|
|
21
|
+
- **youtube-shorts**: 1080x1920 (9:16)
|
|
22
|
+
- **youtube**: 1920x1080 (16:9)
|
|
23
|
+
- **twitter**: 1280x720 (16:9)
|
|
24
|
+
|
|
25
|
+
**example:**
|
|
26
|
+
```bash
|
|
27
|
+
bun run service/edit.ts social raw.mp4 tiktok.mp4 tiktok
|
|
28
|
+
bun run service/edit.ts social raw.mp4 ig.mp4 instagram audio.mp3
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### create montage
|
|
32
|
+
```bash
|
|
33
|
+
bun run service/edit.ts montage <output> <clip1> <clip2> [clip3...]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
combine multiple video clips into one:
|
|
37
|
+
|
|
38
|
+
**example:**
|
|
39
|
+
```bash
|
|
40
|
+
bun run service/edit.ts montage final.mp4 intro.mp4 main.mp4 outro.mp4
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### quick trim
|
|
44
|
+
```bash
|
|
45
|
+
bun run service/edit.ts trim <input> <output> <start> [end]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
extract a segment from video:
|
|
49
|
+
|
|
50
|
+
**example:**
|
|
51
|
+
```bash
|
|
52
|
+
bun run service/edit.ts trim long.mp4 short.mp4 10 30
|
|
53
|
+
# extracts seconds 10-30
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### quick resize
|
|
57
|
+
```bash
|
|
58
|
+
bun run service/edit.ts resize <input> <output> <preset>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
resize to common aspect ratios:
|
|
62
|
+
- **vertical**: 9:16 (1080x1920)
|
|
63
|
+
- **square**: 1:1 (1080x1080)
|
|
64
|
+
- **landscape**: 16:9 (1920x1080)
|
|
65
|
+
- **4k**: 3840x2160
|
|
66
|
+
|
|
67
|
+
**example:**
|
|
68
|
+
```bash
|
|
69
|
+
bun run service/edit.ts resize raw.mp4 vertical.mp4 vertical
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### merge with audio
|
|
73
|
+
```bash
|
|
74
|
+
bun run service/edit.ts merge_audio <audio> <output> <video1> [video2...]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
concatenate videos and add audio overlay:
|
|
78
|
+
|
|
79
|
+
**example:**
|
|
80
|
+
```bash
|
|
81
|
+
bun run service/edit.ts merge_audio song.mp3 final.mp4 clip1.mp4 clip2.mp4
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## as library
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
import {
|
|
88
|
+
prepareForSocial,
|
|
89
|
+
createMontage,
|
|
90
|
+
quickTrim,
|
|
91
|
+
quickResize,
|
|
92
|
+
mergeWithAudio,
|
|
93
|
+
editPipeline
|
|
94
|
+
} from "./service/edit"
|
|
95
|
+
|
|
96
|
+
// social media optimization
|
|
97
|
+
await prepareForSocial({
|
|
98
|
+
input: "raw.mp4",
|
|
99
|
+
output: "tiktok.mp4",
|
|
100
|
+
platform: "tiktok",
|
|
101
|
+
withAudio: "audio.mp3"
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// create montage
|
|
105
|
+
await createMontage({
|
|
106
|
+
clips: ["clip1.mp4", "clip2.mp4"],
|
|
107
|
+
output: "montage.mp4",
|
|
108
|
+
maxClipDuration: 5,
|
|
109
|
+
targetResolution: { width: 1920, height: 1080 }
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// trim video
|
|
113
|
+
await quickTrim("video.mp4", "trimmed.mp4", 10, 30)
|
|
114
|
+
|
|
115
|
+
// resize video
|
|
116
|
+
await quickResize("video.mp4", "resized.mp4", "vertical")
|
|
117
|
+
|
|
118
|
+
// merge videos with audio
|
|
119
|
+
await mergeWithAudio(
|
|
120
|
+
["clip1.mp4", "clip2.mp4"],
|
|
121
|
+
"audio.mp3",
|
|
122
|
+
"final.mp4"
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## advanced: edit pipeline
|
|
127
|
+
|
|
128
|
+
chain multiple operations:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
import { editPipeline } from "./service/edit"
|
|
132
|
+
|
|
133
|
+
await editPipeline({
|
|
134
|
+
steps: [
|
|
135
|
+
{
|
|
136
|
+
operation: "resize",
|
|
137
|
+
options: {
|
|
138
|
+
input: "raw.mp4",
|
|
139
|
+
width: 1080,
|
|
140
|
+
height: 1920
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
operation: "add_audio",
|
|
145
|
+
options: {
|
|
146
|
+
videoPath: "temp.mp4",
|
|
147
|
+
audioPath: "music.mp3"
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
operation: "trim",
|
|
152
|
+
options: {
|
|
153
|
+
input: "temp2.mp4",
|
|
154
|
+
start: 0,
|
|
155
|
+
duration: 30
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
],
|
|
159
|
+
finalOutput: "final.mp4"
|
|
160
|
+
})
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**available operations:**
|
|
164
|
+
- `concat` - concatenate videos
|
|
165
|
+
- `add_audio` - overlay audio
|
|
166
|
+
- `resize` - change dimensions
|
|
167
|
+
- `trim` - extract segment
|
|
168
|
+
- `convert` - change format
|
|
169
|
+
- `extract_audio` - extract audio track
|
|
170
|
+
|
|
171
|
+
## when to use
|
|
172
|
+
|
|
173
|
+
use this skill when:
|
|
174
|
+
- preparing videos for social media platforms
|
|
175
|
+
- combining multiple video clips
|
|
176
|
+
- extracting segments from longer videos
|
|
177
|
+
- resizing to specific aspect ratios
|
|
178
|
+
- adding background music to video montages
|
|
179
|
+
- batch processing video files
|
|
180
|
+
- optimizing videos for specific platforms
|
|
181
|
+
|
|
182
|
+
## typical workflows
|
|
183
|
+
|
|
184
|
+
### social media content
|
|
185
|
+
1. create or edit raw video
|
|
186
|
+
2. add captions (captions service)
|
|
187
|
+
3. prepare for platform (this service)
|
|
188
|
+
4. upload
|
|
189
|
+
|
|
190
|
+
### video montage
|
|
191
|
+
1. collect multiple clips
|
|
192
|
+
2. create montage (this service)
|
|
193
|
+
3. add audio overlay (this service)
|
|
194
|
+
4. add captions if needed (captions service)
|
|
195
|
+
|
|
196
|
+
### talking character for social
|
|
197
|
+
1. generate character (image service)
|
|
198
|
+
2. animate (video service)
|
|
199
|
+
3. sync with voice (sync service)
|
|
200
|
+
4. add captions (captions service)
|
|
201
|
+
5. optimize for tiktok/instagram (this service)
|
|
202
|
+
|
|
203
|
+
## tips
|
|
204
|
+
|
|
205
|
+
**social media optimization:**
|
|
206
|
+
- use platform-specific presets for correct aspect ratio
|
|
207
|
+
- vertical format (9:16) works for tiktok, instagram reels, youtube shorts
|
|
208
|
+
- landscape (16:9) works for youtube, twitter
|
|
209
|
+
|
|
210
|
+
**montage creation:**
|
|
211
|
+
- all clips should have similar resolution for best results
|
|
212
|
+
- use `maxClipDuration` to keep montage paced
|
|
213
|
+
- `targetResolution` ensures consistent quality
|
|
214
|
+
|
|
215
|
+
**trimming:**
|
|
216
|
+
- start time is in seconds
|
|
217
|
+
- end time is optional (omit to trim to end of video)
|
|
218
|
+
- use for extracting highlights or removing unwanted sections
|
|
219
|
+
|
|
220
|
+
## environment variables
|
|
221
|
+
|
|
222
|
+
no api keys required - uses ffmpeg
|
|
223
|
+
|
|
224
|
+
**system requirements:**
|
|
225
|
+
- ffmpeg must be installed
|
|
226
|
+
- `brew install ffmpeg` (macos)
|
|
227
|
+
- `apt-get install ffmpeg` (linux)
|
|
228
|
+
|
|
229
|
+
## processing time
|
|
230
|
+
|
|
231
|
+
depends on operation and video size:
|
|
232
|
+
- trim: 5-10 seconds
|
|
233
|
+
- resize: 10-30 seconds
|
|
234
|
+
- concat: 10-30 seconds per clip
|
|
235
|
+
- social optimization: 15-45 seconds
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* video editing service
|
|
5
|
+
* combines multiple ffmpeg operations into common workflows
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { extname } from "node:path";
|
|
10
|
+
import {
|
|
11
|
+
type AddAudioOptions,
|
|
12
|
+
addAudio,
|
|
13
|
+
type ConcatVideosOptions,
|
|
14
|
+
type ConvertFormatOptions,
|
|
15
|
+
concatVideos,
|
|
16
|
+
convertFormat,
|
|
17
|
+
extractAudio,
|
|
18
|
+
type ResizeVideoOptions,
|
|
19
|
+
resizeVideo,
|
|
20
|
+
type TrimVideoOptions,
|
|
21
|
+
trimVideo,
|
|
22
|
+
} from "../../lib/ffmpeg";
|
|
23
|
+
|
|
24
|
+
// types
|
|
25
|
+
export interface EditPipelineStep {
|
|
26
|
+
operation:
|
|
27
|
+
| "concat"
|
|
28
|
+
| "add_audio"
|
|
29
|
+
| "resize"
|
|
30
|
+
| "trim"
|
|
31
|
+
| "convert"
|
|
32
|
+
| "extract_audio";
|
|
33
|
+
// options should contain all parameters except 'output' which is added by the pipeline
|
|
34
|
+
// biome-ignore lint/suspicious/noExplicitAny: pipeline options are validated at runtime by underlying ffmpeg functions
|
|
35
|
+
options: Record<string, any>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface EditPipelineOptions {
|
|
39
|
+
steps: EditPipelineStep[];
|
|
40
|
+
finalOutput: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PrepareForSocialOptions {
|
|
44
|
+
input: string;
|
|
45
|
+
output: string;
|
|
46
|
+
platform: "tiktok" | "instagram" | "youtube-shorts" | "youtube" | "twitter";
|
|
47
|
+
withAudio?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CreateMontageOptions {
|
|
51
|
+
clips: string[];
|
|
52
|
+
output: string;
|
|
53
|
+
maxClipDuration?: number;
|
|
54
|
+
targetResolution?: { width: number; height: number };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// core functions
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* run a series of editing operations in sequence
|
|
61
|
+
* each step uses output from previous step as input
|
|
62
|
+
*/
|
|
63
|
+
export async function editPipeline(
|
|
64
|
+
options: EditPipelineOptions,
|
|
65
|
+
): Promise<string> {
|
|
66
|
+
const { steps, finalOutput } = options;
|
|
67
|
+
|
|
68
|
+
if (!steps || steps.length === 0) {
|
|
69
|
+
throw new Error("at least one step is required");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log(`[edit] running ${steps.length} editing steps...`);
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < steps.length; i++) {
|
|
75
|
+
const step = steps[i];
|
|
76
|
+
if (!step) {
|
|
77
|
+
throw new Error(`step ${i} is undefined`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const isLastStep = i === steps.length - 1;
|
|
81
|
+
const output = isLastStep
|
|
82
|
+
? finalOutput
|
|
83
|
+
: `/tmp/edit-step-${i}${extname(finalOutput)}`;
|
|
84
|
+
|
|
85
|
+
console.log(`[edit] step ${i + 1}/${steps.length}: ${step.operation}`);
|
|
86
|
+
|
|
87
|
+
switch (step.operation) {
|
|
88
|
+
case "concat":
|
|
89
|
+
await concatVideos({
|
|
90
|
+
...step.options,
|
|
91
|
+
output,
|
|
92
|
+
} as ConcatVideosOptions);
|
|
93
|
+
break;
|
|
94
|
+
|
|
95
|
+
case "add_audio":
|
|
96
|
+
await addAudio({
|
|
97
|
+
...step.options,
|
|
98
|
+
output,
|
|
99
|
+
} as AddAudioOptions);
|
|
100
|
+
break;
|
|
101
|
+
|
|
102
|
+
case "resize":
|
|
103
|
+
await resizeVideo({
|
|
104
|
+
...step.options,
|
|
105
|
+
output,
|
|
106
|
+
} as ResizeVideoOptions);
|
|
107
|
+
break;
|
|
108
|
+
|
|
109
|
+
case "trim":
|
|
110
|
+
await trimVideo({
|
|
111
|
+
...step.options,
|
|
112
|
+
output,
|
|
113
|
+
} as TrimVideoOptions);
|
|
114
|
+
break;
|
|
115
|
+
|
|
116
|
+
case "convert":
|
|
117
|
+
await convertFormat({
|
|
118
|
+
...step.options,
|
|
119
|
+
output,
|
|
120
|
+
} as ConvertFormatOptions);
|
|
121
|
+
break;
|
|
122
|
+
|
|
123
|
+
case "extract_audio":
|
|
124
|
+
await extractAudio((step.options as { input: string }).input, output);
|
|
125
|
+
break;
|
|
126
|
+
|
|
127
|
+
default:
|
|
128
|
+
throw new Error(`unknown operation: ${step.operation}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log(`[edit] pipeline complete: ${finalOutput}`);
|
|
133
|
+
return finalOutput;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* prepare video for social media platform
|
|
138
|
+
* automatically sets correct aspect ratio and resolution
|
|
139
|
+
*/
|
|
140
|
+
export async function prepareForSocial(
|
|
141
|
+
options: PrepareForSocialOptions,
|
|
142
|
+
): Promise<string> {
|
|
143
|
+
const { input, output, platform, withAudio } = options;
|
|
144
|
+
|
|
145
|
+
if (!input || !output || !platform) {
|
|
146
|
+
throw new Error("input, output, and platform are required");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!existsSync(input)) {
|
|
150
|
+
throw new Error(`input file not found: ${input}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
console.log(`[edit] preparing video for ${platform}...`);
|
|
154
|
+
|
|
155
|
+
const platformSpecs: Record<
|
|
156
|
+
string,
|
|
157
|
+
{ width: number; height: number; aspectRatio: string }
|
|
158
|
+
> = {
|
|
159
|
+
tiktok: { width: 1080, height: 1920, aspectRatio: "9:16" },
|
|
160
|
+
instagram: { width: 1080, height: 1920, aspectRatio: "9:16" },
|
|
161
|
+
"youtube-shorts": { width: 1080, height: 1920, aspectRatio: "9:16" },
|
|
162
|
+
youtube: { width: 1920, height: 1080, aspectRatio: "16:9" },
|
|
163
|
+
twitter: { width: 1280, height: 720, aspectRatio: "16:9" },
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const spec = platformSpecs[platform];
|
|
167
|
+
if (!spec) {
|
|
168
|
+
throw new Error(`unknown platform: ${platform}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const steps: EditPipelineStep[] = [];
|
|
172
|
+
|
|
173
|
+
// resize to platform specs
|
|
174
|
+
steps.push({
|
|
175
|
+
operation: "resize",
|
|
176
|
+
options: {
|
|
177
|
+
input,
|
|
178
|
+
width: spec.width,
|
|
179
|
+
height: spec.height,
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// add audio if provided
|
|
184
|
+
if (withAudio) {
|
|
185
|
+
if (!existsSync(withAudio)) {
|
|
186
|
+
throw new Error(`audio file not found: ${withAudio}`);
|
|
187
|
+
}
|
|
188
|
+
steps.push({
|
|
189
|
+
operation: "add_audio",
|
|
190
|
+
options: {
|
|
191
|
+
videoPath: input,
|
|
192
|
+
audioPath: withAudio,
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return editPipeline({ steps, finalOutput: output });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* create a montage from multiple video clips
|
|
202
|
+
* optionally trim clips and resize to consistent resolution
|
|
203
|
+
*/
|
|
204
|
+
export async function createMontage(
|
|
205
|
+
options: CreateMontageOptions,
|
|
206
|
+
): Promise<string> {
|
|
207
|
+
const { clips, output, maxClipDuration, targetResolution } = options;
|
|
208
|
+
|
|
209
|
+
if (!clips || clips.length === 0) {
|
|
210
|
+
throw new Error("at least one clip is required");
|
|
211
|
+
}
|
|
212
|
+
if (!output) {
|
|
213
|
+
throw new Error("output is required");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
console.log(`[edit] creating montage from ${clips.length} clips...`);
|
|
217
|
+
|
|
218
|
+
// validate all clips exist
|
|
219
|
+
for (const clip of clips) {
|
|
220
|
+
if (!existsSync(clip)) {
|
|
221
|
+
throw new Error(`clip not found: ${clip}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let processedClips = clips;
|
|
226
|
+
|
|
227
|
+
// trim clips if max duration specified
|
|
228
|
+
if (maxClipDuration) {
|
|
229
|
+
console.log(`[edit] trimming clips to ${maxClipDuration}s...`);
|
|
230
|
+
processedClips = [];
|
|
231
|
+
|
|
232
|
+
for (let i = 0; i < clips.length; i++) {
|
|
233
|
+
const clip = clips[i];
|
|
234
|
+
if (!clip) {
|
|
235
|
+
throw new Error(`clip ${i} is undefined`);
|
|
236
|
+
}
|
|
237
|
+
const trimmedPath = `/tmp/montage-clip-${i}${extname(clip)}`;
|
|
238
|
+
await trimVideo({
|
|
239
|
+
input: clip,
|
|
240
|
+
output: trimmedPath,
|
|
241
|
+
start: 0,
|
|
242
|
+
duration: maxClipDuration,
|
|
243
|
+
});
|
|
244
|
+
processedClips.push(trimmedPath);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// resize clips if target resolution specified
|
|
249
|
+
if (targetResolution) {
|
|
250
|
+
console.log(
|
|
251
|
+
`[edit] resizing clips to ${targetResolution.width}x${targetResolution.height}...`,
|
|
252
|
+
);
|
|
253
|
+
const resizedClips = [];
|
|
254
|
+
|
|
255
|
+
for (let i = 0; i < processedClips.length; i++) {
|
|
256
|
+
const clip = processedClips[i];
|
|
257
|
+
if (!clip) {
|
|
258
|
+
throw new Error(`clip ${i} is undefined`);
|
|
259
|
+
}
|
|
260
|
+
const resizedPath = `/tmp/montage-resized-${i}${extname(clip)}`;
|
|
261
|
+
await resizeVideo({
|
|
262
|
+
input: clip,
|
|
263
|
+
output: resizedPath,
|
|
264
|
+
width: targetResolution.width,
|
|
265
|
+
height: targetResolution.height,
|
|
266
|
+
});
|
|
267
|
+
resizedClips.push(resizedPath);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
processedClips = resizedClips;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// concatenate all clips
|
|
274
|
+
return concatVideos({
|
|
275
|
+
inputs: processedClips,
|
|
276
|
+
output,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* quick trim: trim video to specific segment
|
|
282
|
+
*/
|
|
283
|
+
export async function quickTrim(
|
|
284
|
+
input: string,
|
|
285
|
+
output: string,
|
|
286
|
+
start: number,
|
|
287
|
+
end?: number,
|
|
288
|
+
): Promise<string> {
|
|
289
|
+
if (!input || !output) {
|
|
290
|
+
throw new Error("input and output are required");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const duration = end ? end - start : undefined;
|
|
294
|
+
|
|
295
|
+
console.log(
|
|
296
|
+
`[edit] trimming video from ${start}s${duration ? ` for ${duration}s` : ""}...`,
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
return trimVideo({ input, output, start, duration });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* quick resize: resize video to common aspect ratios
|
|
304
|
+
*/
|
|
305
|
+
export async function quickResize(
|
|
306
|
+
input: string,
|
|
307
|
+
output: string,
|
|
308
|
+
preset: "vertical" | "square" | "landscape" | "4k",
|
|
309
|
+
): Promise<string> {
|
|
310
|
+
if (!input || !output) {
|
|
311
|
+
throw new Error("input and output are required");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const presets: Record<
|
|
315
|
+
string,
|
|
316
|
+
{ width: number; height: number; label: string }
|
|
317
|
+
> = {
|
|
318
|
+
vertical: { width: 1080, height: 1920, label: "9:16 (1080x1920)" },
|
|
319
|
+
square: { width: 1080, height: 1080, label: "1:1 (1080x1080)" },
|
|
320
|
+
landscape: { width: 1920, height: 1080, label: "16:9 (1920x1080)" },
|
|
321
|
+
"4k": { width: 3840, height: 2160, label: "4K (3840x2160)" },
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const spec = presets[preset];
|
|
325
|
+
if (!spec) {
|
|
326
|
+
throw new Error(`unknown preset: ${preset}`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
console.log(`[edit] resizing to ${spec.label}...`);
|
|
330
|
+
|
|
331
|
+
return resizeVideo({
|
|
332
|
+
input,
|
|
333
|
+
output,
|
|
334
|
+
width: spec.width,
|
|
335
|
+
height: spec.height,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* merge multiple videos with optional audio overlay
|
|
341
|
+
*/
|
|
342
|
+
export async function mergeWithAudio(
|
|
343
|
+
videos: string[],
|
|
344
|
+
audio: string,
|
|
345
|
+
output: string,
|
|
346
|
+
): Promise<string> {
|
|
347
|
+
if (!videos || videos.length === 0) {
|
|
348
|
+
throw new Error("at least one video is required");
|
|
349
|
+
}
|
|
350
|
+
if (!audio || !output) {
|
|
351
|
+
throw new Error("audio and output are required");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
console.log(`[edit] merging ${videos.length} videos with audio...`);
|
|
355
|
+
|
|
356
|
+
// first concatenate videos
|
|
357
|
+
const tempVideo = `/tmp/merged-video${extname(output)}`;
|
|
358
|
+
await concatVideos({
|
|
359
|
+
inputs: videos,
|
|
360
|
+
output: tempVideo,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// then add audio
|
|
364
|
+
return addAudio({
|
|
365
|
+
videoPath: tempVideo,
|
|
366
|
+
audioPath: audio,
|
|
367
|
+
output,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// cli
|
|
372
|
+
async function cli() {
|
|
373
|
+
const args = process.argv.slice(2);
|
|
374
|
+
const command = args[0];
|
|
375
|
+
|
|
376
|
+
if (!command || command === "help") {
|
|
377
|
+
console.log(`
|
|
378
|
+
usage:
|
|
379
|
+
bun run service/edit.ts <command> [args]
|
|
380
|
+
|
|
381
|
+
commands:
|
|
382
|
+
social <input> <output> <platform> [audioPath] prepare for social media
|
|
383
|
+
montage <output> <clip1> <clip2> [clip3...] create montage from clips
|
|
384
|
+
trim <input> <output> <start> [end] quick trim
|
|
385
|
+
resize <input> <output> <preset> quick resize
|
|
386
|
+
merge_audio <audio> <output> <video1> [video2...] merge videos with audio
|
|
387
|
+
|
|
388
|
+
platforms:
|
|
389
|
+
tiktok, instagram, youtube-shorts, youtube, twitter
|
|
390
|
+
|
|
391
|
+
resize presets:
|
|
392
|
+
vertical (9:16), square (1:1), landscape (16:9), 4k
|
|
393
|
+
|
|
394
|
+
examples:
|
|
395
|
+
bun run service/edit.ts social raw.mp4 tiktok.mp4 tiktok
|
|
396
|
+
bun run service/edit.ts social raw.mp4 ig.mp4 instagram audio.mp3
|
|
397
|
+
bun run service/edit.ts montage output.mp4 clip1.mp4 clip2.mp4 clip3.mp4
|
|
398
|
+
bun run service/edit.ts trim long.mp4 short.mp4 10 30
|
|
399
|
+
bun run service/edit.ts resize raw.mp4 vertical.mp4 vertical
|
|
400
|
+
bun run service/edit.ts merge_audio song.mp3 final.mp4 clip1.mp4 clip2.mp4
|
|
401
|
+
`);
|
|
402
|
+
process.exit(0);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
try {
|
|
406
|
+
switch (command) {
|
|
407
|
+
case "social": {
|
|
408
|
+
const input = args[1];
|
|
409
|
+
const output = args[2];
|
|
410
|
+
const platform = args[3] as PrepareForSocialOptions["platform"];
|
|
411
|
+
const withAudio = args[4];
|
|
412
|
+
|
|
413
|
+
if (!input || !output || !platform) {
|
|
414
|
+
throw new Error("input, output, and platform are required");
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
await prepareForSocial({ input, output, platform, withAudio });
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
case "montage": {
|
|
422
|
+
const output = args[1];
|
|
423
|
+
const clips = args.slice(2);
|
|
424
|
+
|
|
425
|
+
if (!output || clips.length === 0) {
|
|
426
|
+
throw new Error("output and at least one clip are required");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
await createMontage({ clips, output });
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
case "trim": {
|
|
434
|
+
const input = args[1];
|
|
435
|
+
const output = args[2];
|
|
436
|
+
const startArg = args[3];
|
|
437
|
+
const endArg = args[4];
|
|
438
|
+
|
|
439
|
+
if (!input || !output || !startArg) {
|
|
440
|
+
throw new Error("input, output, and start are required");
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const start = Number.parseFloat(startArg);
|
|
444
|
+
const end = endArg ? Number.parseFloat(endArg) : undefined;
|
|
445
|
+
|
|
446
|
+
if (Number.isNaN(start) || (endArg && Number.isNaN(end))) {
|
|
447
|
+
throw new Error("start and end must be valid numbers");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
await quickTrim(input, output, start, end);
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
case "resize": {
|
|
455
|
+
const input = args[1];
|
|
456
|
+
const output = args[2];
|
|
457
|
+
const preset = args[3] as "vertical" | "square" | "landscape" | "4k";
|
|
458
|
+
|
|
459
|
+
if (!input || !output || !preset) {
|
|
460
|
+
throw new Error("input, output, and preset are required");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
await quickResize(input, output, preset);
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
case "merge_audio": {
|
|
468
|
+
const audio = args[1];
|
|
469
|
+
const output = args[2];
|
|
470
|
+
const videos = args.slice(3);
|
|
471
|
+
|
|
472
|
+
if (!audio || !output || videos.length === 0) {
|
|
473
|
+
throw new Error("audio, output, and at least one video are required");
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
await mergeWithAudio(videos, audio, output);
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
default:
|
|
481
|
+
console.error(`unknown command: ${command}`);
|
|
482
|
+
console.log("run 'bun run service/edit.ts help' for usage");
|
|
483
|
+
process.exit(1);
|
|
484
|
+
}
|
|
485
|
+
} catch (error) {
|
|
486
|
+
console.error("[edit] error:", error);
|
|
487
|
+
process.exit(1);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (import.meta.main) {
|
|
492
|
+
cli();
|
|
493
|
+
}
|