simple-ffmpegjs 0.3.6 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +139 -38
- package/package.json +1 -1
- package/src/core/gaps.js +5 -31
- package/src/core/resolve.js +1 -1
- package/src/core/validation.js +295 -77
- package/src/ffmpeg/audio_builder.js +3 -1
- package/src/ffmpeg/bgm_builder.js +3 -1
- package/src/ffmpeg/effect_builder.js +138 -0
- package/src/ffmpeg/video_builder.js +20 -37
- package/src/lib/gradient.js +257 -0
- package/src/loaders.js +46 -0
- package/src/schema/formatter.js +2 -0
- package/src/schema/index.js +4 -0
- package/src/schema/modules/color.js +54 -0
- package/src/schema/modules/effect.js +77 -0
- package/src/simpleffmpeg.js +59 -92
- package/types/index.d.mts +76 -5
- package/types/index.d.ts +87 -5
package/src/core/validation.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
|
+
const { detectVisualGaps } = require("./gaps");
|
|
2
3
|
|
|
3
4
|
// ========================================================================
|
|
4
5
|
// FFmpeg named colors (X11/CSS color names accepted by libavutil)
|
|
@@ -71,39 +72,6 @@ function isValidFFmpegColor(value) {
|
|
|
71
72
|
return FFMPEG_NAMED_COLORS.has(color.toLowerCase());
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
/**
|
|
75
|
-
* Normalise a fillGaps option value to either "none" (disabled) or a
|
|
76
|
-
* valid FFmpeg color string.
|
|
77
|
-
*
|
|
78
|
-
* Accepted inputs:
|
|
79
|
-
* - false / "none" / "off" / undefined → "none"
|
|
80
|
-
* - true → "black"
|
|
81
|
-
* - "black", "red", "#FF0000", … → the color string (validated)
|
|
82
|
-
*
|
|
83
|
-
* @param {*} value - Raw fillGaps option value
|
|
84
|
-
* @returns {{ color: string|null, error: string|null }}
|
|
85
|
-
* color is the normalised value ("none" when disabled), error is a
|
|
86
|
-
* human-readable message when the value is invalid.
|
|
87
|
-
*/
|
|
88
|
-
function normalizeFillGaps(value) {
|
|
89
|
-
if (value === undefined || value === null || value === false || value === "none" || value === "off") {
|
|
90
|
-
return { color: "none", error: null };
|
|
91
|
-
}
|
|
92
|
-
if (value === true) {
|
|
93
|
-
return { color: "black", error: null };
|
|
94
|
-
}
|
|
95
|
-
if (typeof value !== "string") {
|
|
96
|
-
return { color: null, error: `fillGaps must be a string color value, boolean, or "none" — got ${typeof value}` };
|
|
97
|
-
}
|
|
98
|
-
if (!isValidFFmpegColor(value)) {
|
|
99
|
-
return {
|
|
100
|
-
color: null,
|
|
101
|
-
error: `fillGaps color "${value}" is not a recognised FFmpeg color. Use a named color (e.g. "black", "red", "navy"), hex (#RRGGBB, 0xRRGGBB), or "random".`,
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
return { color: value, error: null };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
75
|
/**
|
|
108
76
|
* Error/warning codes for programmatic handling
|
|
109
77
|
*/
|
|
@@ -138,6 +106,172 @@ function createIssue(code, path, message, received = undefined) {
|
|
|
138
106
|
return issue;
|
|
139
107
|
}
|
|
140
108
|
|
|
109
|
+
const EFFECT_TYPES = ["vignette", "filmGrain", "gaussianBlur", "colorAdjust"];
|
|
110
|
+
const EFFECT_EASING = ["linear", "ease-in", "ease-out", "ease-in-out"];
|
|
111
|
+
|
|
112
|
+
function validateFiniteNumber(value, path, errors, opts = {}) {
|
|
113
|
+
const { min = null, max = null, minInclusive = true, maxInclusive = true } = opts;
|
|
114
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
115
|
+
errors.push(
|
|
116
|
+
createIssue(
|
|
117
|
+
ValidationCodes.INVALID_VALUE,
|
|
118
|
+
path,
|
|
119
|
+
"Must be a finite number",
|
|
120
|
+
value
|
|
121
|
+
)
|
|
122
|
+
);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (min != null) {
|
|
126
|
+
const failsMin = minInclusive ? value < min : value <= min;
|
|
127
|
+
if (failsMin) {
|
|
128
|
+
errors.push(
|
|
129
|
+
createIssue(
|
|
130
|
+
ValidationCodes.INVALID_RANGE,
|
|
131
|
+
path,
|
|
132
|
+
minInclusive ? `Must be >= ${min}` : `Must be > ${min}`,
|
|
133
|
+
value
|
|
134
|
+
)
|
|
135
|
+
);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (max != null) {
|
|
140
|
+
const failsMax = maxInclusive ? value > max : value >= max;
|
|
141
|
+
if (failsMax) {
|
|
142
|
+
errors.push(
|
|
143
|
+
createIssue(
|
|
144
|
+
ValidationCodes.INVALID_RANGE,
|
|
145
|
+
path,
|
|
146
|
+
maxInclusive ? `Must be <= ${max}` : `Must be < ${max}`,
|
|
147
|
+
value
|
|
148
|
+
)
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function validateEffectClip(clip, path, errors) {
|
|
155
|
+
if (!EFFECT_TYPES.includes(clip.effect)) {
|
|
156
|
+
errors.push(
|
|
157
|
+
createIssue(
|
|
158
|
+
ValidationCodes.INVALID_VALUE,
|
|
159
|
+
`${path}.effect`,
|
|
160
|
+
`Invalid effect '${clip.effect}'. Expected: ${EFFECT_TYPES.join(", ")}`,
|
|
161
|
+
clip.effect
|
|
162
|
+
)
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (clip.fadeIn != null) {
|
|
167
|
+
validateFiniteNumber(clip.fadeIn, `${path}.fadeIn`, errors, { min: 0 });
|
|
168
|
+
}
|
|
169
|
+
if (clip.fadeOut != null) {
|
|
170
|
+
validateFiniteNumber(clip.fadeOut, `${path}.fadeOut`, errors, { min: 0 });
|
|
171
|
+
}
|
|
172
|
+
if (clip.easing != null && !EFFECT_EASING.includes(clip.easing)) {
|
|
173
|
+
errors.push(
|
|
174
|
+
createIssue(
|
|
175
|
+
ValidationCodes.INVALID_VALUE,
|
|
176
|
+
`${path}.easing`,
|
|
177
|
+
`Invalid easing '${clip.easing}'. Expected: ${EFFECT_EASING.join(", ")}`,
|
|
178
|
+
clip.easing
|
|
179
|
+
)
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (typeof clip.position === "number" && typeof clip.end === "number") {
|
|
184
|
+
const duration = clip.end - clip.position;
|
|
185
|
+
const fadeTotal = (clip.fadeIn || 0) + (clip.fadeOut || 0);
|
|
186
|
+
if (fadeTotal > duration + 1e-9) {
|
|
187
|
+
errors.push(
|
|
188
|
+
createIssue(
|
|
189
|
+
ValidationCodes.INVALID_TIMELINE,
|
|
190
|
+
`${path}`,
|
|
191
|
+
`fadeIn + fadeOut (${fadeTotal}) must be <= clip duration (${duration})`,
|
|
192
|
+
{ fadeIn: clip.fadeIn || 0, fadeOut: clip.fadeOut || 0, duration }
|
|
193
|
+
)
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (
|
|
199
|
+
clip.params == null ||
|
|
200
|
+
typeof clip.params !== "object" ||
|
|
201
|
+
Array.isArray(clip.params)
|
|
202
|
+
) {
|
|
203
|
+
errors.push(
|
|
204
|
+
createIssue(
|
|
205
|
+
ValidationCodes.MISSING_REQUIRED,
|
|
206
|
+
`${path}.params`,
|
|
207
|
+
"params is required and must be an object for effect clips",
|
|
208
|
+
clip.params
|
|
209
|
+
)
|
|
210
|
+
);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const params = clip.params;
|
|
215
|
+
if (params.amount != null) {
|
|
216
|
+
validateFiniteNumber(params.amount, `${path}.params.amount`, errors, {
|
|
217
|
+
min: 0,
|
|
218
|
+
max: 1,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (clip.effect === "vignette") {
|
|
223
|
+
if (params.angle != null) {
|
|
224
|
+
validateFiniteNumber(params.angle, `${path}.params.angle`, errors, {
|
|
225
|
+
min: 0,
|
|
226
|
+
max: 6.283185307179586,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
} else if (clip.effect === "filmGrain") {
|
|
230
|
+
if (params.temporal != null && typeof params.temporal !== "boolean") {
|
|
231
|
+
errors.push(
|
|
232
|
+
createIssue(
|
|
233
|
+
ValidationCodes.INVALID_VALUE,
|
|
234
|
+
`${path}.params.temporal`,
|
|
235
|
+
"temporal must be a boolean",
|
|
236
|
+
params.temporal
|
|
237
|
+
)
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
} else if (clip.effect === "gaussianBlur") {
|
|
241
|
+
if (params.sigma != null) {
|
|
242
|
+
validateFiniteNumber(params.sigma, `${path}.params.sigma`, errors, {
|
|
243
|
+
min: 0,
|
|
244
|
+
max: 100,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
} else if (clip.effect === "colorAdjust") {
|
|
248
|
+
if (params.brightness != null) {
|
|
249
|
+
validateFiniteNumber(params.brightness, `${path}.params.brightness`, errors, {
|
|
250
|
+
min: -1,
|
|
251
|
+
max: 1,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
if (params.contrast != null) {
|
|
255
|
+
validateFiniteNumber(params.contrast, `${path}.params.contrast`, errors, {
|
|
256
|
+
min: 0,
|
|
257
|
+
max: 3,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
if (params.saturation != null) {
|
|
261
|
+
validateFiniteNumber(params.saturation, `${path}.params.saturation`, errors, {
|
|
262
|
+
min: 0,
|
|
263
|
+
max: 3,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (params.gamma != null) {
|
|
267
|
+
validateFiniteNumber(params.gamma, `${path}.params.gamma`, errors, {
|
|
268
|
+
min: 0.1,
|
|
269
|
+
max: 10,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
141
275
|
/**
|
|
142
276
|
* Validate a single clip and return issues
|
|
143
277
|
*/
|
|
@@ -156,6 +290,8 @@ function validateClip(clip, index, options = {}) {
|
|
|
156
290
|
"backgroundAudio",
|
|
157
291
|
"image",
|
|
158
292
|
"subtitle",
|
|
293
|
+
"color",
|
|
294
|
+
"effect",
|
|
159
295
|
];
|
|
160
296
|
|
|
161
297
|
// Check type
|
|
@@ -227,7 +363,7 @@ function validateClip(clip, index, options = {}) {
|
|
|
227
363
|
}
|
|
228
364
|
|
|
229
365
|
// Types that require position/end on timeline
|
|
230
|
-
const requiresTimeline = ["video", "audio", "text", "image"].includes(
|
|
366
|
+
const requiresTimeline = ["video", "audio", "text", "image", "color", "effect"].includes(
|
|
231
367
|
clip.type
|
|
232
368
|
);
|
|
233
369
|
|
|
@@ -743,7 +879,9 @@ function validateClip(clip, index, options = {}) {
|
|
|
743
879
|
"custom",
|
|
744
880
|
];
|
|
745
881
|
const kbType =
|
|
746
|
-
typeof clip.kenBurns === "string"
|
|
882
|
+
typeof clip.kenBurns === "string"
|
|
883
|
+
? clip.kenBurns
|
|
884
|
+
: clip.kenBurns.type;
|
|
747
885
|
if (kbType && !validKenBurns.includes(kbType)) {
|
|
748
886
|
errors.push(
|
|
749
887
|
createIssue(
|
|
@@ -896,8 +1034,95 @@ function validateClip(clip, index, options = {}) {
|
|
|
896
1034
|
}
|
|
897
1035
|
}
|
|
898
1036
|
|
|
899
|
-
//
|
|
900
|
-
if (clip.type === "
|
|
1037
|
+
// Color clip validation
|
|
1038
|
+
if (clip.type === "color") {
|
|
1039
|
+
if (clip.color == null) {
|
|
1040
|
+
errors.push(
|
|
1041
|
+
createIssue(
|
|
1042
|
+
ValidationCodes.MISSING_REQUIRED,
|
|
1043
|
+
`${path}.color`,
|
|
1044
|
+
"Color is required for color clips",
|
|
1045
|
+
clip.color
|
|
1046
|
+
)
|
|
1047
|
+
);
|
|
1048
|
+
} else if (typeof clip.color === "string") {
|
|
1049
|
+
if (!isValidFFmpegColor(clip.color)) {
|
|
1050
|
+
errors.push(
|
|
1051
|
+
createIssue(
|
|
1052
|
+
ValidationCodes.INVALID_VALUE,
|
|
1053
|
+
`${path}.color`,
|
|
1054
|
+
`Invalid color "${clip.color}". Use a named color (e.g. "black", "navy"), hex (#RRGGBB, 0xRRGGBB), or "random".`,
|
|
1055
|
+
clip.color
|
|
1056
|
+
)
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
} else if (typeof clip.color === "object" && clip.color !== null) {
|
|
1060
|
+
const validGradientTypes = ["linear-gradient", "radial-gradient"];
|
|
1061
|
+
if (!clip.color.type || !validGradientTypes.includes(clip.color.type)) {
|
|
1062
|
+
errors.push(
|
|
1063
|
+
createIssue(
|
|
1064
|
+
ValidationCodes.INVALID_VALUE,
|
|
1065
|
+
`${path}.color.type`,
|
|
1066
|
+
`Invalid gradient type '${clip.color.type}'. Expected: ${validGradientTypes.join(", ")}`,
|
|
1067
|
+
clip.color.type
|
|
1068
|
+
)
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
if (!Array.isArray(clip.color.colors) || clip.color.colors.length < 2) {
|
|
1072
|
+
errors.push(
|
|
1073
|
+
createIssue(
|
|
1074
|
+
ValidationCodes.INVALID_VALUE,
|
|
1075
|
+
`${path}.color.colors`,
|
|
1076
|
+
"Gradient colors must be an array of at least 2 color strings",
|
|
1077
|
+
clip.color.colors
|
|
1078
|
+
)
|
|
1079
|
+
);
|
|
1080
|
+
} else {
|
|
1081
|
+
clip.color.colors.forEach((c, ci) => {
|
|
1082
|
+
if (typeof c !== "string" || !isValidFFmpegColor(c)) {
|
|
1083
|
+
errors.push(
|
|
1084
|
+
createIssue(
|
|
1085
|
+
ValidationCodes.INVALID_VALUE,
|
|
1086
|
+
`${path}.color.colors[${ci}]`,
|
|
1087
|
+
`Invalid gradient color "${c}". Use a named color (e.g. "black", "navy"), hex (#RRGGBB), or "random".`,
|
|
1088
|
+
c
|
|
1089
|
+
)
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
if (clip.color.direction != null) {
|
|
1095
|
+
const validDirections = ["vertical", "horizontal"];
|
|
1096
|
+
if (typeof clip.color.direction !== "number" && !validDirections.includes(clip.color.direction)) {
|
|
1097
|
+
errors.push(
|
|
1098
|
+
createIssue(
|
|
1099
|
+
ValidationCodes.INVALID_VALUE,
|
|
1100
|
+
`${path}.color.direction`,
|
|
1101
|
+
`Invalid gradient direction '${clip.color.direction}'. Expected: "vertical", "horizontal", or a number (angle in degrees)`,
|
|
1102
|
+
clip.color.direction
|
|
1103
|
+
)
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
} else {
|
|
1108
|
+
errors.push(
|
|
1109
|
+
createIssue(
|
|
1110
|
+
ValidationCodes.INVALID_VALUE,
|
|
1111
|
+
`${path}.color`,
|
|
1112
|
+
"Color must be a string (flat color) or an object (gradient spec)",
|
|
1113
|
+
clip.color
|
|
1114
|
+
)
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
if (clip.type === "effect") {
|
|
1120
|
+
validateEffectClip(clip, path, errors);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Visual clip transition validation (video, image, color)
|
|
1124
|
+
const visualTypes = ["video", "image", "color"];
|
|
1125
|
+
if (visualTypes.includes(clip.type) && clip.transition) {
|
|
901
1126
|
if (typeof clip.transition.duration !== "number") {
|
|
902
1127
|
errors.push(
|
|
903
1128
|
createIssue(
|
|
@@ -932,65 +1157,60 @@ function validateClip(clip, index, options = {}) {
|
|
|
932
1157
|
}
|
|
933
1158
|
|
|
934
1159
|
/**
|
|
935
|
-
* Validate timeline gaps (visual continuity)
|
|
1160
|
+
* Validate timeline gaps (visual continuity).
|
|
1161
|
+
* Uses detectVisualGaps() from gaps.js as the single source of truth
|
|
1162
|
+
* for gap detection logic.
|
|
936
1163
|
*/
|
|
937
|
-
function validateTimelineGaps(clips
|
|
938
|
-
const { fillGaps = "none" } = options;
|
|
1164
|
+
function validateTimelineGaps(clips) {
|
|
939
1165
|
const errors = [];
|
|
940
1166
|
|
|
941
|
-
//
|
|
942
|
-
|
|
1167
|
+
// Build clip objects with original indices for error messages
|
|
1168
|
+
const indexed = clips.map((c, i) => ({ ...c, _origIndex: i }));
|
|
1169
|
+
const gaps = detectVisualGaps(indexed);
|
|
1170
|
+
|
|
1171
|
+
if (gaps.length === 0) {
|
|
943
1172
|
return { errors, warnings: [] };
|
|
944
1173
|
}
|
|
945
1174
|
|
|
946
|
-
//
|
|
1175
|
+
// Build a sorted visual clip list so we can reference neighbours in messages
|
|
947
1176
|
const visual = clips
|
|
948
1177
|
.map((c, i) => ({ clip: c, index: i }))
|
|
949
|
-
.filter(({ clip }) => clip.type === "video" || clip.type === "image")
|
|
1178
|
+
.filter(({ clip }) => clip.type === "video" || clip.type === "image" || clip.type === "color")
|
|
950
1179
|
.filter(
|
|
951
1180
|
({ clip }) =>
|
|
952
1181
|
typeof clip.position === "number" && typeof clip.end === "number"
|
|
953
1182
|
)
|
|
954
1183
|
.sort((a, b) => a.clip.position - b.clip.position);
|
|
955
1184
|
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
const eps = 1e-3;
|
|
1185
|
+
for (const gap of gaps) {
|
|
1186
|
+
const isLeading = gap.start === 0 || (visual.length > 0 && gap.end <= visual[0].clip.position + 1e-3);
|
|
961
1187
|
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
)
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
const curr = visual[i].clip;
|
|
980
|
-
const gapStart = prev.end;
|
|
981
|
-
const gapEnd = curr.position;
|
|
1188
|
+
if (isLeading && gap.start < 1e-3) {
|
|
1189
|
+
errors.push(
|
|
1190
|
+
createIssue(
|
|
1191
|
+
ValidationCodes.TIMELINE_GAP,
|
|
1192
|
+
"timeline",
|
|
1193
|
+
`Gap at start of visual timeline [0, ${gap.end.toFixed(
|
|
1194
|
+
3
|
|
1195
|
+
)}s]. If intentional, fill it with a { type: "color" } clip. Otherwise, start your first clip at position 0.`,
|
|
1196
|
+
{ start: gap.start, end: gap.end }
|
|
1197
|
+
)
|
|
1198
|
+
);
|
|
1199
|
+
} else {
|
|
1200
|
+
// Find the surrounding clip indices for a helpful message
|
|
1201
|
+
const before = visual.filter(v => v.clip.end <= gap.start + 1e-3);
|
|
1202
|
+
const after = visual.filter(v => v.clip.position >= gap.end - 1e-3);
|
|
1203
|
+
const prevIdx = before.length > 0 ? before[before.length - 1].index : "?";
|
|
1204
|
+
const nextIdx = after.length > 0 ? after[0].index : "?";
|
|
982
1205
|
|
|
983
|
-
if (gapEnd - gapStart > eps) {
|
|
984
1206
|
errors.push(
|
|
985
1207
|
createIssue(
|
|
986
1208
|
ValidationCodes.TIMELINE_GAP,
|
|
987
1209
|
"timeline",
|
|
988
|
-
`Gap in timeline [${
|
|
1210
|
+
`Gap in visual timeline [${gap.start.toFixed(3)}s, ${gap.end.toFixed(
|
|
989
1211
|
3
|
|
990
|
-
)}s] between clips[${
|
|
991
|
-
|
|
992
|
-
}]. Use fillGaps option (e.g. 'black') to auto-fill.`,
|
|
993
|
-
{ start: gapStart, end: gapEnd }
|
|
1212
|
+
)}s] between clips[${prevIdx}] and clips[${nextIdx}]. If intentional, fill it with a { type: "color" } clip. Otherwise, adjust clip positions to remove the gap.`,
|
|
1213
|
+
{ start: gap.start, end: gap.end }
|
|
994
1214
|
)
|
|
995
1215
|
);
|
|
996
1216
|
}
|
|
@@ -1005,7 +1225,6 @@ function validateTimelineGaps(clips, options = {}) {
|
|
|
1005
1225
|
* @param {Array} clips - Array of clip objects to validate
|
|
1006
1226
|
* @param {Object} options - Validation options
|
|
1007
1227
|
* @param {boolean} options.skipFileChecks - Skip file existence checks (useful for AI validation)
|
|
1008
|
-
* @param {string} options.fillGaps - Gap handling mode ('none' | 'black')
|
|
1009
1228
|
* @returns {Object} Validation result { valid, errors, warnings }
|
|
1010
1229
|
*/
|
|
1011
1230
|
function validateConfig(clips, options = {}) {
|
|
@@ -1091,6 +1310,5 @@ module.exports = {
|
|
|
1091
1310
|
formatValidationResult,
|
|
1092
1311
|
ValidationCodes,
|
|
1093
1312
|
isValidFFmpegColor,
|
|
1094
|
-
normalizeFillGaps,
|
|
1095
1313
|
FFMPEG_NAMED_COLORS,
|
|
1096
1314
|
};
|
|
@@ -11,7 +11,9 @@ function buildAudioForVideoClips(project, videoClips, transitionOffsets) {
|
|
|
11
11
|
|
|
12
12
|
videoClips.forEach((clip) => {
|
|
13
13
|
if (!clip.hasAudio) return;
|
|
14
|
-
const inputIndex = project.
|
|
14
|
+
const inputIndex = project._inputIndexMap
|
|
15
|
+
? project._inputIndexMap.get(clip)
|
|
16
|
+
: project.videoOrAudioClips.indexOf(clip);
|
|
15
17
|
const requestedDuration = Math.max(
|
|
16
18
|
0,
|
|
17
19
|
(clip.end || 0) - (clip.position || 0)
|
|
@@ -29,7 +29,9 @@ function buildBackgroundMusicMix(
|
|
|
29
29
|
let filter = "";
|
|
30
30
|
const bgLabels = [];
|
|
31
31
|
backgroundClips.forEach((clip, i) => {
|
|
32
|
-
const inputIndex = project.
|
|
32
|
+
const inputIndex = project._inputIndexMap
|
|
33
|
+
? project._inputIndexMap.get(clip)
|
|
34
|
+
: project.videoOrAudioClips.indexOf(clip);
|
|
33
35
|
const effectivePosition =
|
|
34
36
|
typeof clip.position === "number" ? clip.position : 0;
|
|
35
37
|
const effectiveEnd =
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
function formatNumber(value, decimals = 6) {
|
|
2
|
+
return Number(value.toFixed(decimals)).toString();
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function clamp(value, min, max) {
|
|
6
|
+
return Math.min(max, Math.max(min, value));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function buildProcessedEffectFilter(effectClip, inputLabel, outputLabel) {
|
|
10
|
+
const params = effectClip.params || {};
|
|
11
|
+
const amount = clamp(
|
|
12
|
+
typeof params.amount === "number" ? params.amount : 1,
|
|
13
|
+
0,
|
|
14
|
+
1
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
if (effectClip.effect === "vignette") {
|
|
18
|
+
const angle =
|
|
19
|
+
typeof params.angle === "number" ? params.angle : Math.PI / 5;
|
|
20
|
+
return {
|
|
21
|
+
filter: `${inputLabel}vignette=angle=${formatNumber(angle)}:eval=frame${outputLabel};`,
|
|
22
|
+
amount,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (effectClip.effect === "filmGrain") {
|
|
27
|
+
const grainStrength = clamp(
|
|
28
|
+
(typeof params.amount === "number" ? params.amount : 0.35) * 100,
|
|
29
|
+
0,
|
|
30
|
+
100
|
|
31
|
+
);
|
|
32
|
+
const flags = params.temporal === false ? "u" : "t+u";
|
|
33
|
+
return {
|
|
34
|
+
filter: `${inputLabel}noise=alls=${formatNumber(
|
|
35
|
+
grainStrength,
|
|
36
|
+
3
|
|
37
|
+
)}:allf=${flags}${outputLabel};`,
|
|
38
|
+
amount,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (effectClip.effect === "gaussianBlur") {
|
|
43
|
+
const sigma = clamp(
|
|
44
|
+
typeof params.sigma === "number"
|
|
45
|
+
? params.sigma
|
|
46
|
+
: (typeof params.amount === "number" ? params.amount : 0.5) * 20,
|
|
47
|
+
0,
|
|
48
|
+
100
|
|
49
|
+
);
|
|
50
|
+
return {
|
|
51
|
+
filter: `${inputLabel}gblur=sigma=${formatNumber(sigma, 4)}${outputLabel};`,
|
|
52
|
+
amount,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// colorAdjust
|
|
57
|
+
const brightness =
|
|
58
|
+
typeof params.brightness === "number" ? params.brightness : 0;
|
|
59
|
+
const contrast = typeof params.contrast === "number" ? params.contrast : 1;
|
|
60
|
+
const saturation =
|
|
61
|
+
typeof params.saturation === "number" ? params.saturation : 1;
|
|
62
|
+
const gamma = typeof params.gamma === "number" ? params.gamma : 1;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
filter:
|
|
66
|
+
`${inputLabel}eq=` +
|
|
67
|
+
`brightness=${formatNumber(brightness, 4)}:` +
|
|
68
|
+
`contrast=${formatNumber(contrast, 4)}:` +
|
|
69
|
+
`saturation=${formatNumber(saturation, 4)}:` +
|
|
70
|
+
`gamma=${formatNumber(gamma, 4)}` +
|
|
71
|
+
`${outputLabel};`,
|
|
72
|
+
amount,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildEffectFilters(effectClips, inputLabel) {
|
|
77
|
+
if (!Array.isArray(effectClips) || effectClips.length === 0) {
|
|
78
|
+
return { filter: "", finalVideoLabel: inputLabel };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const ordered = [...effectClips]
|
|
82
|
+
.filter((c) => typeof c.position === "number" && typeof c.end === "number")
|
|
83
|
+
.sort((a, b) => {
|
|
84
|
+
if (a.position !== b.position) return a.position - b.position;
|
|
85
|
+
return a.end - b.end;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (ordered.length === 0) {
|
|
89
|
+
return { filter: "", finalVideoLabel: inputLabel };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let filter = "";
|
|
93
|
+
let currentLabel = inputLabel;
|
|
94
|
+
|
|
95
|
+
ordered.forEach((clip, i) => {
|
|
96
|
+
const baseLabel = `[fxbase${i}]`;
|
|
97
|
+
const procSrcLabel = `[fxsrc${i}]`;
|
|
98
|
+
const fxLabel = `[fxraw${i}]`;
|
|
99
|
+
const fxAlphaLabel = `[fxa${i}]`;
|
|
100
|
+
const outLabel = `[fxout${i}]`;
|
|
101
|
+
|
|
102
|
+
// Split current frame stream into base + processed branches so compositing
|
|
103
|
+
// is deterministic and frame-aligned across all effects.
|
|
104
|
+
filter += `${currentLabel}split=2${baseLabel}${procSrcLabel};`;
|
|
105
|
+
|
|
106
|
+
const { filter: fxFilter, amount } = buildProcessedEffectFilter(
|
|
107
|
+
clip,
|
|
108
|
+
procSrcLabel,
|
|
109
|
+
fxLabel
|
|
110
|
+
);
|
|
111
|
+
filter += fxFilter;
|
|
112
|
+
|
|
113
|
+
const start = formatNumber(clip.position || 0, 4);
|
|
114
|
+
const end = formatNumber(clip.end || 0, 4);
|
|
115
|
+
const fadeIn = Math.max(0, clip.fadeIn || 0);
|
|
116
|
+
const fadeOut = Math.max(0, clip.fadeOut || 0);
|
|
117
|
+
const fadeOutStart = Math.max(clip.position || 0, (clip.end || 0) - fadeOut);
|
|
118
|
+
|
|
119
|
+
let alphaChain = `format=rgba,colorchannelmixer=aa=${formatNumber(amount, 4)}`;
|
|
120
|
+
if (fadeIn > 0) {
|
|
121
|
+
alphaChain += `,fade=t=in:st=${start}:d=${formatNumber(fadeIn, 4)}:alpha=1`;
|
|
122
|
+
}
|
|
123
|
+
if (fadeOut > 0) {
|
|
124
|
+
alphaChain += `,fade=t=out:st=${formatNumber(
|
|
125
|
+
fadeOutStart,
|
|
126
|
+
4
|
|
127
|
+
)}:d=${formatNumber(fadeOut, 4)}:alpha=1`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
filter += `${fxLabel}${alphaChain}${fxAlphaLabel};`;
|
|
131
|
+
filter += `${baseLabel}${fxAlphaLabel}overlay=shortest=1:eof_action=pass:enable='between(t,${start},${end})'${outLabel};`;
|
|
132
|
+
currentLabel = outLabel;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return { filter, finalVideoLabel: currentLabel };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = { buildEffectFilters };
|