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
17 changes: 8 additions & 9 deletions src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import { useTheme } from "./ThemeProvider";
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
const [mounted, setMounted] = useState(false);

useEffect(() => {
setMounted(true);
}, []);

const isDark = theme === "dark";

if (!mounted) {
return (
<button
Expand All @@ -30,10 +31,10 @@ export function ThemeToggle() {

return (
<button
type="button"
suppressHydrationWarning={true}
onClick={toggleTheme}
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
type="button"
suppressHydrationWarning={true}
onClick={toggleTheme}
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
className="
relative flex items-center justify-center
w-9 h-9 rounded-full
Expand All @@ -46,9 +47,7 @@ export function ThemeToggle() {
transition-all duration-200
"
>
{!mounted ? (
<div className="w-4 h-4" />
) : isDark ? (
{isDark ? (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="w-4 h-4" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
Expand All @@ -60,4 +59,4 @@ export function ThemeToggle() {
)}
</button>
);
}
}
1 change: 1 addition & 0 deletions src/hooks/useKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect } from "react";
import { EditRecipe, ExportStatus } from "@/lib/types";
import { DEFAULT_RECIPE } from "@/lib/constants";
import { PRESETS } from "@/lib/presets";

interface UseKeyboardShortcutsProps {
Expand Down
1 change: 0 additions & 1 deletion src/hooks/useVideoEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,6 @@ export function useVideoEditor() {
if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl);
setResult(null);

await loadFFmpeg(abortController.signal, setProgress);
if (exportCancelledRef.current) return;

const startedAt = Date.now();
Expand Down
106 changes: 22 additions & 84 deletions src/lib/text-overlay.ts
Original file line number Diff line number Diff line change
@@ -1,107 +1,45 @@
import { TextOverlay } from "./types";
import { getFFmpegFontArg } from "@/utils/fontLoader";

/**
* Generates a unique ID for a text overlay.
*/
export function generateTextOverlayId(): string {
return `text-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
export function buildTextFilter(overlay: TextOverlay, targetW: number, targetH: number): string {
const x = Math.round((overlay.x / 100) * targetW);
const y = Math.round((overlay.y / 100) * targetH);
const weight = overlay.fontWeight === "bold" || overlay.fontWeight === "900" ? "bold" : "normal";
const escaped = overlay.text.replace(/'/g, "\u2019").replace(/:/g, "\\:");
return `drawtext=text='${escaped}':x=${x}:y=${y}:fontsize=${overlay.fontSize}:fontcolor=${overlay.color}:fontweight=${weight}`;
}

/**
* Creates a default text overlay with sensible defaults.
*/
export function createDefaultTextOverlay(): TextOverlay {
return {
id: generateTextOverlayId(),
text: "Enter text",
x: 50, // Centered horizontally
y: 20, // Near top
fontSize: 48,
color: "#ffffff",
fontWeight: "normal",
fontFamily: "Arial", // Default to Arial for immediate visibility
};
}

/**
* Calculates the position of a text overlay relative to the preview container.
* @param percentX - Horizontal position as percentage (0-100)
* @param percentY - Vertical position as percentage (0-100)
* @param containerWidth - Width of the preview container in pixels
* @param containerHeight - Height of the preview container in pixels
*/
export function getTextPixelPosition(
percentX: number,
percentY: number,
x: number,
y: number,
containerWidth: number,
containerHeight: number
): { left: number; top: number } {
return {
left: (percentX / 100) * containerWidth,
top: (percentY / 100) * containerHeight,
left: (x / 100) * containerWidth,
top: (y / 100) * containerHeight,
};
}

/**
* Converts pixel position back to percentage within the container.
*/
export function getTextPercentPosition(
pixelX: number,
pixelY: number,
containerWidth: number,
containerHeight: number
): { x: number; y: number } {
return {
x: Math.max(0, Math.min(100, (pixelX / containerWidth) * 100)),
y: Math.max(0, Math.min(100, (pixelY / containerHeight) * 100)),
x: Math.min(100, Math.max(0, (pixelX / containerWidth) * 100)),
y: Math.min(100, Math.max(0, (pixelY / containerHeight) * 100)),
};
}

/**
* Generates a drawText FFmpeg filter for a single text overlay.
* Escapes special characters and positions text on the output video.
* Includes font family and custom font file support.
*/
export function buildTextFilter(
overlay: TextOverlay,
targetWidth: number,
targetHeight: number
): string {
// Escape special characters for FFmpeg drawtext filter
const escapedText = overlay.text
.replace(/\\/g, "\\\\")
.replace(/'/g, "\\'")
.replace(/:/g, "\\:");

// Convert percentage position to pixel position
const pixelX = Math.round((overlay.x / 100) * targetWidth);
const pixelY = Math.round((overlay.y / 100) * targetHeight);

// Build font parameters
const fontWeightParam = overlay.fontWeight === "900"
? "bold"
: overlay.fontWeight === "bold"
? "bold"
: "normal";

// Get font file parameter for custom fonts (if available)
const fontFileParam = getFFmpegFontArg(overlay.fontFamily, overlay.fontPath);

// 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) {
// Sanitize font name for FFmpeg
const safeFontName = overlay.fontFamily.replace(/[^a-zA-Z0-9-]/g, "");
filter += `:fontfile='${safeFontName}'`;
}

// Add custom font file path if available
if (fontFileParam) {
filter += `:${fontFileParam}`;
}

return filter;
export function createDefaultTextOverlay(): TextOverlay {
return {
id: crypto.randomUUID(),
text: "Text",
x: 50,
y: 50,
fontSize: 32,
color: "#ffffff",
fontWeight: "normal",
};
}
14 changes: 14 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,19 @@ export const MAX_FILE_SIZE =
export const WARNING_FILE_SIZE =
500 * 1024 * 1024; // 500MB

function isValidTextOverlay(o: unknown): boolean {
if (typeof o !== "object" || o === null) return false;
const v = o as Record<string, unknown>;
if (typeof v.id !== "string" || v.id.trim() === "") return false;
if (typeof v.text !== "string") return false;
if (typeof v.x !== "number" || !isFinite(v.x) || v.x < 0 || v.x > 100) return false;
if (typeof v.y !== "number" || !isFinite(v.y) || v.y < 0 || v.y > 100) return false;
if (typeof v.fontSize !== "number" || !isFinite(v.fontSize) || v.fontSize < 8 || v.fontSize > 300) return false;
if (typeof v.color !== "string" || !/^#[0-9a-fA-F]{3,8}$/.test(v.color)) return false;
if (!["normal", "bold", "900"].includes(v.fontWeight as string)) return false;
return true;
}

export function isValidRecipe(value: unknown): value is EditRecipe {
if (!value || typeof value !== "object") return false;
const v = value as any;
Expand All @@ -104,6 +117,7 @@ export function isValidRecipe(value: unknown): value is EditRecipe {
if (typeof v.saturation !== "number" || !isFinite(v.saturation)) return false;
if (typeof v.soundOnCompletion !== "boolean") return false;
if (!Array.isArray(v.textOverlays)) return false;
if (!v.textOverlays.every(isValidTextOverlay)) return false;

return true;
}
Loading