simple-ffmpegjs 0.4.4 → 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.
- package/README.md +192 -3
- package/package.json +1 -1
- package/src/core/rotation.js +6 -5
- package/src/core/validation.js +36 -0
- package/src/ffmpeg/command_builder.js +50 -0
- package/src/ffmpeg/strings.js +82 -0
- package/src/ffmpeg/subtitle_builder.js +163 -7
- package/src/ffmpeg/text_passes.js +3 -1
- package/src/ffmpeg/video_builder.js +85 -20
- package/src/loaders.js +1 -1
- package/src/schema/modules/image.js +17 -1
- package/src/simpleffmpeg.js +309 -10
- package/types/index.d.mts +12 -0
- package/types/index.d.ts +100 -0
|
@@ -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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
"
|
|
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).",
|