simple-ffmpegjs 0.3.2 → 0.3.4

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.
@@ -79,6 +79,49 @@ function validateClip(clip, index, options = {}) {
79
79
  return { errors, warnings }; // Can't validate further with invalid type
80
80
  }
81
81
 
82
+ // Validate duration field if present (applies to all clip types)
83
+ if (clip.duration != null) {
84
+ if (typeof clip.duration !== "number") {
85
+ errors.push(
86
+ createIssue(
87
+ ValidationCodes.INVALID_VALUE,
88
+ `${path}.duration`,
89
+ "Duration must be a number",
90
+ clip.duration
91
+ )
92
+ );
93
+ } else if (!Number.isFinite(clip.duration)) {
94
+ errors.push(
95
+ createIssue(
96
+ ValidationCodes.INVALID_VALUE,
97
+ `${path}.duration`,
98
+ "Duration must be a finite number (not NaN or Infinity)",
99
+ clip.duration
100
+ )
101
+ );
102
+ } else if (clip.duration <= 0) {
103
+ errors.push(
104
+ createIssue(
105
+ ValidationCodes.INVALID_RANGE,
106
+ `${path}.duration`,
107
+ "Duration must be greater than 0",
108
+ clip.duration
109
+ )
110
+ );
111
+ }
112
+ // Conflict check: duration + end both set
113
+ if (clip.end != null) {
114
+ errors.push(
115
+ createIssue(
116
+ ValidationCodes.INVALID_VALUE,
117
+ `${path}`,
118
+ "Cannot specify both 'duration' and 'end'. Use one or the other.",
119
+ { duration: clip.duration, end: clip.end }
120
+ )
121
+ );
122
+ }
123
+ }
124
+
82
125
  // Types that require position/end on timeline
83
126
  const requiresTimeline = ["video", "audio", "text", "image"].includes(
84
127
  clip.type
@@ -209,6 +209,45 @@ function buildThumbnailCommand({ inputPath, outputPath, time, width, height }) {
209
209
  return cmd;
210
210
  }
211
211
 
212
+ /**
213
+ * Build command to capture a single frame from a video file.
214
+ * Output format is determined by the outputPath extension (jpg, png, webp, etc.).
215
+ *
216
+ * @param {Object} options
217
+ * @param {string} options.inputPath - Path to source video
218
+ * @param {string} options.outputPath - Output image path (extension determines format)
219
+ * @param {number} [options.time=0] - Time in seconds to capture
220
+ * @param {number} [options.width] - Output width (maintains aspect if height omitted)
221
+ * @param {number} [options.height] - Output height (maintains aspect if width omitted)
222
+ * @param {number} [options.quality] - JPEG quality 1-31, lower is better (only applies to JPEG)
223
+ * @returns {string} FFmpeg command string
224
+ */
225
+ function buildSnapshotCommand({
226
+ inputPath,
227
+ outputPath,
228
+ time = 0,
229
+ width,
230
+ height,
231
+ quality,
232
+ }) {
233
+ let cmd = `ffmpeg -y -ss ${time} -i "${escapeFilePath(
234
+ inputPath
235
+ )}" -vframes 1 `;
236
+
237
+ if (width || height) {
238
+ const w = width || -1;
239
+ const h = height || -1;
240
+ cmd += `-vf "scale=${w}:${h}" `;
241
+ }
242
+
243
+ if (quality != null) {
244
+ cmd += `-q:v ${quality} `;
245
+ }
246
+
247
+ cmd += `"${escapeFilePath(outputPath)}"`;
248
+ return cmd;
249
+ }
250
+
212
251
  /**
213
252
  * Escape metadata value for FFmpeg
214
253
  */
@@ -223,5 +262,6 @@ module.exports = {
223
262
  buildMainCommand,
224
263
  buildTextBatchCommand,
225
264
  buildThumbnailCommand,
265
+ buildSnapshotCommand,
226
266
  escapeMetadata,
227
267
  };
@@ -1,7 +1,3 @@
1
- function escapeSingleQuotes(text) {
2
- return String(text).replace(/'/g, "\\'");
3
- }
4
-
5
1
  /**
6
2
  * Escape a file path for use in FFmpeg command line arguments.
7
3
  * Prevents command injection by escaping quotes and backslashes.
@@ -21,32 +17,54 @@ function escapeFilePath(filePath) {
21
17
  */
22
18
  function hasProblematicChars(text) {
23
19
  if (typeof text !== "string") return false;
24
- // These characters cannot be reliably escaped in filter_complex parsing
25
- // when passed through shell double-quoting
26
- return /[,;{}\[\]"]/.test(text);
20
+ // These characters cannot be reliably escaped in filter_complex parsing.
21
+ // Single quotes (') are included because FFmpeg's av_get_token does NOT
22
+ // support \' inside single-quoted strings — ' always ends the quoted value.
23
+ // Non-ASCII characters are also routed to textfile to avoid parser issues
24
+ // with UTF-8 inside filter_complex.
25
+ return /[,;{}\[\]"']/.test(text) || /[^\x20-\x7E]/.test(text);
27
26
  }
28
27
 
29
28
  function escapeDrawtextText(text) {
30
29
  if (typeof text !== "string") return "";
31
- // Escape characters that have special meaning in FFmpeg drawtext filter
32
- // AND characters that break shell parsing (since filter_complex is double-quoted)
30
+ // Escape characters that have special meaning in FFmpeg drawtext filter.
31
+ //
32
+ // FFmpeg parses the filter_complex value through TWO levels of av_get_token:
33
+ // Level 1 — filter-graph parser (terminators: [ , ; \n)
34
+ // Level 2 — filter option parser (terminators: : = )
35
+ // Both levels handle '...' quoting and \x escaping identically.
36
+ //
37
+ // To embed a literal single quote that survives both levels:
38
+ // 1. End the current level-1 quoted segment: '
39
+ // 2. \\ — level 1 escape → produces \ in output
40
+ // 3. \' — level 1 escape → produces ' in output
41
+ // So level 2 sees \' → escaped quote → literal '
42
+ // 4. Re-open a new level-1 quoted segment: '
43
+ // The 6-char replacement per apostrophe is: '\\\''
44
+ //
45
+ // Backslash (\\) and colon (\:) escaping inside the quoted segments is
46
+ // passed through literally by level 1 (no escape processing inside '...'),
47
+ // then processed by level 2 as escape sequences: \\ → \ and \: → :
33
48
  return text
34
- .replace(/\\/g, "\\\\") // Escape backslashes first
35
- .replace(/'/g, "\\'") // Escape single quotes (text delimiter)
36
- .replace(/"/g, '\\"') // Escape double quotes (shell safety)
37
- .replace(/:/g, "\\:") // Escape colons (option separator)
49
+ .replace(/\\/g, "\\\\") // Escape backslashes (level 2 decodes \\ → \)
50
+ .replace(/'/g, "'\\\\\\''" ) // End quote, \\' (two-level escape), re-open quote
51
+ .replace(/:/g, "\\:") // Escape colons (level 2 decodes \: → :)
38
52
  .replace(/\n/g, " ") // Replace newlines with space (multiline not supported)
39
53
  .replace(/\r/g, ""); // Remove carriage returns
40
54
  }
41
55
 
42
56
  /**
43
- * Escape a file path for use in FFmpeg filters (Windows paths need special handling)
57
+ * Escape a file path for use inside single-quoted FFmpeg filter parameters
58
+ * (e.g., textfile='...', ass='...').
59
+ *
60
+ * These paths are parsed through two levels of av_get_token, so single
61
+ * quotes need the same '\\\'' two-level escape as drawtext text values.
44
62
  */
45
63
  function escapeTextFilePath(filePath) {
46
64
  if (typeof filePath !== "string") return "";
47
- // FFmpeg on Windows needs forward slashes and escaped colons
48
65
  return filePath
49
66
  .replace(/\\/g, "/") // Convert backslashes to forward slashes
67
+ .replace(/'/g, "'\\\\\\''" ) // Two-level apostrophe escape (same as escapeDrawtextText)
50
68
  .replace(/:/g, "\\:"); // Escape colons (for Windows drive letters)
51
69
  }
52
70
 
@@ -62,7 +80,6 @@ function getClipAudioString(clip, inputIndex) {
62
80
  }
63
81
 
64
82
  module.exports = {
65
- escapeSingleQuotes,
66
83
  escapeFilePath,
67
84
  escapeDrawtextText,
68
85
  getClipAudioString,
@@ -637,11 +637,11 @@ function loadSubtitleFile(filePath, options, canvasWidth, canvasHeight) {
637
637
  * @returns {{ filter: string, finalLabel: string }}
638
638
  */
639
639
  function buildASSFilter(assFilePath, inputLabel) {
640
- // Escape path for FFmpeg filter
640
+ // Escape path for FFmpeg filter (must survive two levels of av_get_token)
641
641
  const escapedPath = assFilePath
642
642
  .replace(/\\/g, "/")
643
643
  .replace(/:/g, "\\:")
644
- .replace(/'/g, "'\\''");
644
+ .replace(/'/g, "'\\\\\\''" );
645
645
 
646
646
  const outputLabel = "[outass]";
647
647
  const filter = `${inputLabel}ass='${escapedPath}'${outputLabel}`;
@@ -8,10 +8,11 @@ const { parseFFmpegCommand } = require("../lib/utils");
8
8
  /**
9
9
  * Run an FFmpeg command using spawn() to avoid command injection.
10
10
  * @param {string} cmd - The full FFmpeg command string
11
+ * @param {Function} [onLog] - Optional log callback receiving { level, message }
11
12
  * @returns {Promise<void>}
12
13
  * @throws {FFmpegError} If ffmpeg fails
13
14
  */
14
- function runCmd(cmd) {
15
+ function runCmd(cmd, onLog) {
15
16
  return new Promise((resolve, reject) => {
16
17
  const args = parseFFmpegCommand(cmd);
17
18
  const ffmpegPath = args.shift(); // Remove 'ffmpeg' from args
@@ -22,8 +23,19 @@ function runCmd(cmd) {
22
23
 
23
24
  let stderr = "";
24
25
 
26
+ proc.stdout.on("data", (data) => {
27
+ const chunk = data.toString();
28
+ if (onLog && typeof onLog === "function") {
29
+ onLog({ level: "stdout", message: chunk });
30
+ }
31
+ });
32
+
25
33
  proc.stderr.on("data", (data) => {
26
- stderr += data.toString();
34
+ const chunk = data.toString();
35
+ stderr += chunk;
36
+ if (onLog && typeof onLog === "function") {
37
+ onLog({ level: "stderr", message: chunk });
38
+ }
27
39
  });
28
40
 
29
41
  proc.on("error", (error) => {
@@ -61,6 +73,7 @@ async function runTextPasses({
61
73
  intermediatePreset,
62
74
  intermediateCrf,
63
75
  batchSize = 75,
76
+ onLog,
64
77
  }) {
65
78
  const tempOutputs = [];
66
79
  let currentInput = baseOutputPath;
@@ -89,7 +102,7 @@ async function runTextPasses({
89
102
  intermediateCrf,
90
103
  outputPath: batchOutput,
91
104
  });
92
- await runCmd(cmd);
105
+ await runCmd(cmd, onLog);
93
106
  currentInput = batchOutput;
94
107
  passes += 1;
95
108
  }
@@ -147,7 +147,7 @@ function buildDrawtextParams(
147
147
  end
148
148
  ) {
149
149
  const fontSpec = baseClip.fontFile
150
- ? `fontfile=${baseClip.fontFile}`
150
+ ? `fontfile='${Strings.escapeTextFilePath(baseClip.fontFile)}'`
151
151
  : baseClip.fontFamily
152
152
  ? `font=${baseClip.fontFamily}`
153
153
  : `font=Sans`;
@@ -29,7 +29,7 @@ function buildVideoFilter(project, videoClips) {
29
29
  if (gaps.length > 0) {
30
30
  const blackClips = createBlackClipsForGaps(gaps, fps, width, height);
31
31
  allVisualClips = [...videoClips, ...blackClips].sort(
32
- (a, b) => (a.position || 0) - (b.position || 0)
32
+ (a, b) => (a.position || 0) - (b.position || 0),
33
33
  );
34
34
  }
35
35
  }
@@ -41,7 +41,7 @@ function buildVideoFilter(project, videoClips) {
41
41
 
42
42
  const requestedDuration = Math.max(
43
43
  0,
44
- (clip.end || 0) - (clip.position || 0)
44
+ (clip.end || 0) - (clip.position || 0),
45
45
  );
46
46
 
47
47
  // Handle synthetic black fill clips
@@ -85,12 +85,12 @@ function buildVideoFilter(project, videoClips) {
85
85
  if (type === "zoom-in") {
86
86
  const inc = (zoomAmount / framesMinusOne).toFixed(6);
87
87
  // Ensure first frame starts exactly at base zoom=1 (on==0)
88
- zoomExpr = `if(eq(on\\,0)\\,1\\,zoom+${inc})`;
88
+ zoomExpr = `if(eq(on,0),1,zoom+${inc})`;
89
89
  } else if (type === "zoom-out") {
90
90
  const start = (1 + zoomAmount).toFixed(4);
91
91
  const dec = (zoomAmount / framesMinusOne).toFixed(6);
92
92
  // Start pre-zoomed on first frame to avoid jump
93
- zoomExpr = `if(eq(on\\,0)\\,${start}\\,zoom-${dec})`;
93
+ zoomExpr = `if(eq(on,0),${start},zoom-${dec})`;
94
94
  } else {
95
95
  const panZoom = 1.12;
96
96
  zoomExpr = `${panZoom}`;
@@ -113,7 +113,9 @@ function buildVideoFilter(project, videoClips) {
113
113
 
114
114
  // Scale to cover target dimensions (upscaling if needed), then center crop
115
115
  // force_original_aspect_ratio=increase ensures image covers the target area
116
- filterComplex += `[${inputIndex}:v]select=eq(n\\,0),setpts=PTS-STARTPTS,scale=${width}:${height}:force_original_aspect_ratio=increase,setsar=1:1,crop=${width}:${height}:(iw-${width})/2:(ih-${height})/2,scale=${overscanW}:-1,zoompan=z='${zoomExpr}':x='${xExpr}':y='${yExpr}':d=${frames}:s=${s},fps=${fps},settb=1/${fps}${scaledLabel};`;
116
+ // select='eq(n,0)' uses single quotes to protect the comma from the graph parser.
117
+ // zoompan z/x/y values are also single-quoted — commas inside are literal.
118
+ filterComplex += `[${inputIndex}:v]select='eq(n,0)',setpts=PTS-STARTPTS,scale=${width}:${height}:force_original_aspect_ratio=increase,setsar=1:1,crop=${width}:${height}:(iw-${width})/2:(ih-${height})/2,scale=${overscanW}:-1,zoompan=z='${zoomExpr}':x='${xExpr}':y='${yExpr}':d=${frames}:s=${s},fps=${fps},settb=1/${fps}${scaledLabel};`;
117
119
  } else {
118
120
  filterComplex += `[${inputIndex}:v]trim=start=${
119
121
  clip.cutFrom || 0
@@ -134,7 +136,7 @@ function buildVideoFilter(project, videoClips) {
134
136
  }
135
137
 
136
138
  const hasTransitions = scaledStreams.some(
137
- (s, i) => i > 0 && s.clip.transition
139
+ (s, i) => i > 0 && s.clip.transition,
138
140
  );
139
141
 
140
142
  if (!hasTransitions) {
@@ -1,16 +1,5 @@
1
1
  const Strings = require("./strings");
2
2
 
3
- /**
4
- * Position presets for watermarks
5
- */
6
- const POSITION_PRESETS = {
7
- "top-left": (margin) => ({ x: margin, y: margin }),
8
- "top-right": (margin) => ({ x: `W-w-${margin}`, y: margin }),
9
- "bottom-left": (margin) => ({ x: margin, y: `H-h-${margin}` }),
10
- "bottom-right": (margin) => ({ x: `W-w-${margin}`, y: `H-h-${margin}` }),
11
- center: () => ({ x: "(W-w)/2", y: "(H-h)/2" }),
12
- };
13
-
14
3
  /**
15
4
  * Calculate position expressions for overlay/drawtext
16
5
  * @param {Object} config - Watermark config with position, margin
@@ -91,7 +80,7 @@ function buildImageWatermark(
91
80
  watermarkInputIndex,
92
81
  canvasWidth,
93
82
  canvasHeight,
94
- totalDuration
83
+ totalDuration,
95
84
  ) {
96
85
  const scale = typeof config.scale === "number" ? config.scale : 0.15;
97
86
  const opacity = typeof config.opacity === "number" ? config.opacity : 1;
@@ -144,7 +133,7 @@ function buildTextWatermark(
144
133
  videoLabel,
145
134
  canvasWidth,
146
135
  canvasHeight,
147
- totalDuration
136
+ totalDuration,
148
137
  ) {
149
138
  const text = config.text || "";
150
139
  const fontSize = typeof config.fontSize === "number" ? config.fontSize : 24;
@@ -158,9 +147,8 @@ function buildTextWatermark(
158
147
  // Escape text for drawtext
159
148
  const escapedText = Strings.escapeDrawtextText(text);
160
149
 
161
- // Font specification
162
150
  const fontSpec = config.fontFile
163
- ? `fontfile=${config.fontFile}`
151
+ ? `fontfile='${Strings.escapeTextFilePath(config.fontFile)}'`
164
152
  : `font=${fontFamily}`;
165
153
 
166
154
  // Calculate position (for text)
@@ -257,7 +245,7 @@ function buildWatermarkFilter(
257
245
  watermarkInputIndex,
258
246
  canvasWidth,
259
247
  canvasHeight,
260
- totalDuration
248
+ totalDuration,
261
249
  ) {
262
250
  if (!config || typeof config !== "object") {
263
251
  return { filter: "", finalLabel: videoLabel, needsInput: false };
@@ -271,7 +259,7 @@ function buildWatermarkFilter(
271
259
  videoLabel,
272
260
  canvasWidth,
273
261
  canvasHeight,
274
- totalDuration
262
+ totalDuration,
275
263
  );
276
264
  return { ...result, needsInput: false };
277
265
  }
@@ -288,7 +276,7 @@ function buildWatermarkFilter(
288
276
  watermarkInputIndex,
289
277
  canvasWidth,
290
278
  canvasHeight,
291
- totalDuration
279
+ totalDuration,
292
280
  );
293
281
  return { ...result, needsInput: true };
294
282
  }
@@ -369,8 +357,8 @@ function validateWatermarkConfig(config) {
369
357
  if (!validPositions.includes(config.position)) {
370
358
  errors.push(
371
359
  `watermark.position must be one of: ${validPositions.join(
372
- ", "
373
- )}; got '${config.position}'`
360
+ ", ",
361
+ )}; got '${config.position}'`,
374
362
  );
375
363
  }
376
364
  } else if (typeof config.position === "object" && config.position !== null) {
package/src/lib/utils.js CHANGED
@@ -76,9 +76,10 @@ function parseFFmpegProgress(line, totalDuration) {
76
76
  * @param {number} options.totalDuration - Expected output duration in seconds (for progress %)
77
77
  * @param {Function} options.onProgress - Progress callback
78
78
  * @param {AbortSignal} options.signal - AbortSignal for cancellation
79
+ * @param {Function} options.onLog - Log callback receiving { level: "stderr"|"stdout", message: string }
79
80
  * @returns {Promise<{stdout: string, stderr: string}>}
80
81
  */
81
- function runFFmpeg({ command, totalDuration = 0, onProgress, signal }) {
82
+ function runFFmpeg({ command, totalDuration = 0, onProgress, signal, onLog }) {
82
83
  return new Promise((resolve, reject) => {
83
84
  // Parse command into args (simple split, assumes no quoted args with spaces in values)
84
85
  // FFmpeg commands from this library don't have spaces in quoted paths handled this way
@@ -115,17 +116,26 @@ function runFFmpeg({ command, totalDuration = 0, onProgress, signal }) {
115
116
  }
116
117
 
117
118
  proc.stdout.on("data", (data) => {
118
- stdout += data.toString();
119
+ const chunk = data.toString();
120
+ stdout += chunk;
121
+ if (onLog && typeof onLog === "function") {
122
+ onLog({ level: "stdout", message: chunk });
123
+ }
119
124
  });
120
125
 
121
126
  proc.stderr.on("data", (data) => {
122
127
  const chunk = data.toString();
123
128
  stderr += chunk;
124
129
 
130
+ if (onLog && typeof onLog === "function") {
131
+ onLog({ level: "stderr", message: chunk });
132
+ }
133
+
125
134
  // Parse progress from stderr (FFmpeg outputs progress to stderr)
126
135
  if (onProgress && typeof onProgress === "function") {
127
136
  const progress = parseFFmpegProgress(chunk, totalDuration);
128
137
  if (Object.keys(progress).length > 0) {
138
+ progress.phase = "rendering";
129
139
  onProgress(progress);
130
140
  }
131
141
  }
@@ -163,8 +173,22 @@ function runFFmpeg({ command, totalDuration = 0, onProgress, signal }) {
163
173
  }
164
174
 
165
175
  /**
166
- * Parse a command string into an array of arguments
167
- * Handles quoted strings and escaped characters
176
+ * Parse a command string into an array of arguments.
177
+ *
178
+ * Inside quoted strings (single or double):
179
+ * - All characters are literal (no escape processing).
180
+ * - The matching closing quote ends the argument segment.
181
+ *
182
+ * This deliberately avoids backslash-escape handling because the
183
+ * filter_complex value relies on \\, \, and \: being passed through
184
+ * verbatim to FFmpeg. For example drawtext's fontsize expressions
185
+ * use \\, (which drawtext decodes as \, → escaped comma) and text
186
+ * values use \\\\ (which drawtext decodes as \\ → literal backslash).
187
+ * Any unescaping here would corrupt those sequences.
188
+ *
189
+ * Outside quotes:
190
+ * - Whitespace separates arguments.
191
+ * - All other characters (including backslash) are literal.
168
192
  */
169
193
  function parseFFmpegCommand(command) {
170
194
  const args = [];
@@ -178,14 +202,12 @@ function parseFFmpegCommand(command) {
178
202
  if (inQuote) {
179
203
  if (char === quoteChar) {
180
204
  inQuote = false;
181
- // Don't add the closing quote to the argument
182
205
  } else {
183
206
  current += char;
184
207
  }
185
208
  } else if (char === '"' || char === "'") {
186
209
  inQuote = true;
187
210
  quoteChar = char;
188
- // Don't add the opening quote to the argument
189
211
  } else if (char === " " || char === "\t") {
190
212
  if (current.length > 0) {
191
213
  args.push(current);
package/src/loaders.js CHANGED
@@ -1,15 +1,15 @@
1
1
  const fs = require("fs");
2
2
  const path = require("path");
3
- const { getVideoMetadata, getMediaDuration } = require("./core/media_info");
3
+ const { probeMedia } = require("./core/media_info");
4
4
  const { ValidationError, MediaNotFoundError } = require("./core/errors");
5
5
  const C = require("./core/constants");
6
6
 
7
7
  async function loadVideo(project, clipObj) {
8
- const metadata = await getVideoMetadata(clipObj.url);
9
- if (typeof clipObj.cutFrom === "number" && metadata.durationSec != null) {
10
- if (clipObj.cutFrom >= metadata.durationSec) {
8
+ const metadata = await probeMedia(clipObj.url);
9
+ if (typeof clipObj.cutFrom === "number" && metadata.duration != null) {
10
+ if (clipObj.cutFrom >= metadata.duration) {
11
11
  throw new ValidationError(
12
- `Video clip cutFrom (${clipObj.cutFrom}s) must be < source duration (${metadata.durationSec}s)`,
12
+ `Video clip cutFrom (${clipObj.cutFrom}s) must be < source duration (${metadata.duration}s)`,
13
13
  {
14
14
  errors: [
15
15
  {
@@ -26,10 +26,10 @@ async function loadVideo(project, clipObj) {
26
26
  typeof clipObj.position === "number" &&
27
27
  typeof clipObj.end === "number" &&
28
28
  typeof clipObj.cutFrom === "number" &&
29
- metadata.durationSec != null
29
+ metadata.duration != null
30
30
  ) {
31
31
  const requestedDuration = Math.max(0, clipObj.end - clipObj.position);
32
- const maxAvailable = Math.max(0, metadata.durationSec - clipObj.cutFrom);
32
+ const maxAvailable = Math.max(0, metadata.duration - clipObj.cutFrom);
33
33
  if (requestedDuration > maxAvailable) {
34
34
  const clampedEnd = clipObj.position + maxAvailable;
35
35
  console.warn(
@@ -42,14 +42,15 @@ async function loadVideo(project, clipObj) {
42
42
  }
43
43
  project.videoOrAudioClips.push({
44
44
  ...clipObj,
45
- iphoneRotation: metadata.iphoneRotation,
45
+ iphoneRotation: metadata.rotation,
46
46
  hasAudio: metadata.hasAudio,
47
- mediaDuration: metadata.durationSec,
47
+ mediaDuration: metadata.duration,
48
48
  });
49
49
  }
50
50
 
51
51
  async function loadAudio(project, clipObj) {
52
- const durationSec = await getMediaDuration(clipObj.url);
52
+ const metadata = await probeMedia(clipObj.url);
53
+ const durationSec = metadata.duration;
53
54
  if (typeof clipObj.cutFrom === "number" && durationSec != null) {
54
55
  if (clipObj.cutFrom >= durationSec) {
55
56
  throw new ValidationError(
@@ -93,7 +94,8 @@ function loadImage(project, clipObj) {
93
94
  }
94
95
 
95
96
  async function loadBackgroundAudio(project, clipObj) {
96
- const durationSec = await getMediaDuration(clipObj.url);
97
+ const metadata = await probeMedia(clipObj.url);
98
+ const durationSec = metadata.duration;
97
99
  const clip = {
98
100
  ...clipObj,
99
101
  volume:
@@ -107,6 +107,12 @@ function formatSchema(modules, options = {}) {
107
107
  lines.push(
108
108
  "- All times are in **seconds**. `position` = when the clip starts, `end` = when it ends."
109
109
  );
110
+ lines.push(
111
+ "- **`duration`** can be used instead of `end`: the library computes `end = position + duration`. Cannot use both."
112
+ );
113
+ lines.push(
114
+ "- **Auto-sequencing:** For video, image, and audio clips, `position` can be omitted. The clip will be placed immediately after the previous clip on its track. The first clip defaults to position 0."
115
+ );
110
116
  lines.push(
111
117
  "- Video/image clips form the visual timeline. Audio, text, and music are layered on top."
112
118
  );
@@ -6,8 +6,9 @@ module.exports = {
6
6
  schema: `{
7
7
  type: "audio"; // Required: clip type identifier
8
8
  url: string; // Required: path to audio file
9
- position: number; // Required: start time on timeline (seconds)
10
- end: number; // Required: end time on timeline (seconds)
9
+ position?: number; // Start time on timeline (seconds). Omit to auto-sequence after previous audio 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.
11
12
  cutFrom?: number; // Start playback from this point in the source (default: 0)
12
13
  volume?: number; // Volume multiplier (default: 1, 0 = mute, >1 = amplify)
13
14
  }`,
@@ -22,6 +23,8 @@ module.exports = {
22
23
  },
23
24
  ],
24
25
  notes: [
26
+ "If position is omitted, the clip is placed immediately after the previous audio clip (auto-sequencing). The first clip defaults to position 0.",
27
+ "Use duration instead of end to specify how long the clip plays: end = position + duration. Cannot use both.",
25
28
  "Audio clips are mixed (layered) with video audio and background music — they don't replace other audio.",
26
29
  "Use cutFrom to start playback partway through the source file.",
27
30
  ],
@@ -6,8 +6,9 @@ module.exports = {
6
6
  schema: `{
7
7
  type: "image"; // Required: clip type identifier
8
8
  url: string; // Required: path to image file (jpg, png, etc.)
9
- position: number; // Required: start time on timeline (seconds)
10
- end: number; // Required: end time on timeline (seconds)
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.
11
12
  kenBurns?: KenBurnsEffect; // Optional: apply pan/zoom motion to the image
12
13
  }`,
13
14
  enums: {
@@ -23,14 +24,20 @@ module.exports = {
23
24
  examples: [
24
25
  {
25
26
  label: "Static image for 5 seconds",
26
- code: `{ type: "image", url: "photo.jpg", position: 0, end: 5 }`,
27
+ code: `{ type: "image", url: "photo.jpg", duration: 5 }`,
27
28
  },
28
29
  {
29
- label: "Image with slow zoom-in effect",
30
- code: `{ type: "image", url: "landscape.png", position: 5, end: 12, kenBurns: "zoom-in" }`,
30
+ label: "Image slideshow with Ken Burns effects (auto-sequenced)",
31
+ code: `[
32
+ { type: "image", url: "photo1.jpg", duration: 3, kenBurns: "zoom-in" },
33
+ { type: "image", url: "photo2.jpg", duration: 3, kenBurns: "pan-right" },
34
+ { type: "image", url: "photo3.jpg", duration: 3, kenBurns: "zoom-out" }
35
+ ]`,
31
36
  },
32
37
  ],
33
38
  notes: [
39
+ "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
+ "Use duration instead of end to specify how long the image displays: end = position + duration. Cannot use both.",
34
41
  "Images are scaled to fill the project canvas. For Ken Burns, use images at least as large as the output resolution for best quality.",
35
42
  "Image clips can be placed on the same timeline as video clips and can use transitions between them.",
36
43
  ],
@@ -7,7 +7,8 @@ module.exports = {
7
7
  type: "music"; // Required: clip type ("music" or "backgroundAudio")
8
8
  url: string; // Required: path to audio file
9
9
  position?: number; // Start time on timeline (default: 0)
10
- end?: number; // End time on timeline (default: end of video)
10
+ end?: number; // End time on timeline (default: end of video). Use end OR duration, not both.
11
+ duration?: number; // Duration in seconds (alternative to end). end = position + duration.
11
12
  cutFrom?: number; // Start playback from this point in the source (default: 0)
12
13
  volume?: number; // Volume multiplier (default: 0.2 — quieter than main audio)
13
14
  loop?: boolean; // Loop the track to fill the entire video duration (default: false)
@@ -7,7 +7,8 @@ module.exports = {
7
7
  type: "subtitle"; // Required: clip type identifier
8
8
  url: string; // Required: path to subtitle file (.srt, .vtt, .ass, .ssa)
9
9
  position?: number; // Timeline offset — shifts all subtitle timestamps forward (default: 0)
10
- end?: number; // Optional end time to cut off subtitles
10
+ end?: number; // Optional end time to cut off subtitles. Use end OR duration, not both.
11
+ duration?: number; // Duration in seconds (alternative to end). end = position + duration.
11
12
 
12
13
  // Styling (applies to SRT/VTT imports — ASS/SSA files use their own embedded styles)
13
14
  fontFamily?: string; // Font family (default: "Sans")
@@ -6,7 +6,8 @@ module.exports = {
6
6
  schema: `{
7
7
  type: "text"; // Required: clip type identifier
8
8
  position: number; // Required: start time on timeline (seconds)
9
- end: number; // Required: end time on timeline (seconds)
9
+ end?: number; // End time on timeline (seconds). Use end OR duration, not both.
10
+ duration?: number; // Duration in seconds (alternative to end). end = position + duration.
10
11
 
11
12
  // Content
12
13
  text?: string; // Text content (required for "static" mode)
@@ -111,6 +112,7 @@ module.exports = {
111
112
  },
112
113
  ],
113
114
  notes: [
115
+ "Use duration instead of end to specify how long the text appears: end = position + duration. Cannot use both.",
114
116
  "If no position is specified (xPercent/yPercent/x/y), text defaults to center of the screen.",
115
117
  "For karaoke mode, provide the words array with per-word start/end times.",
116
118
  "For word-replace and word-sequential, you can use either words[] or wordTimestamps[] (paired with a space-separated text string).",