simple-ffmpegjs 0.3.5 → 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.
@@ -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);
@@ -88,8 +90,15 @@ async function loadAudio(project, clipObj) {
88
90
  project.videoOrAudioClips.push({ ...clipObj, mediaDuration: durationSec });
89
91
  }
90
92
 
91
- function loadImage(project, clipObj) {
92
- const clip = { ...clipObj, hasAudio: false, cutFrom: 0 };
93
+ async function loadImage(project, clipObj) {
94
+ const metadata = await probeMedia(clipObj.url);
95
+ const clip = {
96
+ ...clipObj,
97
+ hasAudio: false,
98
+ cutFrom: 0,
99
+ width: clipObj.width ?? metadata.width,
100
+ height: clipObj.height ?? metadata.height,
101
+ };
93
102
  project.videoOrAudioClips.push(clip);
94
103
  }
95
104
 
@@ -166,6 +175,17 @@ function loadText(project, clipObj) {
166
175
  }
167
176
  }
168
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
+
169
189
  function loadSubtitle(project, clipObj) {
170
190
  // Validate file exists
171
191
  if (!fs.existsSync(clipObj.url)) {
@@ -202,11 +222,44 @@ function loadSubtitle(project, clipObj) {
202
222
  project.subtitleClips.push(clip);
203
223
  }
204
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
+
205
256
  module.exports = {
206
257
  loadVideo,
207
258
  loadAudio,
208
259
  loadImage,
209
260
  loadBackgroundAudio,
210
261
  loadText,
262
+ loadEffect,
211
263
  loadSubtitle,
264
+ loadColor,
212
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
+ };
@@ -4,12 +4,14 @@ module.exports = {
4
4
  description:
5
5
  "Display still images on the timeline, optionally with Ken Burns (pan/zoom) motion effects.",
6
6
  schema: `{
7
- type: "image"; // Required: clip type identifier
8
- url: string; // Required: path to image file (jpg, png, etc.)
9
- position?: number; // Start time on timeline (seconds). Omit to auto-sequence after previous video/image 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
- kenBurns?: KenBurnsEffect; // Optional: apply pan/zoom motion to the image
7
+ type: "image"; // Required: clip type identifier
8
+ url: string; // Required: path to image file (jpg, png, etc.)
9
+ position?: number; // Start time on timeline (seconds). Omit to auto-sequence after previous video/image 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
+ width?: number; // Optional: source image width (skip probe / override)
13
+ height?: number; // Optional: source image height (skip probe / override)
14
+ kenBurns?: KenBurnsEffect | KenBurnsSpec; // Optional: apply pan/zoom motion to the image
13
15
  }`,
14
16
  enums: {
15
17
  KenBurnsEffect: [
@@ -19,7 +21,11 @@ module.exports = {
19
21
  "pan-right",
20
22
  "pan-up",
21
23
  "pan-down",
24
+ "smart",
25
+ "custom",
22
26
  ],
27
+ KenBurnsAnchor: ["top", "bottom", "left", "right"],
28
+ KenBurnsEasing: ["linear", "ease-in", "ease-out", "ease-in-out"],
23
29
  },
24
30
  examples: [
25
31
  {
@@ -34,11 +40,23 @@ module.exports = {
34
40
  { type: "image", url: "photo3.jpg", duration: 3, kenBurns: "zoom-out" }
35
41
  ]`,
36
42
  },
43
+ {
44
+ label: "Custom Ken Burns pan with smart anchor",
45
+ code: `{ type: "image", url: "portrait.jpg", duration: 5, kenBurns: { type: "smart", anchor: "bottom", startZoom: 1.05, endZoom: 1.2 } }`,
46
+ },
47
+ {
48
+ label: "Custom Ken Burns with explicit pan endpoints",
49
+ code: `{ type: "image", url: "photo.jpg", duration: 4, kenBurns: { type: "custom", startX: 0.2, startY: 0.8, endX: 0.7, endY: 0.3 } }`,
50
+ },
37
51
  ],
38
52
  notes: [
39
53
  "If position is omitted, the clip is placed immediately after the previous video/image clip (auto-sequencing). The first clip defaults to position 0.",
40
54
  "Use duration instead of end to specify how long the image displays: end = position + duration. Cannot use both.",
41
55
  "Images are scaled to fill the project canvas. For Ken Burns, use images at least as large as the output resolution for best quality.",
56
+ "If width/height are provided, they override probed dimensions (useful for remote or generated images).",
42
57
  "Image clips can be placed on the same timeline as video clips and can use transitions between them.",
58
+ "Advanced Ken Burns accepts custom zoom/pan endpoints via normalized coordinates (0 = left/top, 1 = right/bottom).",
59
+ "smart mode auto-pans along the dominant axis; use anchor to pick a starting edge.",
60
+ "Use easing ('linear', 'ease-in', 'ease-out', 'ease-in-out') to smooth motion (default: ease-in-out).",
43
61
  ],
44
62
  };