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.
@@ -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, options = {}) {
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
- const fillGaps = project.options.fillGaps || "none";
208
-
209
- // Detect and fill gaps if fillGaps is enabled (any value other than "none")
210
- let allVisualClips = [...videoClips];
211
- if (fillGaps !== "none") {
212
- const gaps = detectVisualGaps(videoClips, { timelineEnd: options.timelineEnd });
213
- if (gaps.length > 0) {
214
- const gapClips = createGapFillClips(gaps);
215
- allVisualClips = [...videoClips, ...gapClips].sort(
216
- (a, b) => (a.position || 0) - (b.position || 0),
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
- allVisualClips.forEach((clip) => {
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 synthetic gap fill clips
232
- if (clip._isGapFill) {
233
- // Generate a color source for the gap duration
234
- filterComplex += `color=c=${fillGaps}:s=${width}x${height}:d=${requestedDuration},fps=${fps},settb=1/${fps}${scaledLabel};`;
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 = project.videoOrAudioClips.indexOf(clip);
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
  };
@@ -143,6 +143,8 @@ function formatSchema(modules, options = {}) {
143
143
  video: '"video"',
144
144
  audio: '"audio"',
145
145
  image: '"image"',
146
+ color: '"color"',
147
+ effect: '"effect"',
146
148
  text: '"text"',
147
149
  subtitle: '"subtitle"',
148
150
  music: '"music" or "backgroundAudio"',
@@ -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
+ };