vargai 0.4.0-alpha4 → 0.4.0-alpha40

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 (114) hide show
  1. package/.env.example +6 -0
  2. package/README.md +483 -61
  3. package/assets/fonts/TikTokSans-Bold.ttf +0 -0
  4. package/examples/grok-imagine-test.tsx +155 -0
  5. package/launch-videos/06-kawaii-fruits.tsx +93 -0
  6. package/launch-videos/07-ugc-weight-loss.tsx +132 -0
  7. package/launch-videos/08-talking-head-varg.tsx +107 -0
  8. package/launch-videos/09-girl.tsx +160 -0
  9. package/launch-videos/README.md +42 -0
  10. package/package.json +10 -4
  11. package/pipeline/cookbooks/round-video-character.md +1 -1
  12. package/skills/varg-video-generation/SKILL.md +224 -0
  13. package/skills/varg-video-generation/references/templates.md +380 -0
  14. package/skills/varg-video-generation/scripts/setup.ts +265 -0
  15. package/src/ai-sdk/cache.ts +1 -3
  16. package/src/ai-sdk/examples/google-image.ts +62 -0
  17. package/src/ai-sdk/index.ts +10 -0
  18. package/src/ai-sdk/middleware/wrap-image-model.ts +4 -21
  19. package/src/ai-sdk/middleware/wrap-music-model.ts +4 -16
  20. package/src/ai-sdk/middleware/wrap-video-model.ts +5 -17
  21. package/src/ai-sdk/providers/CONTRIBUTING.md +457 -0
  22. package/src/ai-sdk/providers/editly/backends/index.ts +8 -0
  23. package/src/ai-sdk/providers/editly/backends/local.ts +94 -0
  24. package/src/ai-sdk/providers/editly/backends/types.ts +74 -0
  25. package/src/ai-sdk/providers/editly/editly.test.ts +49 -1
  26. package/src/ai-sdk/providers/editly/index.ts +164 -80
  27. package/src/ai-sdk/providers/editly/layers.ts +58 -6
  28. package/src/ai-sdk/providers/editly/rendi/editly-with-rendi-backend.test.ts +335 -0
  29. package/src/ai-sdk/providers/editly/rendi/index.ts +289 -0
  30. package/src/ai-sdk/providers/editly/rendi/rendi.test.ts +35 -0
  31. package/src/ai-sdk/providers/editly/types.ts +30 -0
  32. package/src/ai-sdk/providers/elevenlabs.ts +10 -2
  33. package/src/ai-sdk/providers/fal.test.ts +214 -0
  34. package/src/ai-sdk/providers/fal.ts +435 -40
  35. package/src/ai-sdk/providers/google.ts +423 -0
  36. package/src/ai-sdk/providers/together.ts +191 -0
  37. package/src/cli/commands/find.tsx +1 -0
  38. package/src/cli/commands/frame.tsx +616 -0
  39. package/src/cli/commands/hello.ts +85 -0
  40. package/src/cli/commands/help.tsx +18 -30
  41. package/src/cli/commands/index.ts +11 -2
  42. package/src/cli/commands/init.tsx +570 -0
  43. package/src/cli/commands/list.tsx +1 -0
  44. package/src/cli/commands/render.tsx +322 -76
  45. package/src/cli/commands/run.tsx +1 -0
  46. package/src/cli/commands/storyboard.tsx +1714 -0
  47. package/src/cli/commands/which.tsx +1 -0
  48. package/src/cli/index.ts +23 -4
  49. package/src/cli/ui/components/Badge.tsx +1 -0
  50. package/src/cli/ui/components/DataTable.tsx +1 -0
  51. package/src/cli/ui/components/Header.tsx +1 -0
  52. package/src/cli/ui/components/HelpBlock.tsx +1 -0
  53. package/src/cli/ui/components/KeyValue.tsx +1 -0
  54. package/src/cli/ui/components/OptionRow.tsx +1 -0
  55. package/src/cli/ui/components/Separator.tsx +1 -0
  56. package/src/cli/ui/components/StatusBox.tsx +1 -0
  57. package/src/cli/ui/components/VargBox.tsx +1 -0
  58. package/src/cli/ui/components/VargProgress.tsx +1 -0
  59. package/src/cli/ui/components/VargSpinner.tsx +1 -0
  60. package/src/cli/ui/components/VargText.tsx +1 -0
  61. package/src/definitions/actions/grok-edit.ts +133 -0
  62. package/src/definitions/actions/index.ts +16 -0
  63. package/src/definitions/actions/qwen-angles.ts +218 -0
  64. package/src/index.ts +1 -0
  65. package/src/providers/fal.ts +196 -0
  66. package/src/react/assets.ts +9 -0
  67. package/src/react/elements.ts +0 -5
  68. package/src/react/examples/branching.tsx +6 -4
  69. package/src/react/examples/character-video.tsx +13 -10
  70. package/src/react/examples/local-files-test.tsx +19 -0
  71. package/src/react/examples/ltx2-test.tsx +25 -0
  72. package/src/react/examples/madi.tsx +13 -10
  73. package/src/react/examples/mcmeows.tsx +40 -0
  74. package/src/react/examples/music-defaults.tsx +24 -0
  75. package/src/react/examples/quickstart-test.tsx +101 -0
  76. package/src/react/examples/qwen-angles-test.tsx +72 -0
  77. package/src/react/index.ts +3 -3
  78. package/src/react/layouts/grid.tsx +1 -1
  79. package/src/react/layouts/index.ts +2 -1
  80. package/src/react/layouts/slot.tsx +85 -0
  81. package/src/react/layouts/split.tsx +18 -0
  82. package/src/react/react.test.ts +60 -11
  83. package/src/react/renderers/burn-captions.ts +95 -0
  84. package/src/react/renderers/cache.test.ts +182 -0
  85. package/src/react/renderers/captions.ts +25 -6
  86. package/src/react/renderers/clip.ts +56 -25
  87. package/src/react/renderers/context.ts +5 -2
  88. package/src/react/renderers/image.ts +5 -2
  89. package/src/react/renderers/index.ts +0 -1
  90. package/src/react/renderers/music.ts +8 -3
  91. package/src/react/renderers/packshot/blinking-button.ts +413 -0
  92. package/src/react/renderers/packshot.ts +170 -8
  93. package/src/react/renderers/progress.ts +4 -3
  94. package/src/react/renderers/render.ts +127 -71
  95. package/src/react/renderers/speech.ts +2 -2
  96. package/src/react/renderers/split.ts +34 -13
  97. package/src/react/renderers/utils.test.ts +80 -0
  98. package/src/react/renderers/utils.ts +37 -1
  99. package/src/react/renderers/video.ts +47 -9
  100. package/src/react/types.ts +70 -17
  101. package/src/studio/stages.ts +40 -39
  102. package/src/studio/step-renderer.ts +14 -24
  103. package/src/studio/ui/index.html +2 -2
  104. package/src/tests/all.test.ts +4 -4
  105. package/src/tests/index.ts +1 -1
  106. package/test-slot-grid.tsx +19 -0
  107. package/test-slot-userland.tsx +30 -0
  108. package/test-sync-v2.ts +30 -0
  109. package/test-sync-v2.tsx +29 -0
  110. package/tsconfig.json +1 -1
  111. package/video.tsx +7 -0
  112. package/src/ai-sdk/providers/editly/ffmpeg.ts +0 -60
  113. package/src/react/renderers/animate.ts +0 -59
  114. /package/src/cli/commands/{studio.tsx → studio.ts} +0 -0
@@ -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,8 @@ import type {
28
28
  VideoLayer,
29
29
  } from "./types";
30
30
 
31
+ export * from "./backends";
32
+ export * from "./rendi";
31
33
  export * from "./types";
32
34
 
33
35
  const DEFAULT_DURATION = 4;
@@ -36,12 +38,22 @@ const DEFAULT_FPS = 30;
36
38
  const DEFAULT_WIDTH = 1280;
37
39
  const DEFAULT_HEIGHT = 720;
38
40
 
39
- async function getVideoDuration(path: string): Promise<number> {
40
- const info = await ffprobe(path);
41
+ function multipleOf2(n: number): number {
42
+ return Math.round(n / 2) * 2;
43
+ }
44
+
45
+ async function getVideoDuration(
46
+ path: string,
47
+ backend: FFmpegBackend,
48
+ ): Promise<number> {
49
+ const info = await backend.ffprobe(path);
41
50
  return info.duration;
42
51
  }
43
52
 
44
- async function getFirstVideoInfo(clips: Clip[]): Promise<{
53
+ async function getFirstVideoInfo(
54
+ clips: Clip[],
55
+ backend: FFmpegBackend,
56
+ ): Promise<{
45
57
  width?: number;
46
58
  height?: number;
47
59
  fps?: number;
@@ -49,7 +61,7 @@ async function getFirstVideoInfo(clips: Clip[]): Promise<{
49
61
  for (const clip of clips) {
50
62
  for (const layer of clip.layers) {
51
63
  if (layer.type === "video") {
52
- const info = await ffprobe((layer as VideoLayer).path);
64
+ const info = await backend.ffprobe((layer as VideoLayer).path);
53
65
  return { width: info.width, height: info.height, fps: info.fps };
54
66
  }
55
67
  }
@@ -72,6 +84,7 @@ function applyLayerDefaults(
72
84
  async function processClips(
73
85
  clips: Clip[],
74
86
  defaults: EditlyConfig["defaults"],
87
+ backend: FFmpegBackend,
75
88
  ): Promise<ProcessedClip[]> {
76
89
  const processed: ProcessedClip[] = [];
77
90
  const defaultDuration = defaults?.duration ?? DEFAULT_DURATION;
@@ -86,7 +99,7 @@ async function processClips(
86
99
  for (const layer of layers) {
87
100
  if (layer.type === "video" && !clip.duration) {
88
101
  const videoLayer = layer as VideoLayer;
89
- const videoDuration = await getVideoDuration(videoLayer.path);
102
+ const videoDuration = await getVideoDuration(videoLayer.path, backend);
90
103
  const cutFrom = videoLayer.cutFrom ?? 0;
91
104
  const cutTo = videoLayer.cutTo ?? videoDuration;
92
105
  duration = cutTo - cutFrom;
@@ -137,6 +150,15 @@ function isOverlayLayer(layer: Layer): boolean {
137
150
  return isVideoOverlayLayer(layer) || isImageOverlayLayer(layer);
138
151
  }
139
152
 
153
+ function isTextOverlayLayer(layer: Layer): boolean {
154
+ return (
155
+ layer.type === "title" ||
156
+ layer.type === "subtitle" ||
157
+ layer.type === "news-title" ||
158
+ layer.type === "slide-in-text"
159
+ );
160
+ }
161
+
140
162
  function buildBaseClipFilter(
141
163
  clip: ProcessedClip,
142
164
  clipIndex: number,
@@ -164,7 +186,10 @@ function buildBaseClipFilter(
164
186
  let baseLabel = "";
165
187
  let inputIdx = inputOffset;
166
188
 
167
- const baseLayers = clip.layers.filter((l) => l && !isOverlayLayer(l));
189
+ // Filter out overlay layers AND text overlay layers (text will be applied after image overlays)
190
+ const baseLayers = clip.layers.filter(
191
+ (l) => l && !isOverlayLayer(l) && !isTextOverlayLayer(l),
192
+ );
168
193
 
169
194
  for (let i = 0; i < baseLayers.length; i++) {
170
195
  const layer = baseLayers[i];
@@ -201,58 +226,6 @@ function buildBaseClipFilter(
201
226
  inputIdx++;
202
227
  }
203
228
  }
204
-
205
- if (layer.type === "title") {
206
- const titleFilter = getTitleFilter(
207
- layer as TitleLayer,
208
- baseLabel,
209
- width,
210
- height,
211
- clip.duration,
212
- );
213
- const newLabel = `title${clipIndex}_${i}`;
214
- filters.push(`${titleFilter}[${newLabel}]`);
215
- baseLabel = newLabel;
216
- }
217
-
218
- if (layer.type === "subtitle") {
219
- const subtitleFilter = getSubtitleFilter(
220
- layer as SubtitleLayer,
221
- baseLabel,
222
- width,
223
- height,
224
- clip.duration,
225
- );
226
- const newLabel = `sub${clipIndex}_${i}`;
227
- filters.push(`${subtitleFilter}[${newLabel}]`);
228
- baseLabel = newLabel;
229
- }
230
-
231
- if (layer.type === "news-title") {
232
- const newsFilter = getNewsTitleFilter(
233
- layer as NewsTitleLayer,
234
- baseLabel,
235
- width,
236
- height,
237
- clip.duration,
238
- );
239
- const newLabel = `news${clipIndex}_${i}`;
240
- filters.push(`${newsFilter}[${newLabel}]`);
241
- baseLabel = newLabel;
242
- }
243
-
244
- if (layer.type === "slide-in-text") {
245
- const slideFilter = getSlideInTextFilter(
246
- layer as SlideInTextLayer,
247
- baseLabel,
248
- width,
249
- height,
250
- clip.duration,
251
- );
252
- const newLabel = `slide${clipIndex}_${i}`;
253
- filters.push(`${slideFilter}[${newLabel}]`);
254
- baseLabel = newLabel;
255
- }
256
229
  }
257
230
 
258
231
  return {
@@ -358,6 +331,41 @@ function collectAudioLayers(
358
331
  return audioLayers;
359
332
  }
360
333
 
334
+ type TextLayer = TitleLayer | SubtitleLayer | NewsTitleLayer | SlideInTextLayer;
335
+
336
+ interface TimedTextLayer {
337
+ layer: TextLayer;
338
+ startTime: number;
339
+ duration: number;
340
+ }
341
+
342
+ function collectTextLayers(clips: ProcessedClip[]): TimedTextLayer[] {
343
+ const textLayers: TimedTextLayer[] = [];
344
+ let currentTime = 0;
345
+
346
+ for (let i = 0; i < clips.length; i++) {
347
+ const clip = clips[i];
348
+ if (!clip) continue;
349
+
350
+ for (const layer of clip.layers) {
351
+ if (layer && isTextOverlayLayer(layer)) {
352
+ textLayers.push({
353
+ layer: layer as TextLayer,
354
+ startTime: currentTime,
355
+ duration: clip.duration,
356
+ });
357
+ }
358
+ }
359
+
360
+ currentTime += clip.duration;
361
+ if (i < clips.length - 1) {
362
+ currentTime -= clip.transition.duration;
363
+ }
364
+ }
365
+
366
+ return textLayers;
367
+ }
368
+
361
369
  function buildTransitionFilter(
362
370
  fromLabel: string,
363
371
  toLabel: string,
@@ -527,7 +535,7 @@ function buildAudioFilter(
527
535
  };
528
536
  }
529
537
 
530
- export async function editly(config: EditlyConfig): Promise<void> {
538
+ export async function editly(config: EditlyConfig): Promise<EditlyResult> {
531
539
  const {
532
540
  outPath,
533
541
  clips: clipsIn,
@@ -544,11 +552,17 @@ export async function editly(config: EditlyConfig): Promise<void> {
544
552
  fast,
545
553
  } = config;
546
554
 
555
+ const backend: FFmpegBackend = config.backend ?? localBackend;
556
+
557
+ if (verbose) {
558
+ console.log(`[editly] using backend: ${backend.name}`);
559
+ }
560
+
547
561
  if (!clipsIn || clipsIn.length === 0) {
548
562
  throw new Error("At least one clip is required");
549
563
  }
550
564
 
551
- const firstVideoInfo = await getFirstVideoInfo(clipsIn);
565
+ const firstVideoInfo = await getFirstVideoInfo(clipsIn, backend);
552
566
  let width = config.width ?? firstVideoInfo.width ?? DEFAULT_WIDTH;
553
567
  let height = config.height ?? firstVideoInfo.height ?? DEFAULT_HEIGHT;
554
568
  const fps = config.fps ?? firstVideoInfo.fps ?? DEFAULT_FPS;
@@ -566,7 +580,7 @@ export async function editly(config: EditlyConfig): Promise<void> {
566
580
  console.log(`Output: ${width}x${height} @ ${fps}fps`);
567
581
  }
568
582
 
569
- const clips = await processClips(clipsIn, defaults);
583
+ const clips = await processClips(clipsIn, defaults, backend);
570
584
 
571
585
  const continuousVideoOverlays = collectContinuousVideoOverlays(clips);
572
586
  const imageOverlays = collectImageOverlays(clips);
@@ -744,6 +758,67 @@ export async function editly(config: EditlyConfig): Promise<void> {
744
758
  finalVideoLabel = currentBase;
745
759
  }
746
760
 
761
+ const textLayers = collectTextLayers(clips);
762
+ if (textLayers.length > 0) {
763
+ let currentBase = finalVideoLabel;
764
+
765
+ for (let i = 0; i < textLayers.length; i++) {
766
+ const timedLayer = textLayers[i];
767
+ if (!timedLayer) continue;
768
+
769
+ const { layer, startTime, duration } = timedLayer;
770
+ const outputLabel = `vwithtext${i}`;
771
+
772
+ const timedLayerWithEnable = {
773
+ ...layer,
774
+ start: layer.start ?? startTime,
775
+ stop: layer.stop ?? startTime + duration,
776
+ };
777
+
778
+ if (layer.type === "title") {
779
+ const titleFilter = getTitleFilter(
780
+ timedLayerWithEnable as TitleLayer,
781
+ currentBase,
782
+ width,
783
+ height,
784
+ totalDuration,
785
+ );
786
+ allFilters.push(`${titleFilter}[${outputLabel}]`);
787
+ } else if (layer.type === "subtitle") {
788
+ const subtitleFilter = getSubtitleFilter(
789
+ timedLayerWithEnable as SubtitleLayer,
790
+ currentBase,
791
+ width,
792
+ height,
793
+ totalDuration,
794
+ );
795
+ allFilters.push(`${subtitleFilter}[${outputLabel}]`);
796
+ } else if (layer.type === "news-title") {
797
+ const newsFilter = getNewsTitleFilter(
798
+ timedLayerWithEnable as NewsTitleLayer,
799
+ currentBase,
800
+ width,
801
+ height,
802
+ totalDuration,
803
+ );
804
+ allFilters.push(`${newsFilter}[${outputLabel}]`);
805
+ } else if (layer.type === "slide-in-text") {
806
+ const slideFilter = getSlideInTextFilter(
807
+ timedLayerWithEnable as SlideInTextLayer,
808
+ currentBase,
809
+ width,
810
+ height,
811
+ totalDuration,
812
+ );
813
+ allFilters.push(`${slideFilter}[${outputLabel}]`);
814
+ }
815
+
816
+ currentBase = outputLabel;
817
+ }
818
+
819
+ finalVideoLabel = currentBase;
820
+ }
821
+
747
822
  const clipAudioLayers = collectAudioLayers(clips);
748
823
  const videoInputCount = allInputs.length;
749
824
  const audioFilter = buildAudioFilter(
@@ -765,10 +840,9 @@ export async function editly(config: EditlyConfig): Promise<void> {
765
840
  allFilters.push(audioFilter.filter);
766
841
  }
767
842
 
768
- const inputArgs = allInputs.flatMap((input) => ["-i", input]);
769
843
  const filterComplex = allFilters.join(";");
770
844
 
771
- const outputArgs = customOutputArgs ?? [
845
+ const codecArgs = customOutputArgs ?? [
772
846
  "-c:v",
773
847
  "libx264",
774
848
  "-preset",
@@ -785,33 +859,43 @@ export async function editly(config: EditlyConfig): Promise<void> {
785
859
  ? ["-map", `[${finalVideoLabel}]`, "-map", `[${audioFilter.outputLabel}]`]
786
860
  : ["-map", `[${finalVideoLabel}]`];
787
861
 
788
- const ffmpegArgs = [
789
- "-hide_banner",
790
- "-loglevel",
791
- verbose ? "info" : "error",
792
- ...inputArgs,
793
- "-filter_complex",
794
- filterComplex,
862
+ const outputArgs = [
795
863
  ...mapArgs,
796
864
  "-r",
797
865
  String(fps),
798
- ...outputArgs,
799
- "-y",
800
- outPath,
866
+ ...codecArgs,
867
+ ...(config.shortest ? ["-shortest"] : []),
801
868
  ];
802
869
 
803
870
  if (verbose) {
804
- console.log("ffmpeg", ffmpegArgs.join(" "));
871
+ const inputArgs = allInputs.flatMap((input) => ["-i", input]);
872
+ console.log(
873
+ "ffmpeg",
874
+ [
875
+ ...inputArgs,
876
+ "-filter_complex",
877
+ filterComplex,
878
+ ...outputArgs,
879
+ "-y",
880
+ outPath,
881
+ ].join(" "),
882
+ );
805
883
  console.log("\nFilter complex:\n", filterComplex.split(";").join(";\n"));
806
884
  }
807
885
 
808
- const result = await $`ffmpeg ${ffmpegArgs}`.quiet();
886
+ const result = await backend.run({
887
+ inputs: allInputs,
888
+ filterComplex,
889
+ outputArgs,
890
+ outputPath: outPath,
891
+ verbose,
892
+ });
809
893
 
810
- if (result.exitCode !== 0) {
811
- throw new Error(`ffmpeg failed with exit code ${result.exitCode}`);
894
+ if (result.output.type === "file" && verbose) {
895
+ console.log(`Output: ${result.output.path}`);
812
896
  }
813
897
 
814
- console.log(`Output: ${outPath}`);
898
+ return { output: result.output };
815
899
  }
816
900
 
817
901
  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)}'`