vargai 0.4.0-alpha49 → 0.4.0-alpha50
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/launch-videos/07-ugc-weight-loss.tsx +2 -6
- package/package.json +1 -1
- package/src/ai-sdk/file.ts +30 -0
- package/src/ai-sdk/providers/editly/editly.test.ts +240 -59
- package/src/ai-sdk/providers/editly/index.ts +74 -17
- package/src/ai-sdk/providers/editly/layers.ts +39 -7
- package/src/ai-sdk/providers/editly/rendi/index.ts +7 -0
- package/src/ai-sdk/providers/editly/rendi/rendi.test.ts +26 -0
- package/src/ai-sdk/providers/editly/types.ts +2 -0
- package/src/cli/commands/frame.tsx +1 -1
- package/src/cli/commands/render.tsx +1 -1
- package/src/cli/commands/storyboard.tsx +1 -1
- package/src/react/elements.ts +0 -9
- package/src/react/examples/character-video.tsx +1 -1
- package/src/react/examples/grid.tsx +1 -1
- package/src/react/examples/quickstart-test.tsx +1 -1
- package/src/react/examples/split-layout-demo.tsx +6 -6
- package/src/react/examples/split.tsx +3 -3
- package/src/react/examples/video-grid.tsx +1 -1
- package/src/react/index.ts +5 -2
- package/src/react/layouts/grid.tsx +4 -0
- package/src/react/layouts/index.ts +1 -1
- package/src/react/layouts/layouts.test.ts +345 -0
- package/src/react/layouts/split.tsx +1 -18
- package/src/react/react.test.ts +46 -6
- package/src/react/render.ts +4 -4
- package/src/react/renderers/cache.test.ts +1 -0
- package/src/react/renderers/clip.ts +11 -15
- package/src/react/renderers/context.ts +1 -0
- package/src/react/renderers/image.ts +12 -1
- package/src/react/renderers/index.ts +0 -1
- package/src/react/renderers/music.ts +12 -2
- package/src/react/renderers/render.ts +9 -4
- package/src/react/renderers/speech.ts +12 -2
- package/src/react/renderers/video.ts +14 -2
- package/src/react/types.ts +13 -0
- package/src/react/warnings.test.ts +95 -0
- package/src/studio/step-renderer.ts +1 -0
- package/src/react/renderers/split.ts +0 -118
- package/test-slot-grid.tsx +0 -19
- package/test-slot-userland.tsx +0 -30
- package/test-sync-v2.tsx +0 -29
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
Music,
|
|
17
17
|
Render,
|
|
18
18
|
Speech,
|
|
19
|
-
|
|
19
|
+
Split,
|
|
20
20
|
Title,
|
|
21
21
|
Video,
|
|
22
22
|
} from "vargai/react";
|
|
@@ -114,11 +114,7 @@ export default (
|
|
|
114
114
|
{/* Main clip with split screen */}
|
|
115
115
|
<Clip duration={5}>
|
|
116
116
|
{/* Split layout - before on left, after on right */}
|
|
117
|
-
<
|
|
118
|
-
direction="horizontal"
|
|
119
|
-
left={beforeVideo}
|
|
120
|
-
right={afterVideo}
|
|
121
|
-
/>
|
|
117
|
+
<Split direction="horizontal">{[beforeVideo, afterVideo]}</Split>
|
|
122
118
|
|
|
123
119
|
{/* Title at top */}
|
|
124
120
|
<Title position="top" color="#ffffff">
|
package/package.json
CHANGED
package/src/ai-sdk/file.ts
CHANGED
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
import type { ImageModelV3File } from "@ai-sdk/provider";
|
|
2
2
|
import type { StorageProvider } from "./storage/types";
|
|
3
3
|
|
|
4
|
+
/** Type of generated content */
|
|
5
|
+
export type GeneratedFileType =
|
|
6
|
+
| "image"
|
|
7
|
+
| "video"
|
|
8
|
+
| "speech"
|
|
9
|
+
| "music"
|
|
10
|
+
| "captions";
|
|
11
|
+
|
|
12
|
+
/** Metadata for AI-generated files */
|
|
13
|
+
export interface FileMetadata {
|
|
14
|
+
/** Type of generated content */
|
|
15
|
+
type?: GeneratedFileType;
|
|
16
|
+
/** Model used to generate */
|
|
17
|
+
model?: string;
|
|
18
|
+
/** Original prompt used */
|
|
19
|
+
prompt?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
4
22
|
export class File {
|
|
5
23
|
private _data: Uint8Array | null = null;
|
|
6
24
|
private _url: string | null = null;
|
|
7
25
|
private _mediaType: string;
|
|
8
26
|
private _loader: (() => Promise<Uint8Array>) | null = null;
|
|
27
|
+
private _metadata: FileMetadata = {};
|
|
9
28
|
|
|
10
29
|
private constructor(
|
|
11
30
|
options:
|
|
@@ -127,6 +146,17 @@ export class File {
|
|
|
127
146
|
return this._url;
|
|
128
147
|
}
|
|
129
148
|
|
|
149
|
+
/** Get file metadata (type, model, prompt) */
|
|
150
|
+
get metadata(): FileMetadata {
|
|
151
|
+
return this._metadata;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Set metadata and return this for chaining */
|
|
155
|
+
withMetadata(metadata: FileMetadata): this {
|
|
156
|
+
this._metadata = { ...this._metadata, ...metadata };
|
|
157
|
+
return this;
|
|
158
|
+
}
|
|
159
|
+
|
|
130
160
|
async data(): Promise<Uint8Array> {
|
|
131
161
|
if (this._data) return this._data;
|
|
132
162
|
if (this._loader) {
|
|
@@ -272,46 +272,50 @@ describe("editly", () => {
|
|
|
272
272
|
expect(existsSync(outPath)).toBe(true);
|
|
273
273
|
});
|
|
274
274
|
|
|
275
|
-
test(
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
275
|
+
test(
|
|
276
|
+
"image pan left/right",
|
|
277
|
+
async () => {
|
|
278
|
+
const outPath = "output/editly-test-image-pan.mp4";
|
|
279
|
+
if (existsSync(outPath)) unlinkSync(outPath);
|
|
280
|
+
|
|
281
|
+
await editly({
|
|
282
|
+
outPath,
|
|
283
|
+
width: 1280,
|
|
284
|
+
height: 720,
|
|
285
|
+
fps: 30,
|
|
286
|
+
clips: [
|
|
287
|
+
{
|
|
288
|
+
duration: 3,
|
|
289
|
+
layers: [
|
|
290
|
+
{
|
|
291
|
+
type: "image",
|
|
292
|
+
path: "media/cyberpunk-street.png",
|
|
293
|
+
zoomDirection: "left",
|
|
294
|
+
zoomAmount: 0.15,
|
|
295
|
+
resizeMode: "contain",
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
transition: { name: "fade", duration: 0.5 },
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
duration: 3,
|
|
302
|
+
layers: [
|
|
303
|
+
{
|
|
304
|
+
type: "image",
|
|
305
|
+
path: "media/cyberpunk-street.png",
|
|
306
|
+
zoomDirection: "right",
|
|
307
|
+
zoomAmount: 0.15,
|
|
308
|
+
resizeMode: "contain",
|
|
309
|
+
},
|
|
310
|
+
],
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
});
|
|
312
314
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
+
expect(existsSync(outPath)).toBe(true);
|
|
316
|
+
},
|
|
317
|
+
{ timeout: 10000 },
|
|
318
|
+
);
|
|
315
319
|
|
|
316
320
|
test("title with custom font", async () => {
|
|
317
321
|
const outPath = "output/editly-test-title-font.mp4";
|
|
@@ -906,27 +910,31 @@ describe("editly", () => {
|
|
|
906
910
|
expect(existsSync(outPath)).toBe(true);
|
|
907
911
|
});
|
|
908
912
|
|
|
909
|
-
test(
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
913
|
+
test(
|
|
914
|
+
"contain-blur resize mode for video",
|
|
915
|
+
async () => {
|
|
916
|
+
const outPath = "output/editly-test-contain-blur-video.mp4";
|
|
917
|
+
if (existsSync(outPath)) unlinkSync(outPath);
|
|
918
|
+
|
|
919
|
+
await editly({
|
|
920
|
+
outPath,
|
|
921
|
+
width: 1080,
|
|
922
|
+
height: 1920,
|
|
923
|
+
fps: 30,
|
|
924
|
+
clips: [
|
|
925
|
+
{
|
|
926
|
+
duration: 3,
|
|
927
|
+
layers: [
|
|
928
|
+
{ type: "video", path: VIDEO_1, resizeMode: "contain-blur" },
|
|
929
|
+
],
|
|
930
|
+
},
|
|
931
|
+
],
|
|
932
|
+
});
|
|
927
933
|
|
|
928
|
-
|
|
929
|
-
|
|
934
|
+
expect(existsSync(outPath)).toBe(true);
|
|
935
|
+
},
|
|
936
|
+
{ timeout: 10000 },
|
|
937
|
+
);
|
|
930
938
|
|
|
931
939
|
test("contain-blur resize mode for image", async () => {
|
|
932
940
|
const outPath = "output/editly-test-contain-blur-image.mp4";
|
|
@@ -1153,4 +1161,177 @@ describe("editly", () => {
|
|
|
1153
1161
|
expect(info.height).toBe(1920);
|
|
1154
1162
|
expect(info.duration).toBeCloseTo(3, 0);
|
|
1155
1163
|
});
|
|
1164
|
+
|
|
1165
|
+
// Regression tests for grid/split layout bugs
|
|
1166
|
+
// https://github.com/vargHQ/sdk/issues/61
|
|
1167
|
+
// https://github.com/vargHQ/sdk/issues/62
|
|
1168
|
+
|
|
1169
|
+
test("issue #61: same video in multiple grid positions renders both", async () => {
|
|
1170
|
+
// Bug: when same video file is used in multiple positions within a clip,
|
|
1171
|
+
// only one instance renders because collectContinuousVideoOverlays uses
|
|
1172
|
+
// the video path as Map key, causing deduplication.
|
|
1173
|
+
//
|
|
1174
|
+
// Fix: use composite key including position (path:left:top:width:height)
|
|
1175
|
+
const outPath = "output/editly-test-issue-61-grid-dedup.mp4";
|
|
1176
|
+
if (existsSync(outPath)) unlinkSync(outPath);
|
|
1177
|
+
|
|
1178
|
+
await editly({
|
|
1179
|
+
outPath,
|
|
1180
|
+
width: 1920,
|
|
1181
|
+
height: 1080,
|
|
1182
|
+
fps: 30,
|
|
1183
|
+
clips: [
|
|
1184
|
+
{
|
|
1185
|
+
duration: 3,
|
|
1186
|
+
layers: [
|
|
1187
|
+
{ type: "fill-color", color: "#000000" },
|
|
1188
|
+
{
|
|
1189
|
+
type: "video",
|
|
1190
|
+
path: VIDEO_1,
|
|
1191
|
+
width: "50%",
|
|
1192
|
+
height: "100%",
|
|
1193
|
+
left: "0%",
|
|
1194
|
+
top: "0%",
|
|
1195
|
+
resizeMode: "cover",
|
|
1196
|
+
},
|
|
1197
|
+
{
|
|
1198
|
+
type: "video",
|
|
1199
|
+
path: VIDEO_1,
|
|
1200
|
+
width: "50%",
|
|
1201
|
+
height: "100%",
|
|
1202
|
+
left: "50%",
|
|
1203
|
+
top: "0%",
|
|
1204
|
+
resizeMode: "cover",
|
|
1205
|
+
},
|
|
1206
|
+
],
|
|
1207
|
+
},
|
|
1208
|
+
],
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
expect(existsSync(outPath)).toBe(true);
|
|
1212
|
+
const info = await ffprobe(outPath);
|
|
1213
|
+
expect(info.width).toBe(1920);
|
|
1214
|
+
expect(info.height).toBe(1080);
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
test("issue #62: positioned videos in clip don't bleed into other clips", async () => {
|
|
1218
|
+
// Bug: positioned videos from Grid/Split are treated as continuous overlays
|
|
1219
|
+
// spanning entire video instead of being scoped to their clip.
|
|
1220
|
+
// Fix: clip-local overlays (no cutFrom/cutTo) vs continuous (has cutFrom/cutTo)
|
|
1221
|
+
const outPath = "output/editly-test-issue-62-clip-scope.mp4";
|
|
1222
|
+
if (existsSync(outPath)) unlinkSync(outPath);
|
|
1223
|
+
|
|
1224
|
+
await editly({
|
|
1225
|
+
outPath,
|
|
1226
|
+
width: 1920,
|
|
1227
|
+
height: 1080,
|
|
1228
|
+
fps: 30,
|
|
1229
|
+
clips: [
|
|
1230
|
+
{
|
|
1231
|
+
duration: 2,
|
|
1232
|
+
layers: [{ type: "fill-color", color: "#ff0000" }],
|
|
1233
|
+
transition: { name: "fade", duration: 0.3 },
|
|
1234
|
+
},
|
|
1235
|
+
{
|
|
1236
|
+
duration: 2,
|
|
1237
|
+
layers: [
|
|
1238
|
+
{ type: "fill-color", color: "#ff0000" },
|
|
1239
|
+
{
|
|
1240
|
+
type: "video",
|
|
1241
|
+
path: VIDEO_1,
|
|
1242
|
+
width: "50%",
|
|
1243
|
+
height: "100%",
|
|
1244
|
+
left: "0%",
|
|
1245
|
+
top: "0%",
|
|
1246
|
+
resizeMode: "cover",
|
|
1247
|
+
},
|
|
1248
|
+
{
|
|
1249
|
+
type: "video",
|
|
1250
|
+
path: VIDEO_2,
|
|
1251
|
+
width: "50%",
|
|
1252
|
+
height: "100%",
|
|
1253
|
+
left: "50%",
|
|
1254
|
+
top: "0%",
|
|
1255
|
+
resizeMode: "cover",
|
|
1256
|
+
},
|
|
1257
|
+
],
|
|
1258
|
+
transition: { name: "fade", duration: 0.3 },
|
|
1259
|
+
},
|
|
1260
|
+
{
|
|
1261
|
+
duration: 2,
|
|
1262
|
+
layers: [{ type: "fill-color", color: "#0000ff" }],
|
|
1263
|
+
},
|
|
1264
|
+
],
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
expect(existsSync(outPath)).toBe(true);
|
|
1268
|
+
const info = await ffprobe(outPath);
|
|
1269
|
+
expect(info.duration).toBeCloseTo(5.4, 0);
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
test("issue #62: continuous overlay (with cutFrom/cutTo) spans clips correctly", async () => {
|
|
1273
|
+
const outPath = "output/editly-test-continuous-overlay-spans.mp4";
|
|
1274
|
+
if (existsSync(outPath)) unlinkSync(outPath);
|
|
1275
|
+
|
|
1276
|
+
await editly({
|
|
1277
|
+
outPath,
|
|
1278
|
+
width: 1920,
|
|
1279
|
+
height: 1080,
|
|
1280
|
+
fps: 30,
|
|
1281
|
+
clips: [
|
|
1282
|
+
{
|
|
1283
|
+
duration: 2,
|
|
1284
|
+
layers: [
|
|
1285
|
+
{ type: "fill-color", color: "#ff0000" },
|
|
1286
|
+
{
|
|
1287
|
+
type: "video",
|
|
1288
|
+
path: VIDEO_TALKING,
|
|
1289
|
+
width: "25%",
|
|
1290
|
+
height: "25%",
|
|
1291
|
+
left: "73%",
|
|
1292
|
+
top: "2%",
|
|
1293
|
+
cutFrom: 0,
|
|
1294
|
+
cutTo: 2,
|
|
1295
|
+
},
|
|
1296
|
+
],
|
|
1297
|
+
transition: { name: "fade", duration: 0.3 },
|
|
1298
|
+
},
|
|
1299
|
+
{
|
|
1300
|
+
duration: 2,
|
|
1301
|
+
layers: [
|
|
1302
|
+
{ type: "fill-color", color: "#00ff00" },
|
|
1303
|
+
{
|
|
1304
|
+
type: "video",
|
|
1305
|
+
path: VIDEO_TALKING,
|
|
1306
|
+
width: "25%",
|
|
1307
|
+
height: "25%",
|
|
1308
|
+
left: "73%",
|
|
1309
|
+
top: "2%",
|
|
1310
|
+
cutFrom: 2,
|
|
1311
|
+
cutTo: 4,
|
|
1312
|
+
},
|
|
1313
|
+
],
|
|
1314
|
+
transition: { name: "fade", duration: 0.3 },
|
|
1315
|
+
},
|
|
1316
|
+
{
|
|
1317
|
+
duration: 2,
|
|
1318
|
+
layers: [
|
|
1319
|
+
{ type: "fill-color", color: "#0000ff" },
|
|
1320
|
+
{
|
|
1321
|
+
type: "video",
|
|
1322
|
+
path: VIDEO_TALKING,
|
|
1323
|
+
width: "25%",
|
|
1324
|
+
height: "25%",
|
|
1325
|
+
left: "73%",
|
|
1326
|
+
top: "2%",
|
|
1327
|
+
cutFrom: 4,
|
|
1328
|
+
cutTo: 6,
|
|
1329
|
+
},
|
|
1330
|
+
],
|
|
1331
|
+
},
|
|
1332
|
+
],
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
expect(existsSync(outPath)).toBe(true);
|
|
1336
|
+
});
|
|
1156
1337
|
});
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
getSlideInTextFilter,
|
|
8
8
|
getSubtitleFilter,
|
|
9
9
|
getTitleFilter,
|
|
10
|
+
getVideoFilter,
|
|
10
11
|
getVideoFilterWithTrim,
|
|
11
12
|
processLayer,
|
|
12
13
|
} from "./layers";
|
|
@@ -142,6 +143,26 @@ function isVideoOverlayLayer(layer: Layer): boolean {
|
|
|
142
143
|
);
|
|
143
144
|
}
|
|
144
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Clip-local overlay: has positioning but NO cutFrom/cutTo
|
|
148
|
+
* These should be composited within their clip, not across the entire video
|
|
149
|
+
*/
|
|
150
|
+
function isClipLocalVideoOverlay(layer: Layer): boolean {
|
|
151
|
+
if (!isVideoOverlayLayer(layer)) return false;
|
|
152
|
+
const v = layer as VideoLayer;
|
|
153
|
+
return v.cutFrom === undefined && v.cutTo === undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Continuous overlay: has positioning AND cutFrom/cutTo
|
|
158
|
+
* These span specific time ranges across the video (e.g., from <Overlay> element)
|
|
159
|
+
*/
|
|
160
|
+
function isContinuousVideoOverlay(layer: Layer): boolean {
|
|
161
|
+
if (!isVideoOverlayLayer(layer)) return false;
|
|
162
|
+
const v = layer as VideoLayer;
|
|
163
|
+
return v.cutFrom !== undefined || v.cutTo !== undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
145
166
|
function isImageOverlayLayer(layer: Layer): boolean {
|
|
146
167
|
return layer.type === "image-overlay";
|
|
147
168
|
}
|
|
@@ -161,7 +182,7 @@ function isTextOverlayLayer(layer: Layer): boolean {
|
|
|
161
182
|
|
|
162
183
|
function buildBaseClipFilter(
|
|
163
184
|
clip: ProcessedClip,
|
|
164
|
-
|
|
185
|
+
clipIndex: number,
|
|
165
186
|
width: number,
|
|
166
187
|
height: number,
|
|
167
188
|
inputOffset: number,
|
|
@@ -186,11 +207,14 @@ function buildBaseClipFilter(
|
|
|
186
207
|
let baseLabel = "";
|
|
187
208
|
let inputIdx = inputOffset;
|
|
188
209
|
|
|
189
|
-
// Filter out overlay layers AND text overlay layers (text will be applied after image overlays)
|
|
190
210
|
const baseLayers = clip.layers.filter(
|
|
191
211
|
(l) => l && !isOverlayLayer(l) && !isTextOverlayLayer(l),
|
|
192
212
|
);
|
|
193
213
|
|
|
214
|
+
const clipLocalOverlays = clip.layers.filter(
|
|
215
|
+
(l) => l && isClipLocalVideoOverlay(l),
|
|
216
|
+
) as VideoLayer[];
|
|
217
|
+
|
|
194
218
|
for (let i = 0; i < baseLayers.length; i++) {
|
|
195
219
|
const layer = baseLayers[i];
|
|
196
220
|
if (!layer) continue;
|
|
@@ -228,6 +252,36 @@ function buildBaseClipFilter(
|
|
|
228
252
|
}
|
|
229
253
|
}
|
|
230
254
|
|
|
255
|
+
for (let i = 0; i < clipLocalOverlays.length; i++) {
|
|
256
|
+
const layer = clipLocalOverlays[i];
|
|
257
|
+
if (!layer) continue;
|
|
258
|
+
|
|
259
|
+
const overlayFilter = getVideoFilter(
|
|
260
|
+
layer,
|
|
261
|
+
inputIdx,
|
|
262
|
+
width,
|
|
263
|
+
height,
|
|
264
|
+
clip.duration,
|
|
265
|
+
true,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
inputs.push(layer.path);
|
|
269
|
+
filters.push(overlayFilter.filterComplex);
|
|
270
|
+
|
|
271
|
+
const outputLabel = `clip${clipIndex}ov${i}`;
|
|
272
|
+
const positionFilter = getOverlayFilter(
|
|
273
|
+
baseLabel,
|
|
274
|
+
overlayFilter.outputLabel,
|
|
275
|
+
layer,
|
|
276
|
+
width,
|
|
277
|
+
height,
|
|
278
|
+
outputLabel,
|
|
279
|
+
);
|
|
280
|
+
filters.push(positionFilter);
|
|
281
|
+
baseLabel = outputLabel;
|
|
282
|
+
inputIdx++;
|
|
283
|
+
}
|
|
284
|
+
|
|
231
285
|
return {
|
|
232
286
|
filters,
|
|
233
287
|
inputs,
|
|
@@ -247,13 +301,15 @@ function collectContinuousVideoOverlays(
|
|
|
247
301
|
|
|
248
302
|
for (const clip of clips) {
|
|
249
303
|
for (const layer of clip.layers) {
|
|
250
|
-
|
|
304
|
+
// Only collect continuous overlays (with cutFrom/cutTo), not clip-local ones
|
|
305
|
+
if (layer && isContinuousVideoOverlay(layer)) {
|
|
251
306
|
const videoLayer = layer as VideoLayer;
|
|
252
|
-
const
|
|
307
|
+
const key = `${videoLayer.path}:${videoLayer.left ?? ""}:${videoLayer.top ?? ""}:${videoLayer.width ?? ""}:${videoLayer.height ?? ""}`;
|
|
308
|
+
const existing = overlays.get(key);
|
|
253
309
|
if (existing) {
|
|
254
310
|
existing.totalDuration += clip.duration;
|
|
255
311
|
} else {
|
|
256
|
-
overlays.set(
|
|
312
|
+
overlays.set(key, {
|
|
257
313
|
layer: videoLayer,
|
|
258
314
|
totalDuration: clip.duration,
|
|
259
315
|
});
|
|
@@ -277,11 +333,12 @@ function collectImageOverlays(
|
|
|
277
333
|
for (const layer of clip.layers) {
|
|
278
334
|
if (layer && isImageOverlayLayer(layer)) {
|
|
279
335
|
const imgLayer = layer as ImageOverlayLayer;
|
|
280
|
-
const
|
|
336
|
+
const key = `${imgLayer.path}:${JSON.stringify(imgLayer.position ?? "")}:${imgLayer.width ?? ""}:${imgLayer.height ?? ""}`;
|
|
337
|
+
const existing = overlays.get(key);
|
|
281
338
|
if (existing) {
|
|
282
339
|
existing.totalDuration += clip.duration;
|
|
283
340
|
} else {
|
|
284
|
-
overlays.set(
|
|
341
|
+
overlays.set(key, {
|
|
285
342
|
layer: imgLayer,
|
|
286
343
|
totalDuration: clip.duration,
|
|
287
344
|
});
|
|
@@ -585,14 +642,14 @@ export async function editly(config: EditlyConfig): Promise<EditlyResult> {
|
|
|
585
642
|
const videoOverlayInputMap = new Map<string, number>();
|
|
586
643
|
const imageOverlayInputMap = new Map<string, number>();
|
|
587
644
|
|
|
588
|
-
for (const [
|
|
589
|
-
videoOverlayInputMap.set(
|
|
590
|
-
overlayInputs.push(path);
|
|
645
|
+
for (const [key, { layer }] of continuousVideoOverlays) {
|
|
646
|
+
videoOverlayInputMap.set(key, overlayInputs.length);
|
|
647
|
+
overlayInputs.push(layer.path);
|
|
591
648
|
}
|
|
592
649
|
|
|
593
|
-
for (const [
|
|
594
|
-
imageOverlayInputMap.set(
|
|
595
|
-
overlayInputs.push(path);
|
|
650
|
+
for (const [key, { layer }] of imageOverlays) {
|
|
651
|
+
imageOverlayInputMap.set(key, overlayInputs.length);
|
|
652
|
+
overlayInputs.push(layer.path);
|
|
596
653
|
}
|
|
597
654
|
|
|
598
655
|
const allFilters: string[] = [];
|
|
@@ -685,8 +742,8 @@ export async function editly(config: EditlyConfig): Promise<EditlyResult> {
|
|
|
685
742
|
let currentBase = finalVideoLabel;
|
|
686
743
|
let overlayIdx = 0;
|
|
687
744
|
|
|
688
|
-
for (const [
|
|
689
|
-
const inputIndex = videoOverlayInputMap.get(
|
|
745
|
+
for (const [key, { layer }] of continuousVideoOverlays) {
|
|
746
|
+
const inputIndex = videoOverlayInputMap.get(key);
|
|
690
747
|
if (inputIndex === undefined) continue;
|
|
691
748
|
|
|
692
749
|
const trimmedLabel = `ovfinal${overlayIdx}`;
|
|
@@ -724,8 +781,8 @@ export async function editly(config: EditlyConfig): Promise<EditlyResult> {
|
|
|
724
781
|
let currentBase = finalVideoLabel;
|
|
725
782
|
let imgOverlayIdx = 0;
|
|
726
783
|
|
|
727
|
-
for (const [
|
|
728
|
-
const inputIndex = imageOverlayInputMap.get(
|
|
784
|
+
for (const [key, { layer }] of imageOverlays) {
|
|
785
|
+
const inputIndex = imageOverlayInputMap.get(key);
|
|
729
786
|
if (inputIndex === undefined) continue;
|
|
730
787
|
|
|
731
788
|
const imgFilter = getImageOverlayFilter(
|
|
@@ -63,6 +63,20 @@ function parseSize(val: number | string | undefined, base: number): number {
|
|
|
63
63
|
return Math.round(parseFloat(val));
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
let resizeModeWarningShown = false;
|
|
67
|
+
|
|
68
|
+
function warnNoResizeMode(type: "video" | "image"): void {
|
|
69
|
+
if (resizeModeWarningShown) return;
|
|
70
|
+
resizeModeWarningShown = true;
|
|
71
|
+
console.warn(
|
|
72
|
+
`[varg] Deprecation warning: ${type} layer without resizeMode will change behavior in a future version. ` +
|
|
73
|
+
`Current default stretches/crops to fill frame. Future default will be 'contain' (letterbox with black bars). ` +
|
|
74
|
+
`To preserve current behavior, explicitly set resizeMode: 'cover'. ` +
|
|
75
|
+
`For letterboxing, set resizeMode: 'contain'. ` +
|
|
76
|
+
`See: https://github.com/vargHQ/sdk/issues/24`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
66
80
|
export interface FilterInput {
|
|
67
81
|
label: string;
|
|
68
82
|
path?: string;
|
|
@@ -96,9 +110,14 @@ export function getVideoFilter(
|
|
|
96
110
|
const layerHeight = parseSize(layer.height, height);
|
|
97
111
|
|
|
98
112
|
if (isOverlay) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
113
|
+
let scaleFilter = `scale=${layerWidth}:${layerHeight}:force_original_aspect_ratio=decrease`;
|
|
114
|
+
if (layer.resizeMode === "cover") {
|
|
115
|
+
const { x, y } = getCropPositionExpr(layer.cropPosition);
|
|
116
|
+
scaleFilter = `scale=${layerWidth}:${layerHeight}:force_original_aspect_ratio=increase,crop=${layerWidth}:${layerHeight}:${x}:${y}`;
|
|
117
|
+
} else if (layer.resizeMode === "stretch") {
|
|
118
|
+
scaleFilter = `scale=${layerWidth}:${layerHeight}`;
|
|
119
|
+
}
|
|
120
|
+
filters.push(scaleFilter);
|
|
102
121
|
filters.push("setsar=1");
|
|
103
122
|
filters.push("fps=30");
|
|
104
123
|
filters.push("settb=1/30");
|
|
@@ -333,6 +352,9 @@ export function getImageFilter(
|
|
|
333
352
|
filters.push(`pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`);
|
|
334
353
|
} else {
|
|
335
354
|
// Default: fast path - zoompan at target resolution directly
|
|
355
|
+
// WARNING: This path uses cover-like behavior (may crop). In a future version,
|
|
356
|
+
// the default will change to 'contain' (letterbox). See issue #24.
|
|
357
|
+
warnNoResizeMode("image");
|
|
336
358
|
filters.push(
|
|
337
359
|
`scale=${zoomWidth}:${zoomHeight}:force_original_aspect_ratio=increase`,
|
|
338
360
|
);
|
|
@@ -502,9 +524,20 @@ export function getImageOverlayFilter(
|
|
|
502
524
|
const targetWidth = layer.width
|
|
503
525
|
? parseSize(layer.width, width)
|
|
504
526
|
: Math.round(width * 0.3);
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
527
|
+
const hasExplicitHeight = layer.height !== undefined;
|
|
528
|
+
const targetHeight = hasExplicitHeight ? parseSize(layer.height, height) : -2;
|
|
529
|
+
|
|
530
|
+
let scaleExpr: string;
|
|
531
|
+
if (!hasExplicitHeight) {
|
|
532
|
+
scaleExpr = `scale=${targetWidth}:-2`;
|
|
533
|
+
} else if (layer.resizeMode === "cover") {
|
|
534
|
+
const { x, y } = getCropPositionExpr(layer.cropPosition);
|
|
535
|
+
scaleExpr = `scale=${targetWidth}:${targetHeight}:force_original_aspect_ratio=increase,crop=${targetWidth}:${targetHeight}:${x}:${y}`;
|
|
536
|
+
} else if (layer.resizeMode === "stretch") {
|
|
537
|
+
scaleExpr = `scale=${targetWidth}:${targetHeight}`;
|
|
538
|
+
} else {
|
|
539
|
+
scaleExpr = `scale=${targetWidth}:${targetHeight}:force_original_aspect_ratio=decrease,pad=${targetWidth}:${targetHeight}:(ow-iw)/2:(oh-ih)/2:black`;
|
|
540
|
+
}
|
|
508
541
|
|
|
509
542
|
const zoomDir = layer.zoomDirection ?? null;
|
|
510
543
|
const zoomAmt = layer.zoomAmount ?? 0.1;
|
|
@@ -532,7 +565,6 @@ export function getImageOverlayFilter(
|
|
|
532
565
|
yExpr = "trunc((ih-ih/zoom)/2)";
|
|
533
566
|
}
|
|
534
567
|
|
|
535
|
-
// Upscale, zoompan at high res, then scale to target preserving aspect ratio
|
|
536
568
|
filters.push("scale=4000:-2");
|
|
537
569
|
filters.push(
|
|
538
570
|
`zoompan=z='${zoomExpr}':x='${xExpr}':y='${yExpr}':d=${totalFrames}:s=4000x4000:fps=30`,
|
|
@@ -131,6 +131,13 @@ export class RendiBackend implements FFmpegBackend {
|
|
|
131
131
|
verbose,
|
|
132
132
|
} = options;
|
|
133
133
|
|
|
134
|
+
if (!inputs || inputs.length === 0) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
"Rendi backend requires at least one input file. " +
|
|
137
|
+
"Ensure your render contains media elements (Video, Image, etc.) with valid sources.",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
134
141
|
const inputFiles: Record<string, string> = {};
|
|
135
142
|
const pathToPlaceholder = new Map<string, string>();
|
|
136
143
|
|