vargai 0.4.0-alpha30 → 0.4.0-alpha32

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.
Binary file
package/package.json CHANGED
@@ -65,10 +65,11 @@
65
65
  "react-dom": "^19.2.0",
66
66
  "remotion": "^4.0.377",
67
67
  "replicate": "^1.4.0",
68
+ "sharp": "^0.34.5",
68
69
  "vargai": "^0.4.0-alpha11",
69
70
  "zod": "^4.2.1"
70
71
  },
71
- "version": "0.4.0-alpha30",
72
+ "version": "0.4.0-alpha32",
72
73
  "exports": {
73
74
  ".": "./src/index.ts",
74
75
  "./ai": "./src/ai-sdk/index.ts",
@@ -0,0 +1,7 @@
1
+ export { LocalBackend, localBackend } from "./local";
2
+ export type {
3
+ FFmpegBackend,
4
+ FFmpegRunOptions,
5
+ FFmpegRunResult,
6
+ VideoInfo,
7
+ } from "./types";
@@ -0,0 +1,65 @@
1
+ import { $ } from "bun";
2
+ import type {
3
+ FFmpegBackend,
4
+ FFmpegRunOptions,
5
+ FFmpegRunResult,
6
+ VideoInfo,
7
+ } from "./types";
8
+
9
+ const FFMPEG_COMMON_ARGS = ["-hide_banner", "-loglevel", "error"];
10
+
11
+ export class LocalBackend implements FFmpegBackend {
12
+ readonly name = "local";
13
+
14
+ async ffprobe(input: string): Promise<VideoInfo> {
15
+ const result =
16
+ await $`ffprobe -v error -show_entries stream=width,height,r_frame_rate,codec_type -show_entries format=duration -of json ${input}`.json();
17
+
18
+ const videoStream = result.streams?.find(
19
+ (s: { codec_type: string }) => s.codec_type === "video",
20
+ );
21
+ const parsedDuration = parseFloat(result.format?.duration ?? "0");
22
+ const duration = Number.isFinite(parsedDuration) ? parsedDuration : 0;
23
+
24
+ let fps: number | undefined;
25
+ const framerateStr: string | undefined = videoStream?.r_frame_rate;
26
+ if (framerateStr) {
27
+ const parts = framerateStr.split("/").map(Number);
28
+ const num = parts[0];
29
+ const den = parts[1];
30
+ if (den && den > 0 && num) fps = num / den;
31
+ }
32
+
33
+ return {
34
+ duration,
35
+ width: videoStream?.width,
36
+ height: videoStream?.height,
37
+ fps,
38
+ framerateStr,
39
+ };
40
+ }
41
+
42
+ async run(options: FFmpegRunOptions): Promise<FFmpegRunResult> {
43
+ const { args, outputPath, verbose } = options;
44
+
45
+ const ffmpegArgs = [
46
+ ...FFMPEG_COMMON_ARGS.slice(0, 2),
47
+ verbose ? "info" : "error",
48
+ ...args,
49
+ ];
50
+
51
+ if (verbose) {
52
+ console.log("ffmpeg", ffmpegArgs.join(" "));
53
+ }
54
+
55
+ const result = await $`ffmpeg ${ffmpegArgs}`.quiet();
56
+
57
+ if (result.exitCode !== 0) {
58
+ throw new Error(`ffmpeg failed with exit code ${result.exitCode}`);
59
+ }
60
+
61
+ return { output: { type: "file", path: outputPath } };
62
+ }
63
+ }
64
+
65
+ export const localBackend = new LocalBackend();
@@ -0,0 +1,54 @@
1
+ /**
2
+ * FFmpeg backend abstraction for dependency injection
3
+ * Allows switching between local ffmpeg and cloud services like Rendi
4
+ */
5
+
6
+ import type { VideoInfo } from "../types";
7
+
8
+ /**
9
+ * Represents the result of running ffprobe
10
+ */
11
+ export type { VideoInfo };
12
+
13
+ /**
14
+ * FFmpeg execution options
15
+ */
16
+ export interface FFmpegRunOptions {
17
+ /** ffmpeg arguments (without the 'ffmpeg' command itself) */
18
+ args: string[];
19
+ /** List of input file paths (local or URLs) */
20
+ inputs: string[];
21
+ /** Output file path */
22
+ outputPath: string;
23
+ /** Enable verbose logging */
24
+ verbose?: boolean;
25
+ }
26
+
27
+ export type FFmpegOutput =
28
+ | { type: "file"; path: string }
29
+ | { type: "url"; url: string };
30
+
31
+ export interface FFmpegRunResult {
32
+ output: FFmpegOutput;
33
+ }
34
+
35
+ /**
36
+ * Backend interface for ffmpeg/ffprobe execution
37
+ */
38
+ export interface FFmpegBackend {
39
+ /** Backend name for identification */
40
+ readonly name: string;
41
+
42
+ /**
43
+ * Run ffprobe to get media file info
44
+ * @param input - File path (local) or URL
45
+ */
46
+ ffprobe(input: string): Promise<VideoInfo>;
47
+
48
+ /**
49
+ * Run ffmpeg command
50
+ * @param options - Execution options including args, inputs, and output path
51
+ * @returns Result with optional URL for cloud backends
52
+ */
53
+ run(options: FFmpegRunOptions): Promise<FFmpegRunResult>;
54
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { existsSync, unlinkSync } from "node:fs";
3
- import { ffprobe } from "./ffmpeg";
3
+ import { localBackend } from "./backends/local";
4
4
  import { editly } from "./index";
5
5
 
6
6
  const VIDEO_1 = "output/sora-landscape.mp4";
@@ -9,6 +9,8 @@ const VIDEO_TALKING = "output/workflow-talking-synced.mp4";
9
9
  const IMAGE_SQUARE = "media/replicate-forest.png";
10
10
  const IMAGE_PORTRAIT = "media/madi-portrait.png";
11
11
 
12
+ const ffprobe = localBackend.ffprobe;
13
+
12
14
  describe("editly", () => {
13
15
  test("requires outPath", async () => {
14
16
  await expect(
@@ -1105,4 +1107,50 @@ describe("editly", () => {
1105
1107
  expect(info.height).toBe(1920);
1106
1108
  expect(info.duration).toBeCloseTo(3, 0);
1107
1109
  });
1110
+
1111
+ test("video overlay with cropPosition", async () => {
1112
+ const outPath = "output/editly-test-crop-position.mp4";
1113
+ if (existsSync(outPath)) unlinkSync(outPath);
1114
+
1115
+ await editly({
1116
+ outPath,
1117
+ width: 1080,
1118
+ height: 1920,
1119
+ fps: 30,
1120
+ clips: [
1121
+ {
1122
+ duration: 3,
1123
+ layers: [
1124
+ { type: "fill-color", color: "#000000" },
1125
+ {
1126
+ type: "video",
1127
+ path: VIDEO_1,
1128
+ width: 1080,
1129
+ height: 960,
1130
+ left: 0,
1131
+ top: 0,
1132
+ resizeMode: "cover",
1133
+ cropPosition: "top",
1134
+ },
1135
+ {
1136
+ type: "video",
1137
+ path: VIDEO_2,
1138
+ width: 1080,
1139
+ height: 960,
1140
+ left: 0,
1141
+ top: 960,
1142
+ resizeMode: "cover",
1143
+ cropPosition: "bottom",
1144
+ },
1145
+ ],
1146
+ },
1147
+ ],
1148
+ });
1149
+
1150
+ expect(existsSync(outPath)).toBe(true);
1151
+ const info = await ffprobe(outPath);
1152
+ expect(info.width).toBe(1080);
1153
+ expect(info.height).toBe(1920);
1154
+ expect(info.duration).toBeCloseTo(3, 0);
1155
+ });
1108
1156
  });
@@ -1,5 +1,4 @@
1
- import { $ } from "bun";
2
- import { ffprobe, multipleOf2 } from "./ffmpeg";
1
+ import { type FFmpegBackend, localBackend } from "./backends";
3
2
  import {
4
3
  getImageOverlayFilter,
5
4
  getImageOverlayPositionFilter,
@@ -18,6 +17,7 @@ import type {
18
17
  Clip,
19
18
  DetachedAudioLayer,
20
19
  EditlyConfig,
20
+ EditlyResult,
21
21
  ImageOverlayLayer,
22
22
  Layer,
23
23
  NewsTitleLayer,
@@ -28,6 +28,7 @@ import type {
28
28
  VideoLayer,
29
29
  } from "./types";
30
30
 
31
+ export * from "./backends";
31
32
  export * from "./types";
32
33
 
33
34
  const DEFAULT_DURATION = 4;
@@ -36,12 +37,22 @@ const DEFAULT_FPS = 30;
36
37
  const DEFAULT_WIDTH = 1280;
37
38
  const DEFAULT_HEIGHT = 720;
38
39
 
39
- async function getVideoDuration(path: string): Promise<number> {
40
- const info = await ffprobe(path);
40
+ function multipleOf2(n: number): number {
41
+ return Math.round(n / 2) * 2;
42
+ }
43
+
44
+ async function getVideoDuration(
45
+ path: string,
46
+ backend: FFmpegBackend,
47
+ ): Promise<number> {
48
+ const info = await backend.ffprobe(path);
41
49
  return info.duration;
42
50
  }
43
51
 
44
- async function getFirstVideoInfo(clips: Clip[]): Promise<{
52
+ async function getFirstVideoInfo(
53
+ clips: Clip[],
54
+ backend: FFmpegBackend,
55
+ ): Promise<{
45
56
  width?: number;
46
57
  height?: number;
47
58
  fps?: number;
@@ -49,7 +60,7 @@ async function getFirstVideoInfo(clips: Clip[]): Promise<{
49
60
  for (const clip of clips) {
50
61
  for (const layer of clip.layers) {
51
62
  if (layer.type === "video") {
52
- const info = await ffprobe((layer as VideoLayer).path);
63
+ const info = await backend.ffprobe((layer as VideoLayer).path);
53
64
  return { width: info.width, height: info.height, fps: info.fps };
54
65
  }
55
66
  }
@@ -72,6 +83,7 @@ function applyLayerDefaults(
72
83
  async function processClips(
73
84
  clips: Clip[],
74
85
  defaults: EditlyConfig["defaults"],
86
+ backend: FFmpegBackend,
75
87
  ): Promise<ProcessedClip[]> {
76
88
  const processed: ProcessedClip[] = [];
77
89
  const defaultDuration = defaults?.duration ?? DEFAULT_DURATION;
@@ -86,7 +98,7 @@ async function processClips(
86
98
  for (const layer of layers) {
87
99
  if (layer.type === "video" && !clip.duration) {
88
100
  const videoLayer = layer as VideoLayer;
89
- const videoDuration = await getVideoDuration(videoLayer.path);
101
+ const videoDuration = await getVideoDuration(videoLayer.path, backend);
90
102
  const cutFrom = videoLayer.cutFrom ?? 0;
91
103
  const cutTo = videoLayer.cutTo ?? videoDuration;
92
104
  duration = cutTo - cutFrom;
@@ -522,7 +534,7 @@ function buildAudioFilter(
522
534
  };
523
535
  }
524
536
 
525
- export async function editly(config: EditlyConfig): Promise<void> {
537
+ export async function editly(config: EditlyConfig): Promise<EditlyResult> {
526
538
  const {
527
539
  outPath,
528
540
  clips: clipsIn,
@@ -539,11 +551,17 @@ export async function editly(config: EditlyConfig): Promise<void> {
539
551
  fast,
540
552
  } = config;
541
553
 
554
+ const backend: FFmpegBackend = config.backend ?? localBackend;
555
+
556
+ if (verbose) {
557
+ console.log(`[editly] using backend: ${backend.name}`);
558
+ }
559
+
542
560
  if (!clipsIn || clipsIn.length === 0) {
543
561
  throw new Error("At least one clip is required");
544
562
  }
545
563
 
546
- const firstVideoInfo = await getFirstVideoInfo(clipsIn);
564
+ const firstVideoInfo = await getFirstVideoInfo(clipsIn, backend);
547
565
  let width = config.width ?? firstVideoInfo.width ?? DEFAULT_WIDTH;
548
566
  let height = config.height ?? firstVideoInfo.height ?? DEFAULT_HEIGHT;
549
567
  const fps = config.fps ?? firstVideoInfo.fps ?? DEFAULT_FPS;
@@ -561,7 +579,7 @@ export async function editly(config: EditlyConfig): Promise<void> {
561
579
  console.log(`Output: ${width}x${height} @ ${fps}fps`);
562
580
  }
563
581
 
564
- const clips = await processClips(clipsIn, defaults);
582
+ const clips = await processClips(clipsIn, defaults, backend);
565
583
 
566
584
  const continuousVideoOverlays = collectContinuousVideoOverlays(clips);
567
585
  const imageOverlays = collectImageOverlays(clips);
@@ -862,13 +880,18 @@ export async function editly(config: EditlyConfig): Promise<void> {
862
880
  console.log("\nFilter complex:\n", filterComplex.split(";").join(";\n"));
863
881
  }
864
882
 
865
- const result = await $`ffmpeg ${ffmpegArgs}`.quiet();
883
+ const result = await backend.run({
884
+ args: ffmpegArgs,
885
+ inputs: allInputs,
886
+ outputPath: outPath,
887
+ verbose,
888
+ });
866
889
 
867
- if (result.exitCode !== 0) {
868
- throw new Error(`ffmpeg failed with exit code ${result.exitCode}`);
890
+ if (result.output.type === "file" && verbose) {
891
+ console.log(`Output: ${result.output.path}`);
869
892
  }
870
893
 
871
- console.log(`Output: ${outPath}`);
894
+ return { output: result.output };
872
895
  }
873
896
 
874
897
  export default editly;
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ CropPosition,
2
3
  FillColorLayer,
3
4
  ImageLayer,
4
5
  ImageOverlayLayer,
@@ -15,6 +16,33 @@ import type {
15
16
  VideoLayer,
16
17
  } from "./types";
17
18
 
19
+ function getCropPositionExpr(position: CropPosition | undefined): {
20
+ x: string;
21
+ y: string;
22
+ } {
23
+ switch (position) {
24
+ case "top-left":
25
+ return { x: "0", y: "0" };
26
+ case "top":
27
+ return { x: "(iw-ow)/2", y: "0" };
28
+ case "top-right":
29
+ return { x: "iw-ow", y: "0" };
30
+ case "left":
31
+ return { x: "0", y: "(ih-oh)/2" };
32
+ case "right":
33
+ return { x: "iw-ow", y: "(ih-oh)/2" };
34
+ case "bottom-left":
35
+ return { x: "0", y: "ih-oh" };
36
+ case "bottom":
37
+ return { x: "(iw-ow)/2", y: "ih-oh" };
38
+ case "bottom-right":
39
+ return { x: "iw-ow", y: "ih-oh" };
40
+ case "center":
41
+ default:
42
+ return { x: "(iw-ow)/2", y: "(ih-oh)/2" };
43
+ }
44
+ }
45
+
18
46
  function escapeDrawText(text: string): string {
19
47
  return text
20
48
  .replace(/\\/g, "\\\\")
@@ -161,9 +189,14 @@ export function getVideoFilterWithTrim(
161
189
  const layerHeight = parseSize(layer.height, height);
162
190
 
163
191
  if (isOverlay) {
164
- filters.push(
165
- `scale=${layerWidth}:${layerHeight}:force_original_aspect_ratio=decrease`,
166
- );
192
+ let scaleFilter = `scale=${layerWidth}:${layerHeight}:force_original_aspect_ratio=decrease`;
193
+ if (layer.resizeMode === "cover") {
194
+ const { x, y } = getCropPositionExpr(layer.cropPosition);
195
+ scaleFilter = `scale=${layerWidth}:${layerHeight}:force_original_aspect_ratio=increase,crop=${layerWidth}:${layerHeight}:${x}:${y}`;
196
+ } else if (layer.resizeMode === "stretch") {
197
+ scaleFilter = `scale=${layerWidth}:${layerHeight}`;
198
+ }
199
+ filters.push(scaleFilter);
167
200
  filters.push("setsar=1");
168
201
  filters.push("fps=30");
169
202
  filters.push("settb=1/30");
@@ -555,7 +588,13 @@ export function getTitleFilter(
555
588
  ): string {
556
589
  const text = escapeDrawText(layer.text);
557
590
  const color = layer.textColor ?? "white";
558
- const fontSize = Math.round(Math.min(width, height) * 0.08);
591
+
592
+ // Auto-size font to fit within 90% of frame width (same approach as subtitle)
593
+ const maxFontSize = Math.round(Math.min(width, height) * 0.08);
594
+ const maxTextWidth = width * 0.9;
595
+ // Average char width ≈ fontSize * 0.55 for sans-serif fonts
596
+ const fittedFontSize = Math.floor(maxTextWidth / (layer.text.length * 0.55));
597
+ const fontSize = Math.max(16, Math.min(maxFontSize, fittedFontSize));
559
598
 
560
599
  let x = "(w-text_w)/2";
561
600
  let y = "(h-text_h)/2";
@@ -587,7 +626,13 @@ export function getSubtitleFilter(
587
626
  const text = escapeDrawText(layer.text);
588
627
  const textColor = layer.textColor ?? "white";
589
628
  const bgColor = layer.backgroundColor ?? "black@0.7";
590
- const fontSize = Math.round(Math.min(width, height) * 0.05);
629
+
630
+ // Auto-size font to fit within 90% of frame width
631
+ const maxFontSize = Math.round(Math.min(width, height) * 0.05);
632
+ const maxTextWidth = width * 0.9;
633
+ // Average char width ≈ fontSize * 0.55 for sans-serif fonts
634
+ const fittedFontSize = Math.floor(maxTextWidth / (layer.text.length * 0.55));
635
+ const fontSize = Math.max(16, Math.min(maxFontSize, fittedFontSize));
591
636
  const boxPadding = Math.round(fontSize * 0.4);
592
637
 
593
638
  const fontFile = layer.fontPath
@@ -626,7 +671,14 @@ export function getTitleBackgroundFilter(
626
671
 
627
672
  const text = escapeDrawText(layer.text);
628
673
  const textColor = layer.textColor ?? "white";
629
- const fontSize = Math.round(Math.min(width, height) * 0.1);
674
+
675
+ // Auto-size font to fit within 90% of frame width
676
+ const maxFontSizeBg = Math.round(Math.min(width, height) * 0.1);
677
+ const maxTextWidthBg = width * 0.9;
678
+ const fittedFontSizeBg = Math.floor(
679
+ maxTextWidthBg / (layer.text.length * 0.55),
680
+ );
681
+ const fontSize = Math.max(16, Math.min(maxFontSizeBg, fittedFontSizeBg));
630
682
 
631
683
  const fontFile = layer.fontPath
632
684
  ? `:fontfile='${escapeDrawText(layer.fontPath)}'`