simple-ffmpegjs 0.4.3 → 0.5.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.
@@ -630,21 +630,175 @@ function loadSubtitleFile(filePath, options, canvasWidth, canvasHeight) {
630
630
  return ass;
631
631
  }
632
632
 
633
+ /**
634
+ * Regex matching emoji characters: inherently visual + variation-selector emoji.
635
+ * Captures each emoji (including trailing \uFE0F) as a group.
636
+ */
637
+ const EMOJI_RE = /(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)/gu;
638
+
639
+ /**
640
+ * Segment text into emoji and non-emoji runs, wrapping emoji characters
641
+ * with ASS \fn override tags to explicitly switch to the emoji font.
642
+ *
643
+ * If emojiFont is null/undefined, returns plain escaped text (no font switching).
644
+ *
645
+ * @param {string} text - Raw text
646
+ * @param {string} primaryFont - The main text font family
647
+ * @param {string|null} emojiFont - The detected emoji font family, or null
648
+ * @returns {string} ASS-escaped text with inline \fn tags for emoji
649
+ */
650
+ function segmentTextForASS(text, primaryFont, emojiFont) {
651
+ if (!emojiFont) return escapeASSText(text);
652
+
653
+ let result = "";
654
+ let lastIndex = 0;
655
+
656
+ for (const match of text.matchAll(EMOJI_RE)) {
657
+ if (match.index > lastIndex) {
658
+ result += escapeASSText(text.slice(lastIndex, match.index));
659
+ }
660
+ result += `{\\fn${emojiFont}}${escapeASSText(match[0])}{\\fn${primaryFont}}`;
661
+ lastIndex = match.index + match[0].length;
662
+ }
663
+
664
+ if (lastIndex < text.length) {
665
+ result += escapeASSText(text.slice(lastIndex));
666
+ }
667
+
668
+ return result;
669
+ }
670
+
671
+ /**
672
+ * Build ASS content for a text clip that contains emoji.
673
+ * Uses \pos for pixel-precise positioning and \fad for fade animations.
674
+ * When emojiFont is provided, emoji characters are wrapped in \fn tags
675
+ * so libass uses the correct font instead of relying on broken fallback.
676
+ *
677
+ * @param {Object} clip - Text clip (already resolved with defaults)
678
+ * @param {number} canvasWidth - Video width
679
+ * @param {number} canvasHeight - Video height
680
+ * @param {string|null} [emojiFont] - Detected system emoji font name, or null
681
+ * @returns {string} Complete ASS file content
682
+ */
683
+ function buildTextClipASS(clip, canvasWidth, canvasHeight, emojiFont) {
684
+ const {
685
+ text = "",
686
+ position: clipStart,
687
+ end: clipEnd,
688
+ fontFamily = "Sans",
689
+ fontSize = 48,
690
+ fontColor = "#FFFFFF",
691
+ borderColor,
692
+ borderWidth = 0,
693
+ shadowColor,
694
+ shadowX = 0,
695
+ shadowY = 0,
696
+ backgroundColor,
697
+ backgroundOpacity,
698
+ xPercent,
699
+ yPercent,
700
+ x,
701
+ y,
702
+ xOffset = 0,
703
+ yOffset = 0,
704
+ animation,
705
+ opacity = 1,
706
+ } = clip;
707
+
708
+ const shadowDepth =
709
+ shadowColor ? Math.max(Math.abs(shadowX), Math.abs(shadowY), 1) : 0;
710
+ const borderStyle = backgroundColor ? 3 : 1;
711
+ const backColor = backgroundColor || shadowColor || "#000000";
712
+ const backOpacity = backgroundColor
713
+ ? (typeof backgroundOpacity === "number" ? backgroundOpacity : 0.5)
714
+ : 0.5;
715
+
716
+ let ass = generateASSHeader(canvasWidth, canvasHeight, "Emoji Text");
717
+
718
+ ass += generateASSStyles([
719
+ {
720
+ name: "EmojiText",
721
+ fontFamily,
722
+ fontSize,
723
+ primaryColor: fontColor,
724
+ outlineColor: borderColor || "#000000",
725
+ backColor,
726
+ outline: borderWidth,
727
+ shadow: shadowDepth,
728
+ alignment: 7,
729
+ borderStyle,
730
+ marginL: 0,
731
+ marginR: 0,
732
+ marginV: 0,
733
+ opacity,
734
+ outlineOpacity: borderColor ? 1 : 0,
735
+ },
736
+ ]);
737
+
738
+ // Compute pixel position for \pos tag
739
+ // Alignment 7 = top-left, so \an5 override centers the text at \pos(x,y)
740
+ let posX = canvasWidth / 2;
741
+ let posY = canvasHeight / 2;
742
+ if (typeof xPercent === "number") posX = Math.round(xPercent * canvasWidth);
743
+ else if (typeof x === "number") posX = x;
744
+ if (typeof yPercent === "number") posY = Math.round(yPercent * canvasHeight);
745
+ else if (typeof y === "number") posY = y;
746
+ posX += xOffset;
747
+ posY += yOffset;
748
+
749
+ // Build inline override tags
750
+ let overrides = `\\an5\\pos(${posX},${posY})`;
751
+
752
+ const anim = animation || {};
753
+ const animType = anim.type || "none";
754
+ if (animType === "fade-in") {
755
+ const fadeIn = Math.round((typeof anim.in === "number" ? anim.in : 0.25) * 1000);
756
+ overrides += `\\fad(${fadeIn},0)`;
757
+ } else if (animType === "fade-out") {
758
+ const fadeOut = Math.round((typeof anim.out === "number" ? anim.out : 0.25) * 1000);
759
+ overrides += `\\fad(0,${fadeOut})`;
760
+ } else if (animType === "fade-in-out" || animType === "fade") {
761
+ const fadeIn = Math.round((typeof anim.in === "number" ? anim.in : 0.25) * 1000);
762
+ const fadeOut = Math.round((typeof anim.out === "number" ? anim.out : fadeIn / 1000) * 1000);
763
+ overrides += `\\fad(${fadeIn},${fadeOut})`;
764
+ }
765
+
766
+ const segmentedText = segmentTextForASS(text, fontFamily, emojiFont || null);
767
+ const dialogueText = `{${overrides}}${segmentedText}`;
768
+
769
+ ass += generateASSEvents([
770
+ {
771
+ start: clipStart,
772
+ end: clipEnd,
773
+ style: "EmojiText",
774
+ text: dialogueText,
775
+ },
776
+ ]);
777
+
778
+ return ass;
779
+ }
780
+
633
781
  /**
634
782
  * Build the FFmpeg filter string for ASS subtitles
635
783
  * @param {string} assFilePath - Path to the ASS file
636
784
  * @param {string} inputLabel - Current video stream label
785
+ * @param {Object} [options] - Optional settings
786
+ * @param {string} [options.fontsDir] - Directory for libass font lookup (for fontFile support)
637
787
  * @returns {{ filter: string, finalLabel: string }}
638
788
  */
639
- function buildASSFilter(assFilePath, inputLabel) {
640
- // Escape path for FFmpeg filter (must survive two levels of av_get_token)
641
- const escapedPath = assFilePath
642
- .replace(/\\/g, "/")
643
- .replace(/:/g, "\\:")
644
- .replace(/'/g, "'\\\\\\''" );
789
+ function buildASSFilter(assFilePath, inputLabel, options) {
790
+ const escapeFn = (p) =>
791
+ p.replace(/\\/g, "/").replace(/:/g, "\\:").replace(/'/g, "'\\\\\\''" );
792
+
793
+ const escapedPath = escapeFn(assFilePath);
794
+ let filterParams = `ass='${escapedPath}'`;
795
+
796
+ if (options && options.fontsDir) {
797
+ filterParams += `:fontsdir='${escapeFn(options.fontsDir)}'`;
798
+ }
645
799
 
646
800
  const outputLabel = "[outass]";
647
- const filter = `${inputLabel}ass='${escapedPath}'${outputLabel}`;
801
+ const filter = `${inputLabel}${filterParams}${outputLabel}`;
648
802
 
649
803
  return {
650
804
  filter,
@@ -691,6 +845,8 @@ function validateSubtitleClip(clip) {
691
845
  module.exports = {
692
846
  buildKaraokeASS,
693
847
  buildSubtitleASS,
848
+ buildTextClipASS,
849
+ segmentTextForASS,
694
850
  buildASSFilter,
695
851
  loadSubtitleFile,
696
852
  parseSRT,
@@ -74,10 +74,12 @@ async function runTextPasses({
74
74
  intermediateCrf,
75
75
  batchSize = 75,
76
76
  onLog,
77
+ tempDir,
77
78
  }) {
78
79
  const tempOutputs = [];
79
80
  let currentInput = baseOutputPath;
80
81
  let passes = 0;
82
+ const intermediateDir = tempDir || path.dirname(baseOutputPath);
81
83
 
82
84
  for (let i = 0; i < textWindows.length; i += batchSize) {
83
85
  const batch = textWindows.slice(i, i + batchSize);
@@ -89,7 +91,7 @@ async function runTextPasses({
89
91
  );
90
92
 
91
93
  const batchOutput = path.join(
92
- path.dirname(baseOutputPath),
94
+ intermediateDir,
93
95
  `textpass_${i}_${path.basename(baseOutputPath)}`
94
96
  );
95
97
  tempOutputs.push(batchOutput);
@@ -1,5 +1,6 @@
1
1
  const DEFAULT_KEN_BURNS_ZOOM = 0.15;
2
2
  const DEFAULT_PAN_ZOOM = 1.12;
3
+ const MIN_PAN_ZOOM = 1.04;
3
4
 
4
5
  function clamp01(value) {
5
6
  if (typeof value !== "number" || !Number.isFinite(value)) {
@@ -151,16 +152,20 @@ function resolveKenBurnsOptions(kenBurns, width, height, sourceWidth, sourceHeig
151
152
  endY = customEndY;
152
153
  }
153
154
 
154
- // When positions indicate panning but zoom wasn't explicitly set (still at
155
- // the default 1.0), apply a default pan zoom. At zoom=1.0 the zoompan
156
- // visible window equals the full image, so (iw - iw/zoom) = 0 and position
157
- // values are multiplied by zero making the pan completely invisible.
155
+ // At zoom=1.0 the zoompan visible window equals the full image, so
156
+ // (iw - iw/zoom) = 0 and position values are multiplied by zero — making
157
+ // any pan completely invisible. Ensure both zoom endpoints are high enough
158
+ // for the pan to have a visible range.
159
+ // When zoom wasn't explicitly provided, use the full default pan zoom.
160
+ // When it was explicit but below the minimum, nudge it up just enough to
161
+ // make the pan work without dramatically altering the requested zoom.
158
162
  const hasPan = startX !== endX || startY !== endY;
159
- const zoomWasExplicit =
160
- typeof kb.startZoom === "number" || typeof kb.endZoom === "number";
161
- if (hasPan && !zoomWasExplicit && startZoom === 1 && endZoom === 1) {
162
- startZoom = DEFAULT_PAN_ZOOM;
163
- endZoom = DEFAULT_PAN_ZOOM;
163
+ if (hasPan) {
164
+ const zoomWasExplicit =
165
+ typeof kb.startZoom === "number" || typeof kb.endZoom === "number";
166
+ const minZoom = zoomWasExplicit ? MIN_PAN_ZOOM : DEFAULT_PAN_ZOOM;
167
+ if (startZoom < minZoom) startZoom = minZoom;
168
+ if (endZoom < minZoom) endZoom = minZoom;
164
169
  }
165
170
 
166
171
  return { startZoom, endZoom, startX, startY, endX, endY, easing };
@@ -180,6 +185,27 @@ function computeOverscanWidth(width, startZoom, endZoom) {
180
185
  return overscan;
181
186
  }
182
187
 
188
+ function computeContainedSize(srcW, srcH, outW, outH) {
189
+ const srcAspect = srcW / srcH;
190
+ const outAspect = outW / outH;
191
+ let cw, ch;
192
+ if (srcAspect > outAspect) {
193
+ cw = outW;
194
+ ch = Math.round(outW / srcAspect);
195
+ } else {
196
+ ch = outH;
197
+ cw = Math.round(outH * srcAspect);
198
+ }
199
+ if (cw % 2 !== 0) cw += 1;
200
+ if (ch % 2 !== 0) ch += 1;
201
+ return { cw, ch };
202
+ }
203
+
204
+ function resolveImageFit(clip) {
205
+ if (clip.imageFit) return clip.imageFit;
206
+ return clip.kenBurns ? "cover" : "blur-fill";
207
+ }
208
+
183
209
  function buildVideoFilter(project, videoClips) {
184
210
  let filterComplex = "";
185
211
  let videoIndex = 0;
@@ -236,7 +262,6 @@ function buildVideoFilter(project, videoClips) {
236
262
  if (clip.type === "image" && clip.kenBurns) {
237
263
  const frames = Math.max(1, Math.round(clipDuration * fps));
238
264
  const framesMinusOne = Math.max(1, frames - 1);
239
- const s = `${width}x${height}`;
240
265
 
241
266
  const { startZoom, endZoom, startX, startY, endX, endY, easing } =
242
267
  resolveKenBurnsOptions(
@@ -246,23 +271,63 @@ function buildVideoFilter(project, videoClips) {
246
271
  clip.width,
247
272
  clip.height
248
273
  );
249
- // Overscan provides enough pixel resolution for smooth zoompan motion.
250
- const overscanW = computeOverscanWidth(width, startZoom, endZoom);
251
274
  const zoomExpr = buildZoomExpr(startZoom, endZoom, framesMinusOne, easing);
252
275
  const xPosExpr = buildPositionExpr(startX, endX, framesMinusOne, easing);
253
276
  const yPosExpr = buildPositionExpr(startY, endY, framesMinusOne, easing);
254
277
  const xExpr = `(iw - iw/zoom)*(${xPosExpr})`;
255
278
  const yExpr = `(ih - ih/zoom)*(${yPosExpr})`;
256
279
 
257
- // Scale to cover target dimensions (upscaling if needed), then center crop
258
- // force_original_aspect_ratio=increase ensures image covers the target area
259
- // select='eq(n,0)' uses single quotes to protect the comma from the graph parser.
260
- // zoompan z/x/y values are also single-quoted commas inside are literal.
261
- 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},setsar=1:1,settb=1/${fps}${scaledLabel};`;
280
+ let kbFit = resolveImageFit(clip);
281
+ const hasSrcDims = typeof clip.width === "number" && typeof clip.height === "number"
282
+ && clip.width > 0 && clip.height > 0;
283
+ if ((kbFit === "blur-fill" || kbFit === "contain") && !hasSrcDims) {
284
+ kbFit = "cover";
285
+ }
286
+
287
+ if (kbFit === "blur-fill") {
288
+ const { cw, ch } = computeContainedSize(clip.width, clip.height, width, height);
289
+ const sigma = typeof clip.blurIntensity === "number" && Number.isFinite(clip.blurIntensity) && clip.blurIntensity > 0
290
+ ? clip.blurIntensity : 40;
291
+ const overscanCW = computeOverscanWidth(cw, startZoom, endZoom);
292
+ const cs = `${cw}x${ch}`;
293
+ const kbbgLabel = `[kbbg${videoIndex}]`;
294
+ const kbfgLabel = `[kbfg${videoIndex}]`;
295
+ const kbbgrLabel = `[kbbgr${videoIndex}]`;
296
+ const kbfgrLabel = `[kbfgr${videoIndex}]`;
297
+ filterComplex += `[${inputIndex}:v]select='eq(n,0)',setpts=PTS-STARTPTS,split${kbbgLabel}${kbfgLabel};`;
298
+ filterComplex += `${kbbgLabel}scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height}:(iw-${width})/2:(ih-${height})/2,gblur=sigma=${sigma},loop=${frames - 1}:1:0,setpts=N/${fps}/TB,fps=${fps},settb=1/${fps}${kbbgrLabel};`;
299
+ filterComplex += `${kbfgLabel}scale=${cw}:${ch}:force_original_aspect_ratio=increase,setsar=1:1,crop=${cw}:${ch}:(iw-${cw})/2:(ih-${ch})/2,scale=${overscanCW}:-1,zoompan=z='${zoomExpr}':x='${xExpr}':y='${yExpr}':d=${frames}:s=${cs}:fps=${fps},setsar=1:1,settb=1/${fps}${kbfgrLabel};`;
300
+ filterComplex += `${kbbgrLabel}${kbfgrLabel}overlay=(W-w)/2:(H-h)/2,setsar=1:1,settb=1/${fps}${scaledLabel};`;
301
+ } else if (kbFit === "contain") {
302
+ const { cw, ch } = computeContainedSize(clip.width, clip.height, width, height);
303
+ const overscanCW = computeOverscanWidth(cw, startZoom, endZoom);
304
+ const cs = `${cw}x${ch}`;
305
+ filterComplex += `[${inputIndex}:v]select='eq(n,0)',setpts=PTS-STARTPTS,scale=${cw}:${ch}:force_original_aspect_ratio=increase,setsar=1:1,crop=${cw}:${ch}:(iw-${cw})/2:(ih-${ch})/2,scale=${overscanCW}:-1,zoompan=z='${zoomExpr}':x='${xExpr}':y='${yExpr}':d=${frames}:s=${cs}:fps=${fps},setsar=1:1,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,settb=1/${fps}${scaledLabel};`;
306
+ } else {
307
+ const s = `${width}x${height}`;
308
+ const overscanW = computeOverscanWidth(width, startZoom, endZoom);
309
+ 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},setsar=1:1,settb=1/${fps}${scaledLabel};`;
310
+ }
262
311
  } else {
263
- filterComplex += `[${inputIndex}:v]trim=start=${
264
- clip.cutFrom || 0
265
- }:duration=${clipDuration},setpts=PTS-STARTPTS,fps=${fps},scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1:1,settb=1/${fps}${scaledLabel};`;
312
+ const fit = clip.type === "image" ? resolveImageFit(clip) : null;
313
+ const trimPrefix = `[${inputIndex}:v]trim=start=${clip.cutFrom || 0}:duration=${clipDuration},setpts=PTS-STARTPTS,fps=${fps}`;
314
+
315
+ if (fit === "blur-fill") {
316
+ const sigma = typeof clip.blurIntensity === "number" && Number.isFinite(clip.blurIntensity) && clip.blurIntensity > 0
317
+ ? clip.blurIntensity : 40;
318
+ const bgLabel = `[bg${videoIndex}]`;
319
+ const fgLabel = `[fg${videoIndex}]`;
320
+ const bgrLabel = `[bgr${videoIndex}]`;
321
+ const fgrLabel = `[fgr${videoIndex}]`;
322
+ filterComplex += `${trimPrefix},split${bgLabel}${fgLabel};`;
323
+ filterComplex += `${bgLabel}scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height}:(iw-${width})/2:(ih-${height})/2,gblur=sigma=${sigma}${bgrLabel};`;
324
+ filterComplex += `${fgLabel}scale=${width}:${height}:force_original_aspect_ratio=decrease${fgrLabel};`;
325
+ filterComplex += `${bgrLabel}${fgrLabel}overlay=(W-w)/2:(H-h)/2,setsar=1:1,settb=1/${fps}${scaledLabel};`;
326
+ } else if (fit === "cover") {
327
+ filterComplex += `${trimPrefix},scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height}:(iw-${width})/2:(ih-${height})/2,setsar=1:1,settb=1/${fps}${scaledLabel};`;
328
+ } else {
329
+ filterComplex += `${trimPrefix},scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1:1,settb=1/${fps}${scaledLabel};`;
330
+ }
266
331
  }
267
332
 
268
333
  scaledStreams.push({
package/src/loaders.js CHANGED
@@ -236,7 +236,7 @@ async function loadColor(project, clipObj) {
236
236
  const ppmBuffer = generateGradientPPM(width, height, clipObj.color);
237
237
 
238
238
  const tempPath = path.join(
239
- os.tmpdir(),
239
+ project.options.tempDir || os.tmpdir(),
240
240
  `simpleffmpeg-gradient-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.ppm`
241
241
  );
242
242
  fs.writeFileSync(tempPath, ppmBuffer);
@@ -12,6 +12,8 @@ module.exports = {
12
12
  width?: number; // Optional: source image width (skip probe / override)
13
13
  height?: number; // Optional: source image height (skip probe / override)
14
14
  kenBurns?: KenBurnsEffect | KenBurnsSpec; // Optional: apply pan/zoom motion to the image
15
+ imageFit?: ImageFit; // Optional: how to fit image when aspect ratio differs from output (default: "blur-fill" without Ken Burns, "cover" with Ken Burns)
16
+ blurIntensity?: number; // Optional: blur strength for blur-fill background (Gaussian sigma). Default: 40. Higher = blurrier. Typical range: 10-80.
15
17
  }`,
16
18
  enums: {
17
19
  KenBurnsEffect: [
@@ -26,6 +28,7 @@ module.exports = {
26
28
  ],
27
29
  KenBurnsAnchor: ["top", "bottom", "left", "right"],
28
30
  KenBurnsEasing: ["linear", "ease-in", "ease-out", "ease-in-out"],
31
+ ImageFit: ["cover", "contain", "blur-fill"],
29
32
  },
30
33
  examples: [
31
34
  {
@@ -48,11 +51,24 @@ module.exports = {
48
51
  label: "Custom Ken Burns with explicit pan endpoints",
49
52
  code: `{ type: "image", url: "photo.jpg", duration: 4, kenBurns: { type: "custom", startX: 0.2, startY: 0.8, endX: 0.7, endY: 0.3 } }`,
50
53
  },
54
+ {
55
+ label: "Landscape image in portrait video with blurred background (default)",
56
+ code: `{ type: "image", url: "landscape.jpg", duration: 5 }`,
57
+ },
58
+ {
59
+ label: "Image with black bar padding (contain)",
60
+ code: `{ type: "image", url: "landscape.jpg", duration: 5, imageFit: "contain" }`,
61
+ },
62
+ {
63
+ label: "Image scaled to fill frame (cover, crops excess)",
64
+ code: `{ type: "image", url: "landscape.jpg", duration: 5, imageFit: "cover" }`,
65
+ },
51
66
  ],
52
67
  notes: [
53
68
  "If position is omitted, the clip is placed immediately after the previous video/image clip (auto-sequencing). The first clip defaults to position 0.",
54
69
  "Use duration instead of end to specify how long the image displays: end = position + duration. Cannot use both.",
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.",
70
+ "imageFit controls how images are fitted when their aspect ratio differs from the output: 'blur-fill' (default) fills empty space with a blurred version of the image, 'cover' scales up and crops to fill the frame, 'contain' pads with black bars.",
71
+ "Ken Burns defaults to 'cover' but respects imageFit when set. With 'blur-fill' or 'contain', the Ken Burns motion applies to the contained image while the background stays static. Source dimensions (width/height) are required for KB + blur-fill/contain; without them it falls back to cover.",
56
72
  "If width/height are provided, they override probed dimensions (useful for remote or generated images).",
57
73
  "Image clips can be placed on the same timeline as video clips and can use transitions between them.",
58
74
  "Advanced Ken Burns accepts custom zoom/pan endpoints via normalized coordinates (0 = left/top, 1 = right/bottom).",