Files
songs2vid/components/WatermarkPreview.tsx
T
Atakan Doğan Özban 848607f9e0 Add lower-corner layouts, custom blur backgrounds, and classic blur fill.
Ship composition families, optional background images for lower-corner templates, and an optional blurred cover fill for classic letterbox.
2026-08-07 00:51:37 +02:00

426 lines
14 KiB
TypeScript

"use client";
import {
useEffect,
useMemo,
useState,
type CSSProperties,
type ChangeEvent,
} from "react";
import { getVideoAttributionText } from "@/lib/branding";
import {
CURATED_FONTS,
googleFontsStylesheetUrl,
SYSTEM_FONT,
type CuratedFontKey,
type WatermarkFontKey,
} from "@/lib/fonts";
import {
curatedFontApiUrl,
previewFontFamilyCss,
} from "@/lib/preview-typography";
import {
WATERMARK_OFFSET_MAX,
WATERMARK_OFFSET_MIN,
WATERMARK_POSITIONS,
WATERMARK_TEXT_MAX,
type WatermarkMode,
type WatermarkPosition,
type WatermarkSettings,
} from "@/lib/watermark";
type Props = {
enabled: boolean;
locked: boolean;
previewImageUrl: string | null;
value: WatermarkSettings;
onChange: (next: WatermarkSettings) => void;
onUploadLogo: (file: File) => Promise<string>;
onUploadFont: (file: File) => Promise<string>;
logoPreviewUrl?: string | null;
};
const POSITION_LABELS: Record<WatermarkPosition, string> = {
"top-left": "Top left",
"top-right": "Top right",
"bottom-left": "Bottom left",
"bottom-right": "Bottom right",
center: "Center",
};
const CUSTOM_PREVIEW_FAMILY = "S2VIDCustomWm";
function previewStyle(
position: WatermarkPosition,
offsetX: number,
offsetY: number,
): CSSProperties {
const base: CSSProperties = {
position: "absolute",
maxWidth: "32%",
pointerEvents: "none",
};
const ox = `${offsetX}px`;
const oy = `${offsetY}px`;
switch (position) {
case "top-left":
return { ...base, top: oy, left: ox };
case "top-right":
return { ...base, top: oy, right: ox };
case "bottom-left":
return { ...base, bottom: oy, left: ox };
case "center":
return {
...base,
top: "50%",
left: "50%",
transform: `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY}px))`,
};
case "bottom-right":
default:
return { ...base, bottom: oy, right: ox };
}
}
function previewFontFamily(fontKey: WatermarkFontKey | undefined): string {
return previewFontFamilyCss(fontKey);
}
export function WatermarkPreview({
enabled,
locked,
previewImageUrl,
value,
onChange,
onUploadLogo,
onUploadFont,
logoPreviewUrl,
}: Props) {
const [logoUploading, setLogoUploading] = useState(false);
const [logoError, setLogoError] = useState<string | null>(null);
const [fontUploading, setFontUploading] = useState(false);
const [fontError, setFontError] = useState<string | null>(null);
const [customFontObjectUrl, setCustomFontObjectUrl] = useState<string | null>(null);
const overlay = useMemo(
() => previewStyle(value.position, value.offsetX, value.offsetY),
[value.position, value.offsetX, value.offsetY],
);
const textFontStyle = useMemo(
() => ({ fontFamily: previewFontFamily(value.fontKey) }),
[value.fontKey],
);
// Load bundled + curated fonts for live canvas preview (same files as FFmpeg)
useEffect(() => {
const styleId = "s2vid-watermark-preview-fonts";
let el = document.getElementById(styleId) as HTMLStyleElement | null;
if (!el) {
el = document.createElement("style");
el.id = styleId;
document.head.appendChild(el);
}
el.textContent = [
`@font-face {
font-family: '${SYSTEM_FONT.previewFamily}';
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}') format('truetype');
font-display: swap;
}`,
...CURATED_FONTS.map(
(f) => `@font-face {
font-family: 'S2VIDPreview-${f.key}';
src: url('${curatedFontApiUrl(f.key)}') format('truetype');
font-display: swap;
}`,
),
].join("\n");
}, []);
// Legacy Google Fonts link (kept for any older preview paths)
useEffect(() => {
const id = "s2vid-watermark-google-fonts";
if (document.getElementById(id)) return;
const link = document.createElement("link");
link.id = id;
link.rel = "stylesheet";
link.href = googleFontsStylesheetUrl(CURATED_FONTS.map((f) => f.key));
document.head.appendChild(link);
}, []);
// @font-face for uploaded custom font (browser preview only)
useEffect(() => {
if (!customFontObjectUrl) return;
const styleId = "s2vid-watermark-custom-font";
let el = document.getElementById(styleId) as HTMLStyleElement | null;
if (!el) {
el = document.createElement("style");
el.id = styleId;
document.head.appendChild(el);
}
el.textContent = `
@font-face {
font-family: '${CUSTOM_PREVIEW_FAMILY}';
src: url('${customFontObjectUrl}');
font-display: swap;
}`;
return () => {
/* keep style until next custom font replaces it */
};
}, [customFontObjectUrl]);
useEffect(() => {
return () => {
if (customFontObjectUrl) URL.revokeObjectURL(customFontObjectUrl);
};
}, [customFontObjectUrl]);
function patch(partial: Partial<WatermarkSettings>) {
if (locked) return;
onChange({ ...value, ...partial });
}
async function handleLogo(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file || locked) return;
setLogoError(null);
setLogoUploading(true);
try {
const path = await onUploadLogo(file);
patch({ mode: "logo", logoPath: path });
} catch (err) {
setLogoError(err instanceof Error ? err.message : "Logo upload failed");
} finally {
setLogoUploading(false);
}
}
async function handleFont(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file || locked) return;
setFontError(null);
setFontUploading(true);
try {
const objectUrl = URL.createObjectURL(file);
if (customFontObjectUrl) URL.revokeObjectURL(customFontObjectUrl);
setCustomFontObjectUrl(objectUrl);
const path = await onUploadFont(file);
patch({ mode: "text", fontKey: "custom", fontPath: path });
} catch (err) {
setFontError(err instanceof Error ? err.message : "Font upload failed");
} finally {
setFontUploading(false);
}
}
function setFontKey(key: WatermarkFontKey) {
if (key === "custom") {
patch({ fontKey: "custom", fontPath: value.fontPath ?? null });
return;
}
patch({ fontKey: key, fontPath: null });
}
return (
<div
className={`relative rounded-lg border border-gray-700 bg-surface p-6 ${
locked ? "opacity-80" : ""
}`}
>
<div className="mb-4 flex flex-wrap items-center gap-2">
<h3 className="text-lg font-medium text-white">Watermark layout</h3>
</div>
<div
className="relative mx-auto aspect-video w-full max-w-xl overflow-hidden rounded border border-gray-800 bg-black"
aria-hidden={locked}
>
{previewImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={previewImageUrl} alt="" className="h-full w-full object-contain" />
) : (
<div className="flex h-full items-center justify-center text-sm text-gray-600">
Upload a cover image to preview
</div>
)}
{enabled && value.mode !== "none" && (
<div style={overlay} className="rounded bg-black/40 px-2 py-1">
{value.mode === "logo" && logoPreviewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={logoPreviewUrl} alt="" className="max-h-16 w-auto object-contain" />
) : (
<span
className="text-[11px] font-medium text-white/90 drop-shadow"
style={value.mode === "text" ? textFontStyle : undefined}
>
{value.mode === "text" && value.text?.trim()
? value.text.trim().slice(0, WATERMARK_TEXT_MAX)
: getVideoAttributionText()}
</span>
)}
</div>
)}
</div>
<div className="mt-4 space-y-4" aria-disabled={locked}>
<div className="flex flex-wrap gap-2">
{(
[
["none", "No watermark"],
["default", "Songs2VID badge"],
["text", "Custom text"],
["logo", "PNG logo"],
] as const
).map(([mode, label]) => (
<button
key={mode}
type="button"
disabled={locked}
onClick={() => patch({ mode: mode as WatermarkMode })}
className={`rounded border px-3 py-1.5 text-xs font-medium transition-colors ${
value.mode === mode
? "border-accent bg-accent/15 text-accent"
: "border-gray-700 text-gray-400 hover:border-gray-500"
} disabled:cursor-not-allowed`}
>
{label}
</button>
))}
</div>
{value.mode === "text" && (
<>
<label className="block text-sm text-gray-400">
Watermark text
<input
type="text"
maxLength={WATERMARK_TEXT_MAX}
disabled={locked}
value={value.text ?? ""}
onChange={(e) => patch({ text: e.target.value })}
className="input-field mt-1"
placeholder="Your brand name"
/>
</label>
<label className="block text-sm text-gray-400">
Font
<select
disabled={locked}
value={value.fontKey ?? "system"}
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
className="input-field mt-1"
>
<option value="system">{SYSTEM_FONT.label}</option>
{CURATED_FONTS.map((f) => (
<option key={f.key} value={f.key} style={{ fontFamily: f.cssFamily }}>
{f.label}
</option>
))}
<option value="custom">Custom upload (.ttf / .otf)</option>
</select>
</label>
{(value.fontKey === "custom" ||
(value.fontKey &&
value.fontKey !== "system" &&
CURATED_FONTS.some((f) => f.key === (value.fontKey as CuratedFontKey)))) && (
<p className="text-xs text-gray-500" style={textFontStyle}>
Preview: The quick brown fox jumps over the lazy dog
</p>
)}
{value.fontKey === "custom" && (
<div>
<label className="mb-1 block text-sm text-gray-400">
Upload font (.ttf or .otf, max 10 MB)
</label>
<input
type="file"
accept=".ttf,.otf,font/ttf,font/otf"
disabled={locked || fontUploading}
onChange={(e) => void handleFont(e)}
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white"
/>
{fontUploading && (
<p className="mt-1 text-xs text-yellow-400">Uploading font</p>
)}
{fontError && <p className="mt-1 text-xs text-red-400">{fontError}</p>}
{value.fontPath && !fontError && (
<p className="mt-1 text-xs text-green-400">Custom font ready for render</p>
)}
</div>
)}
</>
)}
{value.mode === "logo" && (
<div>
<label className="mb-1 block text-sm text-gray-400">PNG logo</label>
<input
type="file"
accept="image/png,.png"
disabled={locked || logoUploading}
onChange={(e) => void handleLogo(e)}
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white"
/>
{logoUploading && <p className="mt-1 text-xs text-yellow-400">Uploading logo</p>}
{logoError && <p className="mt-1 text-xs text-red-400">{logoError}</p>}
</div>
)}
<div>
<p className="mb-2 text-sm text-gray-400">Position</p>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
{WATERMARK_POSITIONS.map((pos) => (
<button
key={pos}
type="button"
disabled={locked || value.mode === "none"}
onClick={() => patch({ position: pos })}
className={`rounded border px-2 py-2 text-[11px] font-medium ${
value.position === pos
? "border-accent bg-accent/15 text-accent"
: "border-gray-700 text-gray-400"
} disabled:opacity-40`}
>
{POSITION_LABELS[pos]}
</button>
))}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-sm text-gray-400">
Offset X ({value.offsetX}px)
<input
type="range"
min={WATERMARK_OFFSET_MIN}
max={WATERMARK_OFFSET_MAX}
disabled={locked || value.mode === "none"}
value={value.offsetX}
onChange={(e) => patch({ offsetX: Number(e.target.value) })}
className="mt-1 w-full"
/>
</label>
<label className="text-sm text-gray-400">
Offset Y ({value.offsetY}px)
<input
type="range"
min={WATERMARK_OFFSET_MIN}
max={WATERMARK_OFFSET_MAX}
disabled={locked || value.mode === "none"}
value={value.offsetY}
onChange={(e) => patch({ offsetY: Number(e.target.value) })}
className="mt-1 w-full"
/>
</label>
</div>
</div>
</div>
);
}