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.
Files changed (42) hide show
  1. package/launch-videos/07-ugc-weight-loss.tsx +2 -6
  2. package/package.json +1 -1
  3. package/src/ai-sdk/file.ts +30 -0
  4. package/src/ai-sdk/providers/editly/editly.test.ts +240 -59
  5. package/src/ai-sdk/providers/editly/index.ts +74 -17
  6. package/src/ai-sdk/providers/editly/layers.ts +39 -7
  7. package/src/ai-sdk/providers/editly/rendi/index.ts +7 -0
  8. package/src/ai-sdk/providers/editly/rendi/rendi.test.ts +26 -0
  9. package/src/ai-sdk/providers/editly/types.ts +2 -0
  10. package/src/cli/commands/frame.tsx +1 -1
  11. package/src/cli/commands/render.tsx +1 -1
  12. package/src/cli/commands/storyboard.tsx +1 -1
  13. package/src/react/elements.ts +0 -9
  14. package/src/react/examples/character-video.tsx +1 -1
  15. package/src/react/examples/grid.tsx +1 -1
  16. package/src/react/examples/quickstart-test.tsx +1 -1
  17. package/src/react/examples/split-layout-demo.tsx +6 -6
  18. package/src/react/examples/split.tsx +3 -3
  19. package/src/react/examples/video-grid.tsx +1 -1
  20. package/src/react/index.ts +5 -2
  21. package/src/react/layouts/grid.tsx +4 -0
  22. package/src/react/layouts/index.ts +1 -1
  23. package/src/react/layouts/layouts.test.ts +345 -0
  24. package/src/react/layouts/split.tsx +1 -18
  25. package/src/react/react.test.ts +46 -6
  26. package/src/react/render.ts +4 -4
  27. package/src/react/renderers/cache.test.ts +1 -0
  28. package/src/react/renderers/clip.ts +11 -15
  29. package/src/react/renderers/context.ts +1 -0
  30. package/src/react/renderers/image.ts +12 -1
  31. package/src/react/renderers/index.ts +0 -1
  32. package/src/react/renderers/music.ts +12 -2
  33. package/src/react/renderers/render.ts +9 -4
  34. package/src/react/renderers/speech.ts +12 -2
  35. package/src/react/renderers/video.ts +14 -2
  36. package/src/react/types.ts +13 -0
  37. package/src/react/warnings.test.ts +95 -0
  38. package/src/studio/step-renderer.ts +1 -0
  39. package/src/react/renderers/split.ts +0 -118
  40. package/test-slot-grid.tsx +0 -19
  41. package/test-slot-userland.tsx +0 -30
  42. package/test-sync-v2.tsx +0 -29
@@ -16,7 +16,7 @@ import {
16
16
  Music,
17
17
  Render,
18
18
  Speech,
19
- SplitLayout,
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
- <SplitLayout
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
@@ -69,7 +69,7 @@
69
69
  "sharp": "^0.34.5",
70
70
  "zod": "^4.2.1"
71
71
  },
72
- "version": "0.4.0-alpha49",
72
+ "version": "0.4.0-alpha50",
73
73
  "exports": {
74
74
  ".": "./src/index.ts",
75
75
  "./ai": "./src/ai-sdk/index.ts",
@@ -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("image pan left/right", async () => {
276
- const outPath = "output/editly-test-image-pan.mp4";
277
- if (existsSync(outPath)) unlinkSync(outPath);
278
-
279
- await editly({
280
- outPath,
281
- width: 1280,
282
- height: 720,
283
- fps: 30,
284
- clips: [
285
- {
286
- duration: 3,
287
- layers: [
288
- {
289
- type: "image",
290
- path: "media/cyberpunk-street.png",
291
- zoomDirection: "left",
292
- zoomAmount: 0.15,
293
- resizeMode: "contain",
294
- },
295
- ],
296
- transition: { name: "fade", duration: 0.5 },
297
- },
298
- {
299
- duration: 3,
300
- layers: [
301
- {
302
- type: "image",
303
- path: "media/cyberpunk-street.png",
304
- zoomDirection: "right",
305
- zoomAmount: 0.15,
306
- resizeMode: "contain",
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
- expect(existsSync(outPath)).toBe(true);
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("contain-blur resize mode for video", async () => {
910
- const outPath = "output/editly-test-contain-blur-video.mp4";
911
- if (existsSync(outPath)) unlinkSync(outPath);
912
-
913
- await editly({
914
- outPath,
915
- width: 1080,
916
- height: 1920,
917
- fps: 30,
918
- clips: [
919
- {
920
- duration: 3,
921
- layers: [
922
- { type: "video", path: VIDEO_1, resizeMode: "contain-blur" },
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
- expect(existsSync(outPath)).toBe(true);
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
- _clipIndex: number,
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
- if (layer && isVideoOverlayLayer(layer)) {
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 existing = overlays.get(videoLayer.path);
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(videoLayer.path, {
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 existing = overlays.get(imgLayer.path);
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(imgLayer.path, {
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 [path] of continuousVideoOverlays) {
589
- videoOverlayInputMap.set(path, overlayInputs.length);
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 [path] of imageOverlays) {
594
- imageOverlayInputMap.set(path, overlayInputs.length);
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 [path, { layer }] of continuousVideoOverlays) {
689
- const inputIndex = videoOverlayInputMap.get(path);
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 [path, { layer }] of imageOverlays) {
728
- const inputIndex = imageOverlayInputMap.get(path);
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
- filters.push(
100
- `scale=${layerWidth}:${layerHeight}:force_original_aspect_ratio=decrease`,
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 scaleExpr = layer.height
506
- ? `scale=${targetWidth}:${parseSize(layer.height, height)}`
507
- : `scale=${targetWidth}:-2`;
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