omgkit 2.1.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugin/skills/databases/mongodb/SKILL.md +81 -28
- package/plugin/skills/databases/prisma/SKILL.md +87 -32
- package/plugin/skills/databases/redis/SKILL.md +80 -27
- package/plugin/skills/devops/aws/SKILL.md +80 -26
- package/plugin/skills/devops/github-actions/SKILL.md +84 -32
- package/plugin/skills/devops/kubernetes/SKILL.md +94 -32
- package/plugin/skills/devops/performance-profiling/SKILL.md +59 -863
- package/plugin/skills/frameworks/django/SKILL.md +158 -24
- package/plugin/skills/frameworks/express/SKILL.md +153 -33
- package/plugin/skills/frameworks/fastapi/SKILL.md +153 -34
- package/plugin/skills/frameworks/laravel/SKILL.md +146 -33
- package/plugin/skills/frameworks/nestjs/SKILL.md +137 -25
- package/plugin/skills/frameworks/rails/SKILL.md +594 -28
- package/plugin/skills/frameworks/react/SKILL.md +94 -962
- package/plugin/skills/frameworks/spring/SKILL.md +528 -35
- package/plugin/skills/frameworks/vue/SKILL.md +147 -25
- package/plugin/skills/frontend/accessibility/SKILL.md +145 -36
- package/plugin/skills/frontend/frontend-design/SKILL.md +114 -29
- package/plugin/skills/frontend/responsive/SKILL.md +131 -28
- package/plugin/skills/frontend/shadcn-ui/SKILL.md +133 -43
- package/plugin/skills/frontend/tailwindcss/SKILL.md +105 -37
- package/plugin/skills/frontend/threejs/SKILL.md +110 -35
- package/plugin/skills/languages/javascript/SKILL.md +195 -34
- package/plugin/skills/methodology/brainstorming/SKILL.md +98 -30
- package/plugin/skills/methodology/defense-in-depth/SKILL.md +83 -37
- package/plugin/skills/methodology/dispatching-parallel-agents/SKILL.md +92 -31
- package/plugin/skills/methodology/executing-plans/SKILL.md +117 -28
- package/plugin/skills/methodology/finishing-development-branch/SKILL.md +111 -32
- package/plugin/skills/methodology/problem-solving/SKILL.md +65 -311
- package/plugin/skills/methodology/receiving-code-review/SKILL.md +76 -27
- package/plugin/skills/methodology/requesting-code-review/SKILL.md +93 -22
- package/plugin/skills/methodology/root-cause-tracing/SKILL.md +75 -40
- package/plugin/skills/methodology/sequential-thinking/SKILL.md +75 -224
- package/plugin/skills/methodology/systematic-debugging/SKILL.md +81 -35
- package/plugin/skills/methodology/test-driven-development/SKILL.md +120 -26
- package/plugin/skills/methodology/testing-anti-patterns/SKILL.md +88 -35
- package/plugin/skills/methodology/token-optimization/SKILL.md +73 -34
- package/plugin/skills/methodology/verification-before-completion/SKILL.md +128 -28
- package/plugin/skills/methodology/writing-plans/SKILL.md +105 -20
- package/plugin/skills/omega/omega-architecture/SKILL.md +178 -40
- package/plugin/skills/omega/omega-coding/SKILL.md +247 -41
- package/plugin/skills/omega/omega-sprint/SKILL.md +208 -46
- package/plugin/skills/omega/omega-testing/SKILL.md +253 -42
- package/plugin/skills/omega/omega-thinking/SKILL.md +263 -51
- package/plugin/skills/security/better-auth/SKILL.md +83 -34
- package/plugin/skills/security/oauth/SKILL.md +118 -35
- package/plugin/skills/security/owasp/SKILL.md +112 -35
- package/plugin/skills/testing/playwright/SKILL.md +141 -38
- package/plugin/skills/testing/pytest/SKILL.md +137 -38
- package/plugin/skills/testing/vitest/SKILL.md +124 -39
- package/plugin/skills/tools/document-processing/SKILL.md +111 -838
- package/plugin/skills/tools/image-processing/SKILL.md +126 -659
- package/plugin/skills/tools/mcp-development/SKILL.md +85 -758
- package/plugin/skills/tools/media-processing/SKILL.md +118 -735
- package/plugin/stdrules/SKILL_STANDARDS.md +490 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
-
name:
|
|
3
|
-
description:
|
|
2
|
+
name: Processing Media
|
|
3
|
+
description: Processes audio and video with ffmpeg including transcoding, streaming, and batch operations. Use when building video pipelines, converting formats, generating thumbnails, or implementing HLS/DASH streaming.
|
|
4
4
|
category: tools
|
|
5
5
|
triggers:
|
|
6
6
|
- media processing
|
|
@@ -12,818 +12,201 @@ triggers:
|
|
|
12
12
|
- streaming
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
-
# Media
|
|
15
|
+
# Processing Media
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
## Purpose
|
|
20
|
-
|
|
21
|
-
Handle media processing requirements efficiently:
|
|
22
|
-
|
|
23
|
-
- Transcode videos between formats
|
|
24
|
-
- Extract and process audio tracks
|
|
25
|
-
- Generate thumbnails and previews
|
|
26
|
-
- Implement adaptive streaming (HLS/DASH)
|
|
27
|
-
- Process user-uploaded media
|
|
28
|
-
- Build automated media pipelines
|
|
29
|
-
|
|
30
|
-
## Features
|
|
31
|
-
|
|
32
|
-
### 1. Video Transcoding
|
|
17
|
+
## Quick Start
|
|
33
18
|
|
|
34
19
|
```typescript
|
|
35
20
|
import ffmpeg from 'fluent-ffmpeg';
|
|
36
21
|
import { path as ffmpegPath } from '@ffmpeg-installer/ffmpeg';
|
|
37
|
-
import { path as ffprobePath } from '@ffprobe-installer/ffprobe';
|
|
38
22
|
|
|
39
23
|
ffmpeg.setFfmpegPath(ffmpegPath);
|
|
40
|
-
ffmpeg.setFfprobePath(ffprobePath);
|
|
41
|
-
|
|
42
|
-
interface TranscodeOptions {
|
|
43
|
-
inputPath: string;
|
|
44
|
-
outputPath: string;
|
|
45
|
-
format?: 'mp4' | 'webm' | 'mov';
|
|
46
|
-
codec?: 'h264' | 'h265' | 'vp9';
|
|
47
|
-
resolution?: '1080p' | '720p' | '480p' | '360p';
|
|
48
|
-
bitrate?: string;
|
|
49
|
-
fps?: number;
|
|
50
|
-
onProgress?: (progress: number) => void;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const RESOLUTIONS = {
|
|
54
|
-
'1080p': { width: 1920, height: 1080 },
|
|
55
|
-
'720p': { width: 1280, height: 720 },
|
|
56
|
-
'480p': { width: 854, height: 480 },
|
|
57
|
-
'360p': { width: 640, height: 360 },
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const CODECS = {
|
|
61
|
-
h264: { video: 'libx264', audio: 'aac' },
|
|
62
|
-
h265: { video: 'libx265', audio: 'aac' },
|
|
63
|
-
vp9: { video: 'libvpx-vp9', audio: 'libopus' },
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
async function transcodeVideo(options: TranscodeOptions): Promise<void> {
|
|
67
|
-
const {
|
|
68
|
-
inputPath,
|
|
69
|
-
outputPath,
|
|
70
|
-
format = 'mp4',
|
|
71
|
-
codec = 'h264',
|
|
72
|
-
resolution = '720p',
|
|
73
|
-
bitrate,
|
|
74
|
-
fps,
|
|
75
|
-
onProgress,
|
|
76
|
-
} = options;
|
|
77
|
-
|
|
78
|
-
const { width, height } = RESOLUTIONS[resolution];
|
|
79
|
-
const { video: videoCodec, audio: audioCodec } = CODECS[codec];
|
|
80
24
|
|
|
25
|
+
// Transcode video to web-optimized MP4
|
|
26
|
+
async function transcodeVideo(inputPath: string, outputPath: string): Promise<void> {
|
|
81
27
|
return new Promise((resolve, reject) => {
|
|
82
|
-
|
|
83
|
-
.videoCodec(
|
|
84
|
-
.audioCodec(
|
|
85
|
-
.size(
|
|
86
|
-
.
|
|
87
|
-
.
|
|
88
|
-
|
|
89
|
-
// Apply bitrate if specified
|
|
90
|
-
if (bitrate) {
|
|
91
|
-
command = command.videoBitrate(bitrate);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Apply FPS if specified
|
|
95
|
-
if (fps) {
|
|
96
|
-
command = command.fps(fps);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// H.264 specific options for better compatibility
|
|
100
|
-
if (codec === 'h264') {
|
|
101
|
-
command = command.outputOptions([
|
|
102
|
-
'-preset medium',
|
|
103
|
-
'-profile:v high',
|
|
104
|
-
'-level 4.0',
|
|
105
|
-
'-movflags +faststart', // Web optimization
|
|
106
|
-
]);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
command
|
|
110
|
-
.on('progress', (progress) => {
|
|
111
|
-
onProgress?.(progress.percent || 0);
|
|
112
|
-
})
|
|
113
|
-
.on('end', () => resolve())
|
|
28
|
+
ffmpeg(inputPath)
|
|
29
|
+
.videoCodec('libx264')
|
|
30
|
+
.audioCodec('aac')
|
|
31
|
+
.size('1280x720')
|
|
32
|
+
.outputOptions(['-preset medium', '-movflags +faststart'])
|
|
33
|
+
.on('end', resolve)
|
|
114
34
|
.on('error', reject)
|
|
115
35
|
.save(outputPath);
|
|
116
36
|
});
|
|
117
37
|
}
|
|
118
38
|
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
duration: number;
|
|
122
|
-
width: number;
|
|
123
|
-
height: number;
|
|
124
|
-
codec: string;
|
|
125
|
-
bitrate: number;
|
|
126
|
-
fps: number;
|
|
127
|
-
audioCodec?: string;
|
|
128
|
-
audioChannels?: number;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async function getVideoMetadata(filePath: string): Promise<VideoMetadata> {
|
|
39
|
+
// Generate thumbnail at specific timestamp
|
|
40
|
+
async function generateThumbnail(videoPath: string, outputPath: string, timestamp: number): Promise<void> {
|
|
132
41
|
return new Promise((resolve, reject) => {
|
|
133
|
-
ffmpeg
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const videoStream = metadata.streams.find(s => s.codec_type === 'video');
|
|
137
|
-
const audioStream = metadata.streams.find(s => s.codec_type === 'audio');
|
|
138
|
-
|
|
139
|
-
if (!videoStream) {
|
|
140
|
-
return reject(new Error('No video stream found'));
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
resolve({
|
|
144
|
-
duration: metadata.format.duration || 0,
|
|
145
|
-
width: videoStream.width || 0,
|
|
146
|
-
height: videoStream.height || 0,
|
|
147
|
-
codec: videoStream.codec_name || '',
|
|
148
|
-
bitrate: parseInt(metadata.format.bit_rate || '0'),
|
|
149
|
-
fps: eval(videoStream.r_frame_rate || '0'),
|
|
150
|
-
audioCodec: audioStream?.codec_name,
|
|
151
|
-
audioChannels: audioStream?.channels,
|
|
152
|
-
});
|
|
153
|
-
});
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
```
|
|
157
|
-
|
|
158
|
-
### 2. Thumbnail Generation
|
|
159
|
-
|
|
160
|
-
```typescript
|
|
161
|
-
interface ThumbnailOptions {
|
|
162
|
-
inputPath: string;
|
|
163
|
-
outputDir: string;
|
|
164
|
-
count?: number;
|
|
165
|
-
size?: string;
|
|
166
|
-
filename?: string;
|
|
167
|
-
timestamps?: number[]; // Specific timestamps in seconds
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
async function generateThumbnails(options: ThumbnailOptions): Promise<string[]> {
|
|
171
|
-
const {
|
|
172
|
-
inputPath,
|
|
173
|
-
outputDir,
|
|
174
|
-
count = 1,
|
|
175
|
-
size = '320x180',
|
|
176
|
-
filename = 'thumb_%i.jpg',
|
|
177
|
-
timestamps,
|
|
178
|
-
} = options;
|
|
179
|
-
|
|
180
|
-
await fs.mkdir(outputDir, { recursive: true });
|
|
181
|
-
|
|
182
|
-
return new Promise((resolve, reject) => {
|
|
183
|
-
const command = ffmpeg(inputPath).screenshots({
|
|
184
|
-
count: timestamps ? undefined : count,
|
|
185
|
-
folder: outputDir,
|
|
186
|
-
size,
|
|
187
|
-
filename,
|
|
188
|
-
timemarks: timestamps,
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
const generatedFiles: string[] = [];
|
|
192
|
-
|
|
193
|
-
command
|
|
194
|
-
.on('filenames', (filenames) => {
|
|
195
|
-
generatedFiles.push(...filenames.map(f => path.join(outputDir, f)));
|
|
196
|
-
})
|
|
197
|
-
.on('end', () => resolve(generatedFiles))
|
|
42
|
+
ffmpeg(videoPath)
|
|
43
|
+
.screenshots({ timestamps: [timestamp], filename: 'thumb.jpg', folder: outputPath, size: '320x180' })
|
|
44
|
+
.on('end', resolve)
|
|
198
45
|
.on('error', reject);
|
|
199
46
|
});
|
|
200
47
|
}
|
|
201
48
|
|
|
202
|
-
//
|
|
203
|
-
async function
|
|
204
|
-
inputPath: string,
|
|
205
|
-
outputPath: string,
|
|
206
|
-
options: {
|
|
207
|
-
duration?: number;
|
|
208
|
-
startTime?: number;
|
|
209
|
-
fps?: number;
|
|
210
|
-
width?: number;
|
|
211
|
-
format?: 'gif' | 'webm';
|
|
212
|
-
} = {}
|
|
213
|
-
): Promise<void> {
|
|
214
|
-
const {
|
|
215
|
-
duration = 5,
|
|
216
|
-
startTime = 0,
|
|
217
|
-
fps = 10,
|
|
218
|
-
width = 320,
|
|
219
|
-
format = 'gif',
|
|
220
|
-
} = options;
|
|
221
|
-
|
|
222
|
-
return new Promise((resolve, reject) => {
|
|
223
|
-
let command = ffmpeg(inputPath)
|
|
224
|
-
.setStartTime(startTime)
|
|
225
|
-
.setDuration(duration)
|
|
226
|
-
.fps(fps)
|
|
227
|
-
.size(`${width}x?`);
|
|
228
|
-
|
|
229
|
-
if (format === 'gif') {
|
|
230
|
-
command = command.outputOptions([
|
|
231
|
-
'-vf', `fps=${fps},scale=${width}:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse`,
|
|
232
|
-
]);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
command
|
|
236
|
-
.on('end', () => resolve())
|
|
237
|
-
.on('error', reject)
|
|
238
|
-
.save(outputPath);
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// Generate video sprite sheet for preview scrubbing
|
|
243
|
-
async function generateSpriteSheet(
|
|
244
|
-
inputPath: string,
|
|
245
|
-
outputPath: string,
|
|
246
|
-
options: {
|
|
247
|
-
cols?: number;
|
|
248
|
-
rows?: number;
|
|
249
|
-
thumbWidth?: number;
|
|
250
|
-
interval?: number; // Seconds between frames
|
|
251
|
-
} = {}
|
|
252
|
-
): Promise<{ spritePath: string; vttPath: string }> {
|
|
253
|
-
const { cols = 10, rows = 10, thumbWidth = 160, interval = 5 } = options;
|
|
254
|
-
const totalFrames = cols * rows;
|
|
255
|
-
|
|
256
|
-
const metadata = await getVideoMetadata(inputPath);
|
|
257
|
-
const actualInterval = Math.max(interval, metadata.duration / totalFrames);
|
|
258
|
-
|
|
49
|
+
// Extract audio from video
|
|
50
|
+
async function extractAudio(videoPath: string, audioPath: string): Promise<void> {
|
|
259
51
|
return new Promise((resolve, reject) => {
|
|
260
|
-
ffmpeg(
|
|
261
|
-
.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
])
|
|
265
|
-
.on('end', async () => {
|
|
266
|
-
// Generate VTT file for sprite coordinates
|
|
267
|
-
const vttPath = outputPath.replace(/\.\w+$/, '.vtt');
|
|
268
|
-
const vttContent = generateVTT(metadata.duration, cols, rows, thumbWidth, actualInterval);
|
|
269
|
-
await fs.writeFile(vttPath, vttContent);
|
|
270
|
-
resolve({ spritePath: outputPath, vttPath });
|
|
271
|
-
})
|
|
52
|
+
ffmpeg(videoPath)
|
|
53
|
+
.audioCodec('libmp3lame')
|
|
54
|
+
.audioBitrate('192k')
|
|
55
|
+
.on('end', resolve)
|
|
272
56
|
.on('error', reject)
|
|
273
|
-
.save(
|
|
57
|
+
.save(audioPath);
|
|
274
58
|
});
|
|
275
59
|
}
|
|
276
60
|
```
|
|
277
61
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
```typescript
|
|
281
|
-
interface AudioOptions {
|
|
282
|
-
inputPath: string;
|
|
283
|
-
outputPath: string;
|
|
284
|
-
format?: 'mp3' | 'aac' | 'flac' | 'wav' | 'ogg';
|
|
285
|
-
bitrate?: string;
|
|
286
|
-
sampleRate?: number;
|
|
287
|
-
channels?: 1 | 2;
|
|
288
|
-
normalize?: boolean;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
async function processAudio(options: AudioOptions): Promise<void> {
|
|
292
|
-
const {
|
|
293
|
-
inputPath,
|
|
294
|
-
outputPath,
|
|
295
|
-
format = 'mp3',
|
|
296
|
-
bitrate = '192k',
|
|
297
|
-
sampleRate = 44100,
|
|
298
|
-
channels = 2,
|
|
299
|
-
normalize = false,
|
|
300
|
-
} = options;
|
|
301
|
-
|
|
302
|
-
return new Promise((resolve, reject) => {
|
|
303
|
-
let command = ffmpeg(inputPath)
|
|
304
|
-
.audioCodec(getAudioCodec(format))
|
|
305
|
-
.audioBitrate(bitrate)
|
|
306
|
-
.audioFrequency(sampleRate)
|
|
307
|
-
.audioChannels(channels);
|
|
308
|
-
|
|
309
|
-
if (normalize) {
|
|
310
|
-
command = command.audioFilters('loudnorm=I=-16:TP=-1.5:LRA=11');
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
command
|
|
314
|
-
.on('end', () => resolve())
|
|
315
|
-
.on('error', reject)
|
|
316
|
-
.save(outputPath);
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
function getAudioCodec(format: string): string {
|
|
321
|
-
const codecs: Record<string, string> = {
|
|
322
|
-
mp3: 'libmp3lame',
|
|
323
|
-
aac: 'aac',
|
|
324
|
-
flac: 'flac',
|
|
325
|
-
wav: 'pcm_s16le',
|
|
326
|
-
ogg: 'libvorbis',
|
|
327
|
-
};
|
|
328
|
-
return codecs[format] || 'aac';
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Extract audio from video
|
|
332
|
-
async function extractAudio(
|
|
333
|
-
videoPath: string,
|
|
334
|
-
outputPath: string,
|
|
335
|
-
options: Partial<AudioOptions> = {}
|
|
336
|
-
): Promise<void> {
|
|
337
|
-
return processAudio({
|
|
338
|
-
inputPath: videoPath,
|
|
339
|
-
outputPath,
|
|
340
|
-
...options,
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// Merge audio tracks
|
|
345
|
-
async function mergeAudioTracks(
|
|
346
|
-
tracks: string[],
|
|
347
|
-
outputPath: string,
|
|
348
|
-
options: {
|
|
349
|
-
crossfade?: number;
|
|
350
|
-
normalize?: boolean;
|
|
351
|
-
} = {}
|
|
352
|
-
): Promise<void> {
|
|
353
|
-
const { crossfade = 0, normalize = true } = options;
|
|
354
|
-
|
|
355
|
-
return new Promise((resolve, reject) => {
|
|
356
|
-
let command = ffmpeg();
|
|
357
|
-
|
|
358
|
-
// Add all input files
|
|
359
|
-
tracks.forEach(track => {
|
|
360
|
-
command = command.input(track);
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
// Build filter complex for concatenation
|
|
364
|
-
const filterInputs = tracks.map((_, i) => `[${i}:a]`).join('');
|
|
365
|
-
let filter = `${filterInputs}concat=n=${tracks.length}:v=0:a=1`;
|
|
366
|
-
|
|
367
|
-
if (crossfade > 0) {
|
|
368
|
-
filter = tracks.map((_, i) => `[${i}:a]`).join('') +
|
|
369
|
-
`acrossfade=d=${crossfade}:c1=tri:c2=tri`;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
if (normalize) {
|
|
373
|
-
filter += ',loudnorm=I=-16:TP=-1.5:LRA=11';
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
filter += '[out]';
|
|
377
|
-
|
|
378
|
-
command
|
|
379
|
-
.complexFilter(filter)
|
|
380
|
-
.outputOptions(['-map', '[out]'])
|
|
381
|
-
.on('end', () => resolve())
|
|
382
|
-
.on('error', reject)
|
|
383
|
-
.save(outputPath);
|
|
384
|
-
});
|
|
385
|
-
}
|
|
62
|
+
## Features
|
|
386
63
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
width = 1920,
|
|
400
|
-
height = 200,
|
|
401
|
-
color = '0x00FF00',
|
|
402
|
-
background = '0x000000',
|
|
403
|
-
} = options;
|
|
64
|
+
| Feature | Description | Guide |
|
|
65
|
+
|---------|-------------|-------|
|
|
66
|
+
| Video Transcoding | Convert between formats (MP4, WebM, MOV) | Use libx264/libx265 for H.264/H.265 encoding |
|
|
67
|
+
| Resolution Scaling | Resize videos to target resolutions | Use size() with aspect ratio preservation |
|
|
68
|
+
| Thumbnail Generation | Create preview images from videos | Use screenshots() with timestamps |
|
|
69
|
+
| Audio Extraction | Extract audio tracks from video files | Use audioCodec() without video output |
|
|
70
|
+
| Audio Processing | Convert, normalize, and merge audio | Use audioFilters() for normalization |
|
|
71
|
+
| HLS Streaming | Generate adaptive bitrate streams | Create multi-quality variants with m3u8 playlists |
|
|
72
|
+
| DASH Streaming | MPEG-DASH manifest generation | Use -f dash with segment configuration |
|
|
73
|
+
| Batch Processing | Process multiple files concurrently | Use p-queue for controlled parallelism |
|
|
74
|
+
| Progress Tracking | Monitor transcoding progress | Listen to 'progress' events |
|
|
75
|
+
| Metadata Extraction | Read video/audio metadata | Use ffprobe for duration, resolution, codec info |
|
|
404
76
|
|
|
405
|
-
|
|
406
|
-
ffmpeg(audioPath)
|
|
407
|
-
.complexFilter([
|
|
408
|
-
`showwavespic=s=${width}x${height}:colors=${color}`,
|
|
409
|
-
`drawbox=c=${background}@0.5:replace=1:t=fill`,
|
|
410
|
-
])
|
|
411
|
-
.outputOptions(['-frames:v', '1'])
|
|
412
|
-
.on('end', () => resolve())
|
|
413
|
-
.on('error', reject)
|
|
414
|
-
.save(outputPath);
|
|
415
|
-
});
|
|
416
|
-
}
|
|
417
|
-
```
|
|
77
|
+
## Common Patterns
|
|
418
78
|
|
|
419
|
-
###
|
|
79
|
+
### HLS Adaptive Streaming
|
|
420
80
|
|
|
421
81
|
```typescript
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
segmentDuration?: number;
|
|
427
|
-
playlistType?: 'vod' | 'event';
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
interface StreamQuality {
|
|
431
|
-
name: string;
|
|
432
|
-
resolution: string;
|
|
433
|
-
bitrate: string;
|
|
434
|
-
audioBitrate?: string;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
const DEFAULT_QUALITIES: StreamQuality[] = [
|
|
438
|
-
{ name: '1080p', resolution: '1920x1080', bitrate: '5000k', audioBitrate: '192k' },
|
|
439
|
-
{ name: '720p', resolution: '1280x720', bitrate: '2500k', audioBitrate: '128k' },
|
|
440
|
-
{ name: '480p', resolution: '854x480', bitrate: '1000k', audioBitrate: '96k' },
|
|
441
|
-
{ name: '360p', resolution: '640x360', bitrate: '500k', audioBitrate: '64k' },
|
|
82
|
+
const QUALITIES = [
|
|
83
|
+
{ name: '1080p', resolution: '1920x1080', bitrate: '5000k' },
|
|
84
|
+
{ name: '720p', resolution: '1280x720', bitrate: '2500k' },
|
|
85
|
+
{ name: '480p', resolution: '854x480', bitrate: '1000k' },
|
|
442
86
|
];
|
|
443
87
|
|
|
444
|
-
async function
|
|
445
|
-
const {
|
|
446
|
-
inputPath,
|
|
447
|
-
outputDir,
|
|
448
|
-
qualities = DEFAULT_QUALITIES,
|
|
449
|
-
segmentDuration = 6,
|
|
450
|
-
playlistType = 'vod',
|
|
451
|
-
} = options;
|
|
452
|
-
|
|
453
|
-
await fs.mkdir(outputDir, { recursive: true });
|
|
454
|
-
|
|
455
|
-
// Generate each quality level
|
|
456
|
-
const variants: string[] = [];
|
|
457
|
-
|
|
458
|
-
for (const quality of qualities) {
|
|
88
|
+
async function generateHLS(inputPath: string, outputDir: string): Promise<string> {
|
|
89
|
+
for (const quality of QUALITIES) {
|
|
459
90
|
const qualityDir = path.join(outputDir, quality.name);
|
|
460
91
|
await fs.mkdir(qualityDir, { recursive: true });
|
|
461
92
|
|
|
462
93
|
await new Promise<void>((resolve, reject) => {
|
|
463
94
|
ffmpeg(inputPath)
|
|
464
95
|
.videoCodec('libx264')
|
|
465
|
-
.audioCodec('aac')
|
|
466
96
|
.size(quality.resolution)
|
|
467
97
|
.videoBitrate(quality.bitrate)
|
|
468
|
-
.
|
|
469
|
-
.
|
|
470
|
-
'-preset fast',
|
|
471
|
-
'-profile:v main',
|
|
472
|
-
'-level 3.1',
|
|
473
|
-
'-start_number 0',
|
|
474
|
-
`-hls_time ${segmentDuration}`,
|
|
475
|
-
`-hls_playlist_type ${playlistType}`,
|
|
476
|
-
'-hls_segment_filename', path.join(qualityDir, 'segment_%03d.ts'),
|
|
477
|
-
'-f hls',
|
|
478
|
-
])
|
|
479
|
-
.on('end', () => resolve())
|
|
98
|
+
.outputOptions(['-hls_time 6', '-hls_playlist_type vod', '-f hls'])
|
|
99
|
+
.on('end', resolve)
|
|
480
100
|
.on('error', reject)
|
|
481
101
|
.save(path.join(qualityDir, 'playlist.m3u8'));
|
|
482
102
|
});
|
|
483
|
-
|
|
484
|
-
variants.push({
|
|
485
|
-
bandwidth: parseInt(quality.bitrate) * 1000,
|
|
486
|
-
resolution: quality.resolution,
|
|
487
|
-
path: `${quality.name}/playlist.m3u8`,
|
|
488
|
-
});
|
|
489
103
|
}
|
|
490
104
|
|
|
491
105
|
// Generate master playlist
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
return
|
|
106
|
+
const master = '#EXTM3U\n' + QUALITIES.map(q =>
|
|
107
|
+
`#EXT-X-STREAM-INF:BANDWIDTH=${parseInt(q.bitrate) * 1000}\n${q.name}/playlist.m3u8`
|
|
108
|
+
).join('\n');
|
|
109
|
+
await fs.writeFile(path.join(outputDir, 'master.m3u8'), master);
|
|
110
|
+
return path.join(outputDir, 'master.m3u8');
|
|
497
111
|
}
|
|
112
|
+
```
|
|
498
113
|
|
|
499
|
-
|
|
500
|
-
bandwidth: number;
|
|
501
|
-
resolution: string;
|
|
502
|
-
path: string;
|
|
503
|
-
}>): string {
|
|
504
|
-
let content = '#EXTM3U\n#EXT-X-VERSION:3\n\n';
|
|
505
|
-
|
|
506
|
-
for (const variant of variants.sort((a, b) => b.bandwidth - a.bandwidth)) {
|
|
507
|
-
content += `#EXT-X-STREAM-INF:BANDWIDTH=${variant.bandwidth},RESOLUTION=${variant.resolution}\n`;
|
|
508
|
-
content += `${variant.path}\n\n`;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
return content;
|
|
512
|
-
}
|
|
114
|
+
### Video Upload Processing Pipeline
|
|
513
115
|
|
|
514
|
-
|
|
515
|
-
async function
|
|
516
|
-
|
|
517
|
-
outputDir: string,
|
|
518
|
-
qualities: StreamQuality[] = DEFAULT_QUALITIES
|
|
519
|
-
): Promise<string> {
|
|
116
|
+
```typescript
|
|
117
|
+
async function processVideoUpload(inputPath: string, videoId: string): Promise<VideoAssets> {
|
|
118
|
+
const outputDir = path.join(MEDIA_DIR, videoId);
|
|
520
119
|
await fs.mkdir(outputDir, { recursive: true });
|
|
521
120
|
|
|
522
|
-
|
|
523
|
-
|
|
121
|
+
// Get metadata
|
|
122
|
+
const metadata = await getVideoMetadata(inputPath);
|
|
524
123
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
const adaptationSet: string[] = [];
|
|
124
|
+
// Generate thumbnails
|
|
125
|
+
const thumbnails = await generateThumbnails(inputPath, path.join(outputDir, 'thumbs'), 10);
|
|
528
126
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
.output(path.join(outputDir, `stream_${index}.mp4`))
|
|
532
|
-
.videoCodec('libx264')
|
|
533
|
-
.size(quality.resolution)
|
|
534
|
-
.videoBitrate(quality.bitrate);
|
|
127
|
+
// Transcode to web format
|
|
128
|
+
await transcodeVideo(inputPath, path.join(outputDir, 'video.mp4'));
|
|
535
129
|
|
|
536
|
-
|
|
537
|
-
|
|
130
|
+
// Generate HLS for streaming
|
|
131
|
+
const streamUrl = await generateHLS(inputPath, path.join(outputDir, 'hls'));
|
|
538
132
|
|
|
539
|
-
|
|
540
|
-
.outputOptions([
|
|
541
|
-
'-f dash',
|
|
542
|
-
'-init_seg_name', 'init_$RepresentationID$.m4s',
|
|
543
|
-
'-media_seg_name', 'chunk_$RepresentationID$_$Number%05d$.m4s',
|
|
544
|
-
'-use_timeline 1',
|
|
545
|
-
'-use_template 1',
|
|
546
|
-
'-adaptation_sets', 'id=0,streams=v id=1,streams=a',
|
|
547
|
-
])
|
|
548
|
-
.on('end', () => resolve(path.join(outputDir, 'manifest.mpd')))
|
|
549
|
-
.on('error', reject)
|
|
550
|
-
.save(path.join(outputDir, 'manifest.mpd'));
|
|
551
|
-
});
|
|
133
|
+
return { metadata, thumbnails, videoUrl: `/media/${videoId}/video.mp4`, streamUrl };
|
|
552
134
|
}
|
|
553
135
|
```
|
|
554
136
|
|
|
555
|
-
###
|
|
137
|
+
### Audio Normalization and Merge
|
|
556
138
|
|
|
557
139
|
```typescript
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
inputPath: string;
|
|
563
|
-
outputPath: string;
|
|
564
|
-
operation: 'transcode' | 'thumbnail' | 'extract-audio' | 'hls';
|
|
565
|
-
options: Record<string, any>;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
interface BatchResult {
|
|
569
|
-
id: string;
|
|
570
|
-
success: boolean;
|
|
571
|
-
outputPath?: string;
|
|
572
|
-
error?: string;
|
|
573
|
-
duration: number;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
class MediaProcessingPipeline {
|
|
577
|
-
private queue: PQueue;
|
|
578
|
-
private results: Map<string, BatchResult> = new Map();
|
|
579
|
-
|
|
580
|
-
constructor(concurrency: number = 2) {
|
|
581
|
-
this.queue = new PQueue({ concurrency });
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
async processBatch(
|
|
585
|
-
jobs: BatchJob[],
|
|
586
|
-
onProgress?: (completed: number, total: number, current: BatchJob) => void
|
|
587
|
-
): Promise<BatchResult[]> {
|
|
588
|
-
let completed = 0;
|
|
589
|
-
|
|
590
|
-
const tasks = jobs.map(job =>
|
|
591
|
-
this.queue.add(async () => {
|
|
592
|
-
const startTime = Date.now();
|
|
593
|
-
|
|
594
|
-
try {
|
|
595
|
-
onProgress?.(completed, jobs.length, job);
|
|
596
|
-
|
|
597
|
-
const outputPath = await this.processJob(job);
|
|
598
|
-
|
|
599
|
-
const result: BatchResult = {
|
|
600
|
-
id: job.id,
|
|
601
|
-
success: true,
|
|
602
|
-
outputPath,
|
|
603
|
-
duration: Date.now() - startTime,
|
|
604
|
-
};
|
|
605
|
-
|
|
606
|
-
this.results.set(job.id, result);
|
|
607
|
-
completed++;
|
|
608
|
-
return result;
|
|
609
|
-
} catch (error) {
|
|
610
|
-
const result: BatchResult = {
|
|
611
|
-
id: job.id,
|
|
612
|
-
success: false,
|
|
613
|
-
error: error.message,
|
|
614
|
-
duration: Date.now() - startTime,
|
|
615
|
-
};
|
|
616
|
-
|
|
617
|
-
this.results.set(job.id, result);
|
|
618
|
-
completed++;
|
|
619
|
-
return result;
|
|
620
|
-
}
|
|
621
|
-
})
|
|
622
|
-
);
|
|
623
|
-
|
|
624
|
-
return Promise.all(tasks);
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
private async processJob(job: BatchJob): Promise<string> {
|
|
628
|
-
switch (job.operation) {
|
|
629
|
-
case 'transcode':
|
|
630
|
-
await transcodeVideo({
|
|
631
|
-
inputPath: job.inputPath,
|
|
632
|
-
outputPath: job.outputPath,
|
|
633
|
-
...job.options,
|
|
634
|
-
});
|
|
635
|
-
return job.outputPath;
|
|
636
|
-
|
|
637
|
-
case 'thumbnail':
|
|
638
|
-
const thumbs = await generateThumbnails({
|
|
639
|
-
inputPath: job.inputPath,
|
|
640
|
-
outputDir: path.dirname(job.outputPath),
|
|
641
|
-
...job.options,
|
|
642
|
-
});
|
|
643
|
-
return thumbs[0];
|
|
644
|
-
|
|
645
|
-
case 'extract-audio':
|
|
646
|
-
await extractAudio(job.inputPath, job.outputPath, job.options);
|
|
647
|
-
return job.outputPath;
|
|
648
|
-
|
|
649
|
-
case 'hls':
|
|
650
|
-
return generateHLSStream({
|
|
651
|
-
inputPath: job.inputPath,
|
|
652
|
-
outputDir: job.outputPath,
|
|
653
|
-
...job.options,
|
|
654
|
-
});
|
|
655
|
-
|
|
656
|
-
default:
|
|
657
|
-
throw new Error(`Unknown operation: ${job.operation}`);
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
getResult(jobId: string): BatchResult | undefined {
|
|
662
|
-
return this.results.get(jobId);
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
async waitForCompletion(): Promise<void> {
|
|
666
|
-
await this.queue.onIdle();
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
// Usage example
|
|
671
|
-
async function processUserUploads(uploads: Upload[]): Promise<void> {
|
|
672
|
-
const pipeline = new MediaProcessingPipeline(2);
|
|
673
|
-
|
|
674
|
-
const jobs: BatchJob[] = uploads.map(upload => ({
|
|
675
|
-
id: upload.id,
|
|
676
|
-
inputPath: upload.tempPath,
|
|
677
|
-
outputPath: path.join(MEDIA_DIR, upload.id, 'video.mp4'),
|
|
678
|
-
operation: 'transcode',
|
|
679
|
-
options: {
|
|
680
|
-
resolution: '720p',
|
|
681
|
-
format: 'mp4',
|
|
682
|
-
},
|
|
683
|
-
}));
|
|
684
|
-
|
|
685
|
-
// Add thumbnail jobs
|
|
686
|
-
uploads.forEach(upload => {
|
|
687
|
-
jobs.push({
|
|
688
|
-
id: `${upload.id}-thumb`,
|
|
689
|
-
inputPath: upload.tempPath,
|
|
690
|
-
outputPath: path.join(MEDIA_DIR, upload.id, 'thumbnails'),
|
|
691
|
-
operation: 'thumbnail',
|
|
692
|
-
options: { count: 5 },
|
|
693
|
-
});
|
|
694
|
-
});
|
|
140
|
+
async function normalizeAndMerge(tracks: string[], outputPath: string): Promise<void> {
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
let command = ffmpeg();
|
|
143
|
+
tracks.forEach(track => { command = command.input(track); });
|
|
695
144
|
|
|
696
|
-
|
|
697
|
-
|
|
145
|
+
const filterInputs = tracks.map((_, i) => `[${i}:a]`).join('');
|
|
146
|
+
command
|
|
147
|
+
.complexFilter(`${filterInputs}concat=n=${tracks.length}:v=0:a=1,loudnorm=I=-16:TP=-1.5[out]`)
|
|
148
|
+
.outputOptions(['-map', '[out]'])
|
|
149
|
+
.on('end', resolve)
|
|
150
|
+
.on('error', reject)
|
|
151
|
+
.save(outputPath);
|
|
698
152
|
});
|
|
699
|
-
|
|
700
|
-
// Update database with results
|
|
701
|
-
for (const result of results) {
|
|
702
|
-
if (result.success) {
|
|
703
|
-
await db.media.update({
|
|
704
|
-
where: { id: result.id.replace('-thumb', '') },
|
|
705
|
-
data: { processedPath: result.outputPath, status: 'ready' },
|
|
706
|
-
});
|
|
707
|
-
} else {
|
|
708
|
-
await db.media.update({
|
|
709
|
-
where: { id: result.id },
|
|
710
|
-
data: { status: 'failed', error: result.error },
|
|
711
|
-
});
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
153
|
}
|
|
715
154
|
```
|
|
716
155
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
### 1. Video Upload Processing
|
|
156
|
+
### Batch Processing with Progress
|
|
720
157
|
|
|
721
158
|
```typescript
|
|
722
|
-
|
|
723
|
-
async function handleVideoUpload(file: Express.Multer.File, userId: string): Promise<Video> {
|
|
724
|
-
const videoId = generateId();
|
|
725
|
-
const baseDir = path.join(MEDIA_DIR, videoId);
|
|
726
|
-
|
|
727
|
-
// Create video record
|
|
728
|
-
const video = await db.video.create({
|
|
729
|
-
data: {
|
|
730
|
-
id: videoId,
|
|
731
|
-
userId,
|
|
732
|
-
originalName: file.originalname,
|
|
733
|
-
status: 'processing',
|
|
734
|
-
},
|
|
735
|
-
});
|
|
736
|
-
|
|
737
|
-
// Process asynchronously
|
|
738
|
-
processVideoAsync(videoId, file.path, baseDir);
|
|
739
|
-
|
|
740
|
-
return video;
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
async function processVideoAsync(videoId: string, inputPath: string, outputDir: string): Promise<void> {
|
|
744
|
-
try {
|
|
745
|
-
await fs.mkdir(outputDir, { recursive: true });
|
|
746
|
-
|
|
747
|
-
// Get metadata
|
|
748
|
-
const metadata = await getVideoMetadata(inputPath);
|
|
749
|
-
|
|
750
|
-
// Generate thumbnails
|
|
751
|
-
const thumbnails = await generateThumbnails({
|
|
752
|
-
inputPath,
|
|
753
|
-
outputDir: path.join(outputDir, 'thumbnails'),
|
|
754
|
-
count: 10,
|
|
755
|
-
});
|
|
756
|
-
|
|
757
|
-
// Transcode to web format
|
|
758
|
-
await transcodeVideo({
|
|
759
|
-
inputPath,
|
|
760
|
-
outputPath: path.join(outputDir, 'video.mp4'),
|
|
761
|
-
format: 'mp4',
|
|
762
|
-
codec: 'h264',
|
|
763
|
-
resolution: '720p',
|
|
764
|
-
});
|
|
765
|
-
|
|
766
|
-
// Generate HLS for streaming
|
|
767
|
-
await generateHLSStream({
|
|
768
|
-
inputPath,
|
|
769
|
-
outputDir: path.join(outputDir, 'hls'),
|
|
770
|
-
qualities: [
|
|
771
|
-
{ name: '720p', resolution: '1280x720', bitrate: '2500k' },
|
|
772
|
-
{ name: '480p', resolution: '854x480', bitrate: '1000k' },
|
|
773
|
-
],
|
|
774
|
-
});
|
|
775
|
-
|
|
776
|
-
// Update database
|
|
777
|
-
await db.video.update({
|
|
778
|
-
where: { id: videoId },
|
|
779
|
-
data: {
|
|
780
|
-
status: 'ready',
|
|
781
|
-
duration: metadata.duration,
|
|
782
|
-
width: metadata.width,
|
|
783
|
-
height: metadata.height,
|
|
784
|
-
thumbnailUrl: `/media/${videoId}/thumbnails/thumb_1.jpg`,
|
|
785
|
-
streamUrl: `/media/${videoId}/hls/master.m3u8`,
|
|
786
|
-
},
|
|
787
|
-
});
|
|
159
|
+
import PQueue from 'p-queue';
|
|
788
160
|
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
161
|
+
async function batchTranscode(
|
|
162
|
+
files: string[],
|
|
163
|
+
outputDir: string,
|
|
164
|
+
onProgress?: (completed: number, total: number) => void
|
|
165
|
+
): Promise<BatchResult[]> {
|
|
166
|
+
const queue = new PQueue({ concurrency: 2 });
|
|
167
|
+
const results: BatchResult[] = [];
|
|
168
|
+
let completed = 0;
|
|
169
|
+
|
|
170
|
+
for (const file of files) {
|
|
171
|
+
queue.add(async () => {
|
|
172
|
+
const outputPath = path.join(outputDir, `${path.basename(file, path.extname(file))}.mp4`);
|
|
173
|
+
try {
|
|
174
|
+
await transcodeVideo(file, outputPath);
|
|
175
|
+
results.push({ file, success: true, outputPath });
|
|
176
|
+
} catch (error) {
|
|
177
|
+
results.push({ file, success: false, error: error.message });
|
|
178
|
+
}
|
|
179
|
+
completed++;
|
|
180
|
+
onProgress?.(completed, files.length);
|
|
795
181
|
});
|
|
796
182
|
}
|
|
183
|
+
|
|
184
|
+
await queue.onIdle();
|
|
185
|
+
return results;
|
|
797
186
|
}
|
|
798
187
|
```
|
|
799
188
|
|
|
800
189
|
## Best Practices
|
|
801
190
|
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
- Don't use synchronous operations for large files
|
|
815
|
-
- Don't ignore ffmpeg exit codes
|
|
816
|
-
- Don't skip error handling
|
|
817
|
-
- Don't process without concurrency limits
|
|
818
|
-
- Don't forget to set output format explicitly
|
|
191
|
+
| Do | Avoid |
|
|
192
|
+
|----|-------|
|
|
193
|
+
| Enable hardware acceleration (NVENC/VAAPI) when available | Using software encoding on capable hardware |
|
|
194
|
+
| Implement progress tracking for long operations | Running transcodes without user feedback |
|
|
195
|
+
| Use streaming for large file processing | Loading entire videos into memory |
|
|
196
|
+
| Set reasonable timeouts for processing | Allowing indefinite process hangs |
|
|
197
|
+
| Validate input formats before processing | Processing arbitrary untrusted files |
|
|
198
|
+
| Clean up temporary files after processing | Leaving temp files on disk |
|
|
199
|
+
| Use -movflags +faststart for web videos | Serving videos without fast-start optimization |
|
|
200
|
+
| Limit concurrent processing based on resources | Running unlimited parallel transcodes |
|
|
201
|
+
| Handle ffmpeg exit codes properly | Ignoring process errors |
|
|
202
|
+
| Set explicit output formats | Relying on auto-detection |
|
|
819
203
|
|
|
820
204
|
## Related Skills
|
|
821
205
|
|
|
822
|
-
- **
|
|
823
|
-
- **
|
|
824
|
-
- **backend-development** - Integration patterns
|
|
206
|
+
- **image-processing** - Image manipulation with Sharp
|
|
207
|
+
- **document-processing** - Office document handling
|
|
825
208
|
|
|
826
|
-
##
|
|
209
|
+
## References
|
|
827
210
|
|
|
828
211
|
- [FFmpeg Documentation](https://ffmpeg.org/documentation.html)
|
|
829
212
|
- [fluent-ffmpeg](https://github.com/fluent-ffmpeg/node-fluent-ffmpeg)
|