simple-ffmpegjs 0.3.6 → 0.4.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/README.md +139 -38
- package/package.json +1 -1
- package/src/core/gaps.js +5 -31
- package/src/core/resolve.js +1 -1
- package/src/core/validation.js +295 -77
- package/src/ffmpeg/audio_builder.js +3 -1
- package/src/ffmpeg/bgm_builder.js +3 -1
- package/src/ffmpeg/effect_builder.js +138 -0
- package/src/ffmpeg/video_builder.js +20 -37
- package/src/lib/gradient.js +257 -0
- package/src/loaders.js +46 -0
- package/src/schema/formatter.js +2 -0
- package/src/schema/index.js +4 -0
- package/src/schema/modules/color.js +54 -0
- package/src/schema/modules/effect.js +77 -0
- package/src/simpleffmpeg.js +59 -92
- package/types/index.d.mts +76 -5
- package/types/index.d.ts +87 -5
|
@@ -1,20 +1,3 @@
|
|
|
1
|
-
const { detectVisualGaps } = require("../core/gaps");
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Create synthetic clips to fill visual gaps.
|
|
5
|
-
* The actual fill color is determined by the project's fillGaps option
|
|
6
|
-
* and applied when building the filter graph.
|
|
7
|
-
*/
|
|
8
|
-
function createGapFillClips(gaps) {
|
|
9
|
-
return gaps.map((gap, index) => ({
|
|
10
|
-
type: "_gapfill",
|
|
11
|
-
position: gap.start,
|
|
12
|
-
end: gap.end,
|
|
13
|
-
_gapIndex: index,
|
|
14
|
-
_isGapFill: true,
|
|
15
|
-
}));
|
|
16
|
-
}
|
|
17
|
-
|
|
18
1
|
const DEFAULT_KEN_BURNS_ZOOM = 0.15;
|
|
19
2
|
const DEFAULT_PAN_ZOOM = 1.12;
|
|
20
3
|
|
|
@@ -197,30 +180,31 @@ function computeOverscanWidth(width, startZoom, endZoom) {
|
|
|
197
180
|
return overscan;
|
|
198
181
|
}
|
|
199
182
|
|
|
200
|
-
function buildVideoFilter(project, videoClips
|
|
183
|
+
function buildVideoFilter(project, videoClips) {
|
|
201
184
|
let filterComplex = "";
|
|
202
185
|
let videoIndex = 0;
|
|
203
|
-
let blackGapIndex = 0;
|
|
204
186
|
const fps = project.options.fps;
|
|
205
187
|
const width = project.options.width;
|
|
206
188
|
const height = project.options.height;
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
//
|
|
210
|
-
let
|
|
211
|
-
if (
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
189
|
+
|
|
190
|
+
// Use the project-level input index map (built in _prepareExport) when available,
|
|
191
|
+
// otherwise build a local one for standalone usage (e.g. unit tests).
|
|
192
|
+
let inputIndexMap = project._inputIndexMap;
|
|
193
|
+
if (!inputIndexMap) {
|
|
194
|
+
inputIndexMap = new Map();
|
|
195
|
+
let inputIdx = 0;
|
|
196
|
+
for (const clip of project.videoOrAudioClips) {
|
|
197
|
+
if (clip.type === "color" && clip._isFlatColor) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
inputIndexMap.set(clip, inputIdx);
|
|
201
|
+
inputIdx++;
|
|
218
202
|
}
|
|
219
203
|
}
|
|
220
204
|
|
|
221
205
|
// Build scaled streams
|
|
222
206
|
const scaledStreams = [];
|
|
223
|
-
|
|
207
|
+
videoClips.forEach((clip) => {
|
|
224
208
|
const scaledLabel = `[scaled${videoIndex}]`;
|
|
225
209
|
|
|
226
210
|
const requestedDuration = Math.max(
|
|
@@ -228,10 +212,10 @@ function buildVideoFilter(project, videoClips, options = {}) {
|
|
|
228
212
|
(clip.end || 0) - (clip.position || 0),
|
|
229
213
|
);
|
|
230
214
|
|
|
231
|
-
// Handle
|
|
232
|
-
if (clip.
|
|
233
|
-
|
|
234
|
-
filterComplex += `color=c=${
|
|
215
|
+
// Handle flat color clips — generate using color= filter source
|
|
216
|
+
if (clip.type === "color" && clip._isFlatColor) {
|
|
217
|
+
const colorValue = clip.color;
|
|
218
|
+
filterComplex += `color=c=${colorValue}:s=${width}x${height}:d=${requestedDuration},fps=${fps},settb=1/${fps}${scaledLabel};`;
|
|
235
219
|
scaledStreams.push({
|
|
236
220
|
label: scaledLabel,
|
|
237
221
|
clip,
|
|
@@ -239,11 +223,10 @@ function buildVideoFilter(project, videoClips, options = {}) {
|
|
|
239
223
|
duration: requestedDuration,
|
|
240
224
|
});
|
|
241
225
|
videoIndex++;
|
|
242
|
-
blackGapIndex++;
|
|
243
226
|
return;
|
|
244
227
|
}
|
|
245
228
|
|
|
246
|
-
const inputIndex =
|
|
229
|
+
const inputIndex = inputIndexMap.get(clip);
|
|
247
230
|
const maxAvailable =
|
|
248
231
|
typeof clip.mediaDuration === "number" && typeof clip.cutFrom === "number"
|
|
249
232
|
? Math.max(0, clip.mediaDuration - clip.cutFrom)
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure Node.js gradient image generator.
|
|
3
|
+
*
|
|
4
|
+
* Produces PPM (P6) format images — the simplest binary image format.
|
|
5
|
+
* FFmpeg reads PPM natively on all versions, so no external dependencies
|
|
6
|
+
* are needed.
|
|
7
|
+
*
|
|
8
|
+
* Supports:
|
|
9
|
+
* - Linear gradients (vertical, horizontal, or arbitrary angle)
|
|
10
|
+
* - Radial gradients (center → edge)
|
|
11
|
+
* - Multi-color stop support (2+ colors, evenly distributed)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ── Named color → RGB lookup ────────────────────────────────────────────────
|
|
15
|
+
// Subset of X11/CSS colors that FFmpeg accepts. This list mirrors the
|
|
16
|
+
// FFMPEG_NAMED_COLORS set in validation.js but maps to RGB values.
|
|
17
|
+
const NAMED_COLORS = {
|
|
18
|
+
aliceblue: [240, 248, 255], antiquewhite: [250, 235, 215], aqua: [0, 255, 255],
|
|
19
|
+
aquamarine: [127, 255, 212], azure: [240, 255, 255], beige: [245, 245, 220],
|
|
20
|
+
bisque: [255, 228, 196], black: [0, 0, 0], blanchedalmond: [255, 235, 205],
|
|
21
|
+
blue: [0, 0, 255], blueviolet: [138, 43, 226], brown: [165, 42, 42],
|
|
22
|
+
burlywood: [222, 184, 135], cadetblue: [95, 158, 160], chartreuse: [127, 255, 0],
|
|
23
|
+
chocolate: [210, 105, 30], coral: [255, 127, 80], cornflowerblue: [100, 149, 237],
|
|
24
|
+
cornsilk: [255, 248, 220], crimson: [220, 20, 60], cyan: [0, 255, 255],
|
|
25
|
+
darkblue: [0, 0, 139], darkcyan: [0, 139, 139], darkgoldenrod: [184, 134, 11],
|
|
26
|
+
darkgray: [169, 169, 169], darkgreen: [0, 100, 0], darkgrey: [169, 169, 169],
|
|
27
|
+
darkkhaki: [189, 183, 107], darkmagenta: [139, 0, 139], darkolivegreen: [85, 107, 47],
|
|
28
|
+
darkorange: [255, 140, 0], darkorchid: [153, 50, 204], darkred: [139, 0, 0],
|
|
29
|
+
darksalmon: [233, 150, 122], darkseagreen: [143, 188, 143], darkslateblue: [72, 61, 139],
|
|
30
|
+
darkslategray: [47, 79, 79], darkslategrey: [47, 79, 79], darkturquoise: [0, 206, 209],
|
|
31
|
+
darkviolet: [148, 0, 211], deeppink: [255, 20, 147], deepskyblue: [0, 191, 255],
|
|
32
|
+
dimgray: [105, 105, 105], dimgrey: [105, 105, 105], dodgerblue: [30, 144, 255],
|
|
33
|
+
firebrick: [178, 34, 34], floralwhite: [255, 250, 240], forestgreen: [34, 139, 34],
|
|
34
|
+
fuchsia: [255, 0, 255], gainsboro: [220, 220, 220], ghostwhite: [248, 248, 255],
|
|
35
|
+
gold: [255, 215, 0], goldenrod: [218, 165, 32], gray: [128, 128, 128],
|
|
36
|
+
green: [0, 128, 0], greenyellow: [173, 255, 47], grey: [128, 128, 128],
|
|
37
|
+
honeydew: [240, 255, 240], hotpink: [255, 105, 180], indianred: [205, 92, 92],
|
|
38
|
+
indigo: [75, 0, 130], ivory: [255, 255, 240], khaki: [240, 230, 140],
|
|
39
|
+
lavender: [230, 230, 250], lavenderblush: [255, 240, 245], lawngreen: [124, 252, 0],
|
|
40
|
+
lemonchiffon: [255, 250, 205], lightblue: [173, 216, 230], lightcoral: [240, 128, 128],
|
|
41
|
+
lightcyan: [224, 255, 255], lightgoldenrodyellow: [250, 250, 210],
|
|
42
|
+
lightgray: [211, 211, 211], lightgreen: [144, 238, 144], lightgrey: [211, 211, 211],
|
|
43
|
+
lightpink: [255, 182, 193], lightsalmon: [255, 160, 122], lightseagreen: [32, 178, 170],
|
|
44
|
+
lightskyblue: [135, 206, 250], lightslategray: [119, 136, 153],
|
|
45
|
+
lightslategrey: [119, 136, 153], lightsteelblue: [176, 196, 222],
|
|
46
|
+
lightyellow: [255, 255, 224], lime: [0, 255, 0], limegreen: [50, 205, 50],
|
|
47
|
+
linen: [250, 240, 230], magenta: [255, 0, 255], maroon: [128, 0, 0],
|
|
48
|
+
mediumaquamarine: [102, 205, 170], mediumblue: [0, 0, 205],
|
|
49
|
+
mediumorchid: [186, 85, 211], mediumpurple: [147, 112, 219],
|
|
50
|
+
mediumseagreen: [60, 179, 113], mediumslateblue: [123, 104, 238],
|
|
51
|
+
mediumspringgreen: [0, 250, 154], mediumturquoise: [72, 209, 204],
|
|
52
|
+
mediumvioletred: [199, 21, 133], midnightblue: [25, 25, 112],
|
|
53
|
+
mintcream: [245, 255, 250], mistyrose: [255, 228, 225], moccasin: [255, 228, 181],
|
|
54
|
+
navajowhite: [255, 222, 173], navy: [0, 0, 128], oldlace: [253, 245, 230],
|
|
55
|
+
olive: [128, 128, 0], olivedrab: [107, 142, 35], orange: [255, 165, 0],
|
|
56
|
+
orangered: [255, 69, 0], orchid: [218, 112, 214], palegoldenrod: [238, 232, 170],
|
|
57
|
+
palegreen: [152, 251, 152], paleturquoise: [175, 238, 238],
|
|
58
|
+
palevioletred: [219, 112, 147], papayawhip: [255, 239, 213],
|
|
59
|
+
peachpuff: [255, 218, 185], peru: [205, 133, 63], pink: [255, 192, 203],
|
|
60
|
+
plum: [221, 160, 221], powderblue: [176, 224, 230], purple: [128, 0, 128],
|
|
61
|
+
red: [255, 0, 0], rosybrown: [188, 143, 143], royalblue: [65, 105, 225],
|
|
62
|
+
saddlebrown: [139, 69, 19], salmon: [250, 128, 114], sandybrown: [244, 164, 96],
|
|
63
|
+
seagreen: [46, 139, 87], seashell: [255, 245, 238], sienna: [160, 82, 45],
|
|
64
|
+
silver: [192, 192, 192], skyblue: [135, 206, 235], slateblue: [106, 90, 205],
|
|
65
|
+
slategray: [112, 128, 144], slategrey: [112, 128, 144], snow: [255, 250, 250],
|
|
66
|
+
springgreen: [0, 255, 127], steelblue: [70, 130, 180], tan: [210, 180, 140],
|
|
67
|
+
teal: [0, 128, 128], thistle: [216, 191, 216], tomato: [255, 99, 71],
|
|
68
|
+
turquoise: [64, 224, 208], violet: [238, 130, 238], wheat: [245, 222, 179],
|
|
69
|
+
white: [255, 255, 255], whitesmoke: [245, 245, 245], yellow: [255, 255, 0],
|
|
70
|
+
yellowgreen: [154, 205, 50],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse a color string to [r, g, b] (0-255 each).
|
|
75
|
+
*
|
|
76
|
+
* Accepted formats:
|
|
77
|
+
* - Named colors: "black", "navy", "red", …
|
|
78
|
+
* - Hex: "#RGB", "#RRGGBB"
|
|
79
|
+
* - 0x hex: "0xRRGGBB"
|
|
80
|
+
*
|
|
81
|
+
* @param {string} str - Color string
|
|
82
|
+
* @returns {number[]} [r, g, b]
|
|
83
|
+
*/
|
|
84
|
+
function parseColor(str) {
|
|
85
|
+
if (typeof str !== "string" || str.length === 0) {
|
|
86
|
+
return [0, 0, 0]; // fallback to black
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Strip @alpha suffix if present (e.g. "white@0.5")
|
|
90
|
+
const atIdx = str.indexOf("@");
|
|
91
|
+
const color = atIdx > 0 ? str.slice(0, atIdx) : str;
|
|
92
|
+
|
|
93
|
+
// Named color
|
|
94
|
+
const named = NAMED_COLORS[color.toLowerCase()];
|
|
95
|
+
if (named) return [...named];
|
|
96
|
+
|
|
97
|
+
// #RGB → #RRGGBB
|
|
98
|
+
if (/^#[0-9a-fA-F]{3}$/.test(color)) {
|
|
99
|
+
const r = parseInt(color[1] + color[1], 16);
|
|
100
|
+
const g = parseInt(color[2] + color[2], 16);
|
|
101
|
+
const b = parseInt(color[3] + color[3], 16);
|
|
102
|
+
return [r, g, b];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// #RRGGBB or #RRGGBBAA
|
|
106
|
+
if (/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(color)) {
|
|
107
|
+
const r = parseInt(color.slice(1, 3), 16);
|
|
108
|
+
const g = parseInt(color.slice(3, 5), 16);
|
|
109
|
+
const b = parseInt(color.slice(5, 7), 16);
|
|
110
|
+
return [r, g, b];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 0xRRGGBB or 0xRRGGBBAA
|
|
114
|
+
if (/^0x[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(color)) {
|
|
115
|
+
const r = parseInt(color.slice(2, 4), 16);
|
|
116
|
+
const g = parseInt(color.slice(4, 6), 16);
|
|
117
|
+
const b = parseInt(color.slice(6, 8), 16);
|
|
118
|
+
return [r, g, b];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return [0, 0, 0]; // fallback
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Interpolate between an array of colors at position t (0–1).
|
|
126
|
+
* Colors are evenly distributed across the 0–1 range.
|
|
127
|
+
*
|
|
128
|
+
* @param {number[][]} colors - Array of [r,g,b] colors
|
|
129
|
+
* @param {number} t - Position in gradient (0 = first color, 1 = last color)
|
|
130
|
+
* @returns {number[]} [r, g, b]
|
|
131
|
+
*/
|
|
132
|
+
function interpolateColors(colors, t) {
|
|
133
|
+
if (colors.length === 1) return colors[0];
|
|
134
|
+
|
|
135
|
+
const clamped = Math.max(0, Math.min(1, t));
|
|
136
|
+
const segments = colors.length - 1;
|
|
137
|
+
const segFloat = clamped * segments;
|
|
138
|
+
const segIdx = Math.min(Math.floor(segFloat), segments - 1);
|
|
139
|
+
const segT = segFloat - segIdx;
|
|
140
|
+
|
|
141
|
+
const c0 = colors[segIdx];
|
|
142
|
+
const c1 = colors[segIdx + 1];
|
|
143
|
+
return [
|
|
144
|
+
Math.round(c0[0] + (c1[0] - c0[0]) * segT),
|
|
145
|
+
Math.round(c0[1] + (c1[1] - c0[1]) * segT),
|
|
146
|
+
Math.round(c0[2] + (c1[2] - c0[2]) * segT),
|
|
147
|
+
];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Generate a linear gradient PPM image buffer.
|
|
152
|
+
*
|
|
153
|
+
* @param {number} width
|
|
154
|
+
* @param {number} height
|
|
155
|
+
* @param {number[][]} colors - Parsed [r,g,b] color stops
|
|
156
|
+
* @param {string|number} direction - "vertical", "horizontal", or angle in degrees
|
|
157
|
+
* @returns {Buffer}
|
|
158
|
+
*/
|
|
159
|
+
function generateLinearGradient(width, height, colors, direction) {
|
|
160
|
+
const pixels = Buffer.alloc(width * height * 3);
|
|
161
|
+
|
|
162
|
+
// Compute unit direction vector from direction spec
|
|
163
|
+
let dx = 0;
|
|
164
|
+
let dy = 1; // default: vertical (top to bottom)
|
|
165
|
+
if (direction === "horizontal") {
|
|
166
|
+
dx = 1;
|
|
167
|
+
dy = 0;
|
|
168
|
+
} else if (direction === "vertical") {
|
|
169
|
+
dx = 0;
|
|
170
|
+
dy = 1;
|
|
171
|
+
} else if (typeof direction === "number") {
|
|
172
|
+
const rad = (direction * Math.PI) / 180;
|
|
173
|
+
dx = Math.cos(rad);
|
|
174
|
+
dy = Math.sin(rad);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (let y = 0; y < height; y++) {
|
|
178
|
+
for (let x = 0; x < width; x++) {
|
|
179
|
+
// Project pixel onto gradient axis (normalized 0–1)
|
|
180
|
+
const nx = width > 1 ? x / (width - 1) : 0.5;
|
|
181
|
+
const ny = height > 1 ? y / (height - 1) : 0.5;
|
|
182
|
+
const t = nx * dx + ny * dy;
|
|
183
|
+
|
|
184
|
+
const [r, g, b] = interpolateColors(colors, t);
|
|
185
|
+
const idx = (y * width + x) * 3;
|
|
186
|
+
pixels[idx] = r;
|
|
187
|
+
pixels[idx + 1] = g;
|
|
188
|
+
pixels[idx + 2] = b;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return pixels;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Generate a radial gradient PPM image buffer.
|
|
197
|
+
*
|
|
198
|
+
* @param {number} width
|
|
199
|
+
* @param {number} height
|
|
200
|
+
* @param {number[][]} colors - Parsed [r,g,b] color stops (center → edge)
|
|
201
|
+
* @returns {Buffer}
|
|
202
|
+
*/
|
|
203
|
+
function generateRadialGradient(width, height, colors) {
|
|
204
|
+
const pixels = Buffer.alloc(width * height * 3);
|
|
205
|
+
const cx = (width - 1) / 2;
|
|
206
|
+
const cy = (height - 1) / 2;
|
|
207
|
+
// Max distance from center to any corner
|
|
208
|
+
const maxDist = Math.sqrt(cx * cx + cy * cy) || 1;
|
|
209
|
+
|
|
210
|
+
for (let y = 0; y < height; y++) {
|
|
211
|
+
for (let x = 0; x < width; x++) {
|
|
212
|
+
const dist = Math.sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy));
|
|
213
|
+
const t = dist / maxDist;
|
|
214
|
+
|
|
215
|
+
const [r, g, b] = interpolateColors(colors, t);
|
|
216
|
+
const idx = (y * width + x) * 3;
|
|
217
|
+
pixels[idx] = r;
|
|
218
|
+
pixels[idx + 1] = g;
|
|
219
|
+
pixels[idx + 2] = b;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return pixels;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Generate a gradient image as a PPM (P6) buffer.
|
|
228
|
+
*
|
|
229
|
+
* @param {number} width - Image width
|
|
230
|
+
* @param {number} height - Image height
|
|
231
|
+
* @param {Object} colorSpec - Gradient specification
|
|
232
|
+
* @param {string} colorSpec.type - "linear-gradient" or "radial-gradient"
|
|
233
|
+
* @param {string[]} colorSpec.colors - Array of color strings (2+ colors)
|
|
234
|
+
* @param {string|number} [colorSpec.direction] - For linear: "vertical", "horizontal", or angle in degrees (default: "vertical")
|
|
235
|
+
* @returns {Buffer} PPM image buffer ready to write to disk
|
|
236
|
+
*/
|
|
237
|
+
function generateGradientPPM(width, height, colorSpec) {
|
|
238
|
+
const parsedColors = colorSpec.colors.map(parseColor);
|
|
239
|
+
|
|
240
|
+
let pixels;
|
|
241
|
+
if (colorSpec.type === "radial-gradient") {
|
|
242
|
+
pixels = generateRadialGradient(width, height, parsedColors);
|
|
243
|
+
} else {
|
|
244
|
+
// linear-gradient (default)
|
|
245
|
+
const direction = colorSpec.direction || "vertical";
|
|
246
|
+
pixels = generateLinearGradient(width, height, parsedColors, direction);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const header = Buffer.from(`P6\n${width} ${height}\n255\n`);
|
|
250
|
+
return Buffer.concat([header, pixels]);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
module.exports = {
|
|
254
|
+
generateGradientPPM,
|
|
255
|
+
parseColor,
|
|
256
|
+
interpolateColors,
|
|
257
|
+
};
|
package/src/loaders.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
|
+
const os = require("os");
|
|
2
3
|
const path = require("path");
|
|
3
4
|
const { probeMedia } = require("./core/media_info");
|
|
4
5
|
const { ValidationError, MediaNotFoundError } = require("./core/errors");
|
|
5
6
|
const C = require("./core/constants");
|
|
7
|
+
const { generateGradientPPM } = require("./lib/gradient");
|
|
6
8
|
|
|
7
9
|
async function loadVideo(project, clipObj) {
|
|
8
10
|
const metadata = await probeMedia(clipObj.url);
|
|
@@ -173,6 +175,17 @@ function loadText(project, clipObj) {
|
|
|
173
175
|
}
|
|
174
176
|
}
|
|
175
177
|
|
|
178
|
+
function loadEffect(project, clipObj) {
|
|
179
|
+
const clip = {
|
|
180
|
+
...clipObj,
|
|
181
|
+
fadeIn: typeof clipObj.fadeIn === "number" ? clipObj.fadeIn : 0,
|
|
182
|
+
fadeOut: typeof clipObj.fadeOut === "number" ? clipObj.fadeOut : 0,
|
|
183
|
+
easing: clipObj.easing || "linear",
|
|
184
|
+
params: clipObj.params || {},
|
|
185
|
+
};
|
|
186
|
+
project.effectClips.push(clip);
|
|
187
|
+
}
|
|
188
|
+
|
|
176
189
|
function loadSubtitle(project, clipObj) {
|
|
177
190
|
// Validate file exists
|
|
178
191
|
if (!fs.existsSync(clipObj.url)) {
|
|
@@ -209,11 +222,44 @@ function loadSubtitle(project, clipObj) {
|
|
|
209
222
|
project.subtitleClips.push(clip);
|
|
210
223
|
}
|
|
211
224
|
|
|
225
|
+
async function loadColor(project, clipObj) {
|
|
226
|
+
if (typeof clipObj.color === "string") {
|
|
227
|
+
// Flat color — no file needed, uses FFmpeg color= filter source directly
|
|
228
|
+
project.videoOrAudioClips.push({
|
|
229
|
+
...clipObj,
|
|
230
|
+
hasAudio: false,
|
|
231
|
+
_isFlatColor: true,
|
|
232
|
+
});
|
|
233
|
+
} else {
|
|
234
|
+
// Gradient — generate a temp PPM image and treat as an image clip
|
|
235
|
+
const width = project.options.width || C.DEFAULT_WIDTH;
|
|
236
|
+
const height = project.options.height || C.DEFAULT_HEIGHT;
|
|
237
|
+
const ppmBuffer = generateGradientPPM(width, height, clipObj.color);
|
|
238
|
+
|
|
239
|
+
const tempPath = path.join(
|
|
240
|
+
os.tmpdir(),
|
|
241
|
+
`simpleffmpeg-gradient-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.ppm`
|
|
242
|
+
);
|
|
243
|
+
fs.writeFileSync(tempPath, ppmBuffer);
|
|
244
|
+
|
|
245
|
+
// Register for cleanup
|
|
246
|
+
project.filesToClean.push(tempPath);
|
|
247
|
+
|
|
248
|
+
project.videoOrAudioClips.push({
|
|
249
|
+
...clipObj,
|
|
250
|
+
url: tempPath,
|
|
251
|
+
hasAudio: false,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
212
256
|
module.exports = {
|
|
213
257
|
loadVideo,
|
|
214
258
|
loadAudio,
|
|
215
259
|
loadImage,
|
|
216
260
|
loadBackgroundAudio,
|
|
217
261
|
loadText,
|
|
262
|
+
loadEffect,
|
|
218
263
|
loadSubtitle,
|
|
264
|
+
loadColor,
|
|
219
265
|
};
|
package/src/schema/formatter.js
CHANGED
package/src/schema/index.js
CHANGED
|
@@ -12,6 +12,8 @@ const { formatSchema } = require("./formatter");
|
|
|
12
12
|
const videoModule = require("./modules/video");
|
|
13
13
|
const audioModule = require("./modules/audio");
|
|
14
14
|
const imageModule = require("./modules/image");
|
|
15
|
+
const colorModule = require("./modules/color");
|
|
16
|
+
const effectModule = require("./modules/effect");
|
|
15
17
|
const textModule = require("./modules/text");
|
|
16
18
|
const subtitleModule = require("./modules/subtitle");
|
|
17
19
|
const musicModule = require("./modules/music");
|
|
@@ -24,6 +26,8 @@ const ALL_MODULES = {
|
|
|
24
26
|
video: videoModule,
|
|
25
27
|
audio: audioModule,
|
|
26
28
|
image: imageModule,
|
|
29
|
+
color: colorModule,
|
|
30
|
+
effect: effectModule,
|
|
27
31
|
text: textModule,
|
|
28
32
|
subtitle: subtitleModule,
|
|
29
33
|
music: musicModule,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
id: "color",
|
|
3
|
+
name: "Color Clips",
|
|
4
|
+
description:
|
|
5
|
+
"Solid color or gradient clips for filling gaps, creating transitions to/from black, or adding visual backgrounds to the timeline.",
|
|
6
|
+
schema: `{
|
|
7
|
+
type: "color"; // Required: clip type identifier
|
|
8
|
+
color: string | GradientSpec; // Required: flat color string or gradient specification
|
|
9
|
+
position?: number; // Start time on timeline (seconds). Omit to auto-sequence after previous visual clip.
|
|
10
|
+
end?: number; // End time on timeline (seconds). Use end OR duration, not both.
|
|
11
|
+
duration?: number; // Duration in seconds (alternative to end). end = position + duration.
|
|
12
|
+
transition?: TransitionConfig; // Optional: transition effect from the previous visual clip
|
|
13
|
+
}`,
|
|
14
|
+
enums: {
|
|
15
|
+
GradientType: ["linear-gradient", "radial-gradient"],
|
|
16
|
+
GradientDirection: ["vertical", "horizontal"],
|
|
17
|
+
},
|
|
18
|
+
examples: [
|
|
19
|
+
{
|
|
20
|
+
label: "Black gap between clips",
|
|
21
|
+
code: `{ type: "color", color: "black", position: 5, end: 7 }`,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
label: "Fade to black between videos",
|
|
25
|
+
code: `[
|
|
26
|
+
{ type: "video", url: "intro.mp4", position: 0, end: 5 },
|
|
27
|
+
{ type: "color", color: "black", position: 5, end: 7, transition: { type: "fade", duration: 0.5 } },
|
|
28
|
+
{ type: "video", url: "main.mp4", position: 7, end: 15, transition: { type: "fade", duration: 0.5 } }
|
|
29
|
+
]`,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
label: "Linear gradient clip",
|
|
33
|
+
code: `{ type: "color", color: { type: "linear-gradient", colors: ["#000000", "#0a0a2e"], direction: "vertical" }, duration: 3 }`,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
label: "Radial gradient clip",
|
|
37
|
+
code: `{ type: "color", color: { type: "radial-gradient", colors: ["white", "navy"] }, duration: 2 }`,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
label: "Multi-stop gradient",
|
|
41
|
+
code: `{ type: "color", color: { type: "linear-gradient", colors: ["#ff0000", "#00ff00", "#0000ff"], direction: "horizontal" }, duration: 4 }`,
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
notes: [
|
|
45
|
+
"Flat color accepts any valid FFmpeg color: named colors (\"black\", \"navy\", \"red\"), hex (#RGB, #RRGGBB), or \"random\".",
|
|
46
|
+
"Gradient clips generate a temporary image internally and flow through the image pipeline — no external dependencies required.",
|
|
47
|
+
"Linear gradients support direction as \"vertical\" (default), \"horizontal\", or a number (angle in degrees).",
|
|
48
|
+
"Radial gradients interpolate from the center outward.",
|
|
49
|
+
"Gradient colors array must have at least 2 colors; multiple stops are evenly distributed.",
|
|
50
|
+
"Color clips support transitions just like video and image clips (e.g. fade, wipe, dissolve).",
|
|
51
|
+
"If position is omitted, the clip is placed immediately after the previous visual clip (auto-sequencing).",
|
|
52
|
+
"Use duration instead of end to specify length: end = position + duration. Cannot use both.",
|
|
53
|
+
],
|
|
54
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
id: "effect",
|
|
3
|
+
name: "Effect Clips",
|
|
4
|
+
description:
|
|
5
|
+
"Overlay adjustment clips that apply timed visual effects to the composed video (they do not create visual content by themselves).",
|
|
6
|
+
schema: `{
|
|
7
|
+
type: "effect"; // Required: clip type identifier
|
|
8
|
+
effect: "vignette" | "filmGrain" | "gaussianBlur" | "colorAdjust"; // Required: effect kind
|
|
9
|
+
position: number; // Required: start time on timeline (seconds)
|
|
10
|
+
end?: number; // End time on timeline (seconds). Use end OR duration, not both.
|
|
11
|
+
duration?: number; // Duration in seconds (alternative to end). end = position + duration.
|
|
12
|
+
fadeIn?: number; // Optional: seconds to ramp in from 0 to full intensity
|
|
13
|
+
fadeOut?: number; // Optional: seconds to ramp out from full intensity to 0
|
|
14
|
+
easing?: "linear" | "ease-in" | "ease-out" | "ease-in-out"; // Optional envelope easing (default: "linear")
|
|
15
|
+
params: EffectParams; // Required: effect-specific parameters
|
|
16
|
+
}`,
|
|
17
|
+
enums: {
|
|
18
|
+
EffectType: ["vignette", "filmGrain", "gaussianBlur", "colorAdjust"],
|
|
19
|
+
EffectEasing: ["linear", "ease-in", "ease-out", "ease-in-out"],
|
|
20
|
+
VignetteParams: `{ amount?: number; angle?: number; }`,
|
|
21
|
+
FilmGrainParams: `{ amount?: number; temporal?: boolean; }`,
|
|
22
|
+
GaussianBlurParams: `{ amount?: number; sigma?: number; }`,
|
|
23
|
+
ColorAdjustParams:
|
|
24
|
+
`{ amount?: number; brightness?: number; contrast?: number; saturation?: number; gamma?: number; }`,
|
|
25
|
+
},
|
|
26
|
+
examples: [
|
|
27
|
+
{
|
|
28
|
+
label: "Vignette that ramps in (duration shorthand)",
|
|
29
|
+
code: `{
|
|
30
|
+
type: "effect",
|
|
31
|
+
effect: "vignette",
|
|
32
|
+
position: 0,
|
|
33
|
+
duration: 4,
|
|
34
|
+
fadeIn: 1,
|
|
35
|
+
params: { amount: 0.8 }
|
|
36
|
+
}`,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
label: "Film grain only during the middle section",
|
|
40
|
+
code: `{
|
|
41
|
+
type: "effect",
|
|
42
|
+
effect: "filmGrain",
|
|
43
|
+
position: 3,
|
|
44
|
+
end: 8,
|
|
45
|
+
fadeIn: 0.4,
|
|
46
|
+
fadeOut: 0.6,
|
|
47
|
+
easing: "ease-in-out",
|
|
48
|
+
params: { amount: 0.45, temporal: true }
|
|
49
|
+
}`,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
label: "Color adjustment look with smooth exit",
|
|
53
|
+
code: `{
|
|
54
|
+
type: "effect",
|
|
55
|
+
effect: "colorAdjust",
|
|
56
|
+
position: 0,
|
|
57
|
+
end: 10,
|
|
58
|
+
fadeOut: 1,
|
|
59
|
+
params: {
|
|
60
|
+
amount: 0.7,
|
|
61
|
+
contrast: 1.12,
|
|
62
|
+
saturation: 1.18,
|
|
63
|
+
gamma: 1.04,
|
|
64
|
+
brightness: -0.02
|
|
65
|
+
}
|
|
66
|
+
}`,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
notes: [
|
|
70
|
+
"Effect clips are adjustment layers: they modify underlying video during their active window.",
|
|
71
|
+
"Effects do not satisfy visual timeline continuity checks and do not fill gaps.",
|
|
72
|
+
"Use duration instead of end to specify length: end = position + duration. Cannot use both.",
|
|
73
|
+
"position is required for effect clips (no auto-sequencing).",
|
|
74
|
+
"fadeIn/fadeOut are optional envelope controls that avoid abrupt on/off changes.",
|
|
75
|
+
"params.amount is a normalized blend amount from 0 to 1 (default: 1).",
|
|
76
|
+
],
|
|
77
|
+
};
|