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.
- package/assets/fonts/TikTokSans-Bold.ttf +0 -0
- package/package.json +2 -1
- package/src/ai-sdk/providers/editly/backends/index.ts +7 -0
- package/src/ai-sdk/providers/editly/backends/local.ts +65 -0
- package/src/ai-sdk/providers/editly/backends/types.ts +54 -0
- package/src/ai-sdk/providers/editly/editly.test.ts +49 -1
- package/src/ai-sdk/providers/editly/index.ts +37 -14
- package/src/ai-sdk/providers/editly/layers.ts +58 -6
- package/src/ai-sdk/providers/editly/rendi/editly-with-rendi-backend.test.ts +335 -0
- package/src/ai-sdk/providers/editly/rendi/index.ts +271 -0
- package/src/ai-sdk/providers/editly/rendi/rendi.test.ts +46 -0
- package/src/ai-sdk/providers/editly/types.ts +28 -0
- package/src/ai-sdk/providers/fal.ts +81 -6
- package/src/react/index.ts +1 -1
- package/src/react/layouts/grid.tsx +1 -1
- package/src/react/layouts/index.ts +2 -1
- package/src/react/layouts/slot.tsx +85 -0
- package/src/react/layouts/split.tsx +18 -0
- package/src/react/renderers/captions.ts +25 -6
- package/src/react/renderers/clip.ts +33 -1
- package/src/react/renderers/music.ts +5 -0
- package/src/react/renderers/packshot/blinking-button.ts +413 -0
- package/src/react/renderers/packshot.ts +170 -8
- package/src/react/renderers/progress.ts +3 -0
- package/src/react/renderers/render.ts +2 -1
- package/src/react/renderers/split.ts +34 -13
- package/src/react/types.ts +43 -1
- package/test-slot-grid.tsx +19 -0
- package/test-slot-userland.tsx +30 -0
- package/src/ai-sdk/providers/editly/ffmpeg.ts +0 -60
|
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-
|
|
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,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 {
|
|
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 {
|
|
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
|
-
|
|
40
|
-
|
|
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(
|
|
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<
|
|
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
|
|
883
|
+
const result = await backend.run({
|
|
884
|
+
args: ffmpegArgs,
|
|
885
|
+
inputs: allInputs,
|
|
886
|
+
outputPath: outPath,
|
|
887
|
+
verbose,
|
|
888
|
+
});
|
|
866
889
|
|
|
867
|
-
if (result.
|
|
868
|
-
|
|
890
|
+
if (result.output.type === "file" && verbose) {
|
|
891
|
+
console.log(`Output: ${result.output.path}`);
|
|
869
892
|
}
|
|
870
893
|
|
|
871
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)}'`
|