Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/components/TextControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,13 @@ export default function TextControls({
{/* Font Selector */}
<FontSelector
selectedFont={selectedOverlay.fontFamily}
onSelectFont={(fontName) =>
handleUpdateText(selectedTextId!, { fontFamily: fontName })
}
onSelectFont={(fontName) => {
const customFont = customFonts.find((font) => font.name === fontName);
handleUpdateText(selectedTextId!, {
fontFamily: fontName,
fontPath: customFont?.blobUrl,
});
}}
customFonts={customFonts}
onAddFonts={addFonts}
onRemoveFont={removeFont}
Expand Down
43 changes: 43 additions & 0 deletions src/lib/__tests__/text-overlay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { buildTextFilter } from "../text-overlay";
import { TextOverlay } from "../types";

function createOverlay(overrides: Partial<TextOverlay> = {}): TextOverlay {
return {
id: "text-1",
text: "Hello",
x: 10,
y: 20,
fontSize: 48,
color: "#ffffff",
fontWeight: "normal",
fontFamily: "Arial",
...overrides,
};
}

describe("buildTextFilter", () => {
it("uses an exported custom font file path when available", () => {
const filter = buildTextFilter(
createOverlay({
fontFamily: "CustomFont",
fontPath: "/tmp/custom-font.ttf",
}),
1920,
1080,
);

expect(filter).toContain("fontfile=/tmp/custom-font.ttf");
expect(filter).not.toContain("fontfile='CustomFont'");
});

it("falls back to the sanitized font family when no export path exists", () => {
const filter = buildTextFilter(
createOverlay({ fontFamily: "My Font" }),
1920,
1080,
);

expect(filter).toContain("fontfile='MyFont'");
});
});
56 changes: 50 additions & 6 deletions src/lib/ffmpeg.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,49 @@ function serializeFileBuffer(file: SerializedFile): Uint8Array {
return new Uint8Array(file.data);
}

async function materializeCustomFontPaths(
recipe: EditRecipe,
cleanupFiles: Set<string>,
sessionId: string
): Promise<EditRecipe> {
const nextRecipe = JSON.parse(JSON.stringify(recipe)) as EditRecipe;
const fontCache = new Map<string, string>();
let fontIndex = 0;

for (const overlay of nextRecipe.textOverlays || []) {
const fontPath = overlay.fontPath;
if (!fontPath) continue;

const shouldFetch =
fontPath.startsWith("blob:") ||
fontPath.startsWith("data:") ||
fontPath.startsWith("http://") ||
fontPath.startsWith("https://");

if (!shouldFetch) continue;

const cachedPath = fontCache.get(fontPath);
if (cachedPath) {
overlay.fontPath = cachedPath;
continue;
}

const response = await fetch(fontPath);
if (!response.ok) {
throw new Error(`Failed to load custom font for export: ${overlay.fontFamily || overlay.id}`);
}

const localPath = `custom-font-${sessionId}-${fontIndex++}.ttf`;
const fontBytes = new Uint8Array(await response.arrayBuffer());
await ffmpeg!.writeFile(localPath, fontBytes, { signal: activeExportAbortController?.signal });
cleanupFiles.add(localPath);
fontCache.set(fontPath, localPath);
overlay.fontPath = localPath;
}

return nextRecipe;
}

function getOutputConfig(format: string, sessionId: string) {
switch (format) {
case "webm":
Expand Down Expand Up @@ -430,6 +473,7 @@ async function runExport(request: ExportRequest): Promise<ResultPayload> {
}

const videoDuration = request.videoDuration;
const exportRecipe = await materializeCustomFontPaths(recipe, cleanupFiles, sessionId);

const handleProgress = ({ progress }: { progress: number }) => {
if (activeExportId !== sessionId) return;
Expand All @@ -441,16 +485,16 @@ async function runExport(request: ExportRequest): Promise<ResultPayload> {

try {
if (recipe.format === "gif") {
const vf = buildVideoFilter(recipe, targetW, targetH);
const vf = buildVideoFilter(exportRecipe, targetW, targetH);
const vfWithPalette = vf ? `${vf},palettegen` : "palettegen";
const vfWithPaletteUse = vf
? `[0:v]${vf}[x];[x][1:v]paletteuse`
: "[0:v][1:v]paletteuse";

const gifDurationArgs = recipe.speed !== 1
const gifDurationArgs = exportRecipe.speed !== 1
? (() => {
const sourceDuration = (recipe.trimEnd ?? videoDuration) - recipe.trimStart;
const outputDuration = sourceDuration / recipe.speed;
const sourceDuration = (exportRecipe.trimEnd ?? videoDuration) - exportRecipe.trimStart;
const outputDuration = sourceDuration / exportRecipe.speed;
return ["-t", outputDuration.toFixed(6)];
})()
: [];
Expand Down Expand Up @@ -499,8 +543,8 @@ async function runExport(request: ExportRequest): Promise<ResultPayload> {
ffmpeg.on("log", logListener);

let args = buildArguments(
recipe,
recipe.format,
exportRecipe,
exportRecipe.format,
outputName,
inputName,
targetW,
Expand Down
4 changes: 2 additions & 2 deletions src/lib/text-overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,8 @@ export function buildTextFilter(
// Build the drawtext filter with font support
let filter = `drawtext=text='${escapedText}':x=${pixelX}:y=${pixelY}:fontsize=${overlay.fontSize}:fontcolor=${overlay.color}:fontweight=${fontWeightParam}`;

// Add font family if specified
if (overlay.fontFamily) {
// Add a fallback font name when no explicit export path is available.
if (overlay.fontFamily && !fontFileParam) {
// Sanitize font name for FFmpeg
const safeFontName = overlay.fontFamily.replace(/[^a-zA-Z0-9-]/g, "");
filter += `:fontfile='${safeFontName}'`;
Expand Down