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.
This commit is contained in:
Atakan Doğan Özban
2026-08-07 00:51:37 +02:00
parent 03634d5100
commit 848607f9e0
25 changed files with 1081 additions and 122 deletions
+298 -49
View File
@@ -10,6 +10,7 @@ import {
import { getVideoAttributionText } from "@/lib/branding";
import {
CURATED_FONTS,
SYSTEM_FONT,
type CuratedFontKey,
type WatermarkFontKey,
} from "@/lib/fonts";
@@ -19,15 +20,18 @@ import {
BLUR_OPACITY_DEFAULT,
BLUR_OPACITY_MAX,
BLUR_OPACITY_MIN,
COMPOSITION_FAMILIES,
DEFAULT_LAYOUT,
LAYOUT_TEMPLATE_LABELS,
LAYOUT_TEMPLATES,
TEXT_OFFSET_MAX,
TEXT_OFFSET_MIN,
TEXT_PADDING_MAX,
TEXT_PADDING_MIN,
TITLE_ARTIST_GAP_MAX,
TITLE_ARTIST_GAP_MIN,
compositionFamilyForTemplate,
isLowerCornerTemplate,
lowerCornerCoverSide,
type LayoutSettings,
type LayoutTemplate,
} from "@/lib/layout";
@@ -54,6 +58,8 @@ import {
type Props = {
locked: boolean;
previewImageUrl: string | null;
/** Optional custom blur-fill for lower-corner layouts. */
previewBackgroundUrl?: string | null;
/** On-video song title (art-track layouts). */
songTitle: string;
artist: string;
@@ -65,7 +71,10 @@ type Props = {
onWatermarkChange: (next: WatermarkSettings) => void;
onUploadLogo: (file: File) => Promise<string>;
onUploadFont: (file: File) => Promise<string>;
onUploadBackground?: (file: File) => Promise<string>;
onClearBackground?: () => void;
logoPreviewUrl?: string | null;
hasCustomBackground?: boolean;
};
const WM_POSITION_LABELS: Record<WatermarkPosition, string> = {
@@ -121,11 +130,13 @@ function watermarkOverlayStyle(
function MiniThumb({
template,
label,
active,
onClick,
disabled,
}: {
template: LayoutTemplate | null;
label: string;
active: boolean;
onClick: () => void;
disabled: boolean;
@@ -150,20 +161,14 @@ function MiniThumb({
) : (
<div className="relative h-full w-full overflow-hidden bg-gray-700/50">
<div className="absolute inset-0 scale-110 bg-gray-600/40 blur-[3px]" />
{template === "COVER_LEFT_TEXT_RIGHT" && (
{(template === "COVER_LEFT_TEXT_RIGHT" ||
template === "COVER_RIGHT_TEXT_LEFT") && (
<>
<div className="absolute left-[6%] top-[18%] h-[64%] w-[38%] bg-gray-300" />
<div className="absolute right-[8%] top-[38%] h-1 w-[40%] rounded bg-white/80" />
<div className="absolute right-[8%] top-[52%] h-0.5 w-[28%] rounded bg-white/50" />
</>
)}
{template === "COVER_RIGHT_TEXT_LEFT" && (
<>
<div className="absolute right-[6%] top-[18%] h-[64%] w-[38%] bg-gray-300" />
<div className="absolute left-[8%] top-[38%] h-1 w-[40%] rounded bg-white/80" />
<div className="absolute left-[8%] top-[52%] h-0.5 w-[28%] rounded bg-white/50" />
</>
)}
{template === "COVER_TOP_TEXT_BOTTOM" && (
<>
<div className="absolute left-[8%] top-[6%] h-[52%] w-[84%] bg-gray-300" />
@@ -178,12 +183,18 @@ function MiniThumb({
<div className="absolute left-[34%] bottom-[12%] h-0.5 w-[32%] rounded bg-white/50" />
</>
)}
{(template === "LOWER_LEFT_COVER_TEXT" ||
template === "LOWER_RIGHT_COVER_TEXT") && (
<>
<div className="absolute bottom-[8%] left-[5%] h-[36%] w-[24%] bg-gray-300" />
<div className="absolute bottom-[26%] left-[34%] h-1 w-[40%] rounded bg-white/80" />
<div className="absolute bottom-[14%] left-[34%] h-0.5 w-[28%] rounded bg-white/50" />
</>
)}
</div>
)}
</div>
<p className="px-2 py-1.5 text-[10px] font-medium text-gray-300">
{isClassic ? "Classic letterbox" : LAYOUT_TEMPLATE_LABELS[template]}
</p>
<p className="px-2 py-1.5 text-[10px] font-medium text-gray-300">{label}</p>
</button>
);
}
@@ -191,8 +202,18 @@ function MiniThumb({
function previewCoverStyle(
template: LayoutTemplate,
padPct: number,
textPadding: number,
encodeWidth: number,
): CSSProperties {
const p = `${padPct}%`;
// Equal pixel inset on both axes (not % — % of width ≠ % of height on 16:9).
const cornerEdgePx = scaleFontToPreview(textPadding, encodeWidth);
// Assume 16:9 when only width is known; side is usually width-driven for lower corner.
const encodeHeight = Math.round((encodeWidth * 9) / 16);
const cornerSidePx = scaleFontToPreview(
lowerCornerCoverSide(encodeWidth, encodeHeight, textPadding),
encodeWidth,
);
switch (template) {
case "COVER_LEFT_TEXT_RIGHT":
return {
@@ -234,6 +255,24 @@ function previewCoverStyle(
maxHeight: "38%",
objectFit: "contain",
};
case "LOWER_LEFT_COVER_TEXT":
return {
position: "absolute",
left: cornerEdgePx,
bottom: cornerEdgePx,
width: cornerSidePx,
height: cornerSidePx,
objectFit: "contain",
};
case "LOWER_RIGHT_COVER_TEXT":
return {
position: "absolute",
right: cornerEdgePx,
bottom: cornerEdgePx,
width: cornerSidePx,
height: cornerSidePx,
objectFit: "contain",
};
}
}
@@ -243,11 +282,20 @@ function previewTextStyle(
textOffsetX: number,
textOffsetY: number,
titleArtistGap: number,
textPadding: number,
encodeWidth: number,
): CSSProperties {
const p = `${padPct}%`;
const shift = {
transform: undefined as string | undefined,
};
const cornerEdgePx = scaleFontToPreview(textPadding, encodeWidth);
const coverTextGapPx = scaleFontToPreview(
Math.max(10, Math.round(textPadding * 0.55)),
encodeWidth,
);
const encodeHeight = Math.round((encodeWidth * 9) / 16);
const cornerSidePx = scaleFontToPreview(
lowerCornerCoverSide(encodeWidth, encodeHeight, textPadding),
encodeWidth,
);
const baseGap = { display: "flex", flexDirection: "column" as const, gap: `${titleArtistGap}px` };
@@ -294,13 +342,38 @@ function previewTextStyle(
textAlign: "center",
alignItems: "center",
};
case "LOWER_LEFT_COVER_TEXT":
return {
...baseGap,
position: "absolute",
left: cornerEdgePx + cornerSidePx + coverTextGapPx + textOffsetX,
right: p,
bottom: cornerEdgePx,
height: cornerSidePx,
justifyContent: "center",
textAlign: "left",
transform: textOffsetY ? `translateY(${textOffsetY}px)` : undefined,
};
case "LOWER_RIGHT_COVER_TEXT":
return {
...baseGap,
position: "absolute",
left: p,
right: cornerEdgePx + cornerSidePx + coverTextGapPx - textOffsetX,
bottom: cornerEdgePx,
height: cornerSidePx,
justifyContent: "center",
textAlign: "right",
alignItems: "flex-end",
transform: textOffsetY ? `translateY(${textOffsetY}px)` : undefined,
};
}
void shift;
}
export function LayoutStudio({
locked,
previewImageUrl,
previewBackgroundUrl = null,
songTitle,
artist,
encodeWidth = 1280,
@@ -310,24 +383,31 @@ export function LayoutStudio({
onWatermarkChange,
onUploadLogo,
onUploadFont,
onUploadBackground,
onClearBackground,
logoPreviewUrl,
hasCustomBackground = false,
}: 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 [bgUploading, setBgUploading] = useState(false);
const [bgError, setBgError] = useState<string | null>(null);
// Always coalesce HMR / older session state may omit newly added fields
const L: LayoutSettings = {
...DEFAULT_LAYOUT,
...layout,
blurFill: layout.blurFill ?? DEFAULT_LAYOUT.blurFill,
blurAmount: layout.blurAmount ?? DEFAULT_LAYOUT.blurAmount,
blurOpacity: layout.blurOpacity ?? BLUR_OPACITY_DEFAULT,
textPadding: layout.textPadding ?? DEFAULT_LAYOUT.textPadding,
titleArtistGap: layout.titleArtistGap ?? DEFAULT_LAYOUT.titleArtistGap,
textOffsetX: layout.textOffsetX ?? DEFAULT_LAYOUT.textOffsetX,
textOffsetY: layout.textOffsetY ?? DEFAULT_LAYOUT.textOffsetY,
titleBold: layout.titleBold ?? DEFAULT_LAYOUT.titleBold,
};
const W: WatermarkSettings = {
...DEFAULT_WATERMARK,
@@ -386,13 +466,34 @@ export function LayoutStudio({
el.id = styleId;
document.head.appendChild(el);
}
el.textContent = CURATED_FONTS.map(
(f) => `@font-face {
font-family: 'S2VIDPreview-${f.key}';
src: url('${curatedFontApiUrl(f.key)}') format('truetype');
el.textContent = [
`@font-face {
font-family: '${SYSTEM_FONT.previewFamily}';
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}') format('truetype');
font-weight: 400;
font-display: swap;
}`,
).join("\n");
`@font-face {
font-family: '${SYSTEM_FONT.previewFamily}';
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}?weight=bold') format('truetype');
font-weight: 600;
font-display: swap;
}`,
`@font-face {
font-family: '${SYSTEM_FONT.previewFamily}';
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}?weight=bold') format('truetype');
font-weight: 700;
font-display: swap;
}`,
...CURATED_FONTS.map(
(f) => `@font-face {
font-family: 'S2VIDPreview-${f.key}';
src: url('${curatedFontApiUrl(f.key)}') format('truetype');
font-weight: 400;
font-display: swap;
}`,
),
].join("\n");
}, []);
useEffect(() => {
@@ -463,6 +564,21 @@ export function LayoutStudio({
}
}
async function handleBackground(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file || locked || !onUploadBackground) return;
setBgError(null);
setBgUploading(true);
try {
await onUploadBackground(file);
} catch (err) {
setBgError(err instanceof Error ? err.message : "Background upload failed");
} finally {
setBgUploading(false);
}
}
function setFontKey(key: WatermarkFontKey) {
if (key === "custom") {
patchWm({ fontKey: "custom", fontPath: watermark.fontPath ?? null });
@@ -472,6 +588,8 @@ export function LayoutStudio({
}
const artTrack = Boolean(L.template);
const lowerCorner = isLowerCornerTemplate(L.template);
const blurPreviewUrl = previewBackgroundUrl || previewImageUrl;
return (
<div
@@ -495,7 +613,7 @@ export function LayoutStudio({
<div className="absolute inset-0 bg-black" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewImageUrl}
src={blurPreviewUrl!}
alt=""
className="absolute inset-0 h-full w-full object-cover"
style={{
@@ -508,7 +626,12 @@ export function LayoutStudio({
<img
src={previewImageUrl}
alt=""
style={previewCoverStyle(L.template!, padPct)}
style={previewCoverStyle(
L.template!,
padPct,
L.textPadding,
encodeWidth,
)}
className="pointer-events-none"
/>
<div
@@ -518,12 +641,17 @@ export function LayoutStudio({
L.textOffsetX,
L.textOffsetY,
L.titleArtistGap,
L.textPadding,
encodeWidth,
)}
>
<p
className="max-w-full truncate font-semibold text-white drop-shadow"
className={`max-w-full truncate text-white drop-shadow ${
L.titleBold ? "font-semibold" : "font-normal"
}`}
style={{
...textFontStyle,
fontWeight: L.titleBold ? 600 : 400,
fontSize: `${titlePreviewPx}px`,
lineHeight: 1.15,
}}
@@ -543,13 +671,28 @@ export function LayoutStudio({
</div>
</>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-black">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewImageUrl}
alt=""
className="max-h-full max-w-full object-contain"
/>
<div className="absolute inset-0 bg-black">
{L.blurFill && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewImageUrl}
alt=""
className="absolute inset-0 h-full w-full object-cover"
style={{
filter: L.blurAmount > 0 ? `blur(${blurPx}px)` : undefined,
transform: "scale(1.15)",
opacity: L.blurOpacity / 100,
}}
/>
)}
<div className="absolute inset-0 flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewImageUrl}
alt=""
className="max-h-full max-w-full object-contain"
/>
</div>
</div>
)}
@@ -595,22 +738,114 @@ export function LayoutStudio({
{/* Art-track templates */}
<p className="mb-2 mt-5 text-sm font-medium text-gray-300">Composition</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
<MiniThumb
template={null}
active={L.template === null}
disabled={locked}
onClick={() => patchLayout({ template: null })}
/>
{LAYOUT_TEMPLATES.map((t) => (
<MiniThumb
key={t}
template={t}
active={L.template === t}
disabled={locked}
onClick={() => patchLayout({ template: t })}
/>
))}
{COMPOSITION_FAMILIES.map((family) => {
const active =
family.id === "classic"
? L.template === null
: family.variants.includes(L.template as LayoutTemplate);
return (
<MiniThumb
key={family.id}
template={family.thumb}
label={family.label}
active={active}
disabled={locked}
onClick={() => {
if (family.id === "classic") {
patchLayout({ template: null });
return;
}
if (
L.template &&
family.variants.includes(L.template as LayoutTemplate)
) {
return;
}
patchLayout({ template: family.defaultTemplate, blurFill: false });
}}
/>
);
})}
</div>
{(() => {
const family = compositionFamilyForTemplate(L.template);
if (family.variants.length < 2) return null;
return (
<div className="mt-2 flex flex-wrap gap-2">
{family.variants.map((variant) => (
<button
key={variant}
type="button"
disabled={locked}
onClick={() => patchLayout({ template: variant })}
className={`rounded border px-2.5 py-1.5 text-[11px] font-medium transition-colors ${
L.template === variant
? "border-accent bg-accent/15 text-white"
: "border-gray-700 bg-black/40 text-gray-300 hover:border-gray-500"
} disabled:cursor-not-allowed disabled:opacity-50`}
>
{LAYOUT_TEMPLATE_LABELS[variant]}
</button>
))}
</div>
);
})()}
{!artTrack && (
<label className="mt-3 flex cursor-pointer items-start gap-2 text-sm text-gray-300">
<input
type="checkbox"
className="mt-0.5"
disabled={locked}
checked={L.blurFill}
onChange={(e) => patchLayout({ blurFill: e.target.checked })}
/>
<span>
<span className="font-medium text-gray-200">Blurred cover background</span>
<span className="mt-0.5 block text-xs text-gray-500">
Fill letterbox bars with a blurred cover instead of black. Adjust blur and opacity
below when enabled.
</span>
</span>
</label>
)}
{lowerCorner && (
<div className="mt-3 rounded border border-gray-700 bg-black/30 p-3">
<p className="mb-2 text-sm font-medium text-gray-300">Background image</p>
<p className="mb-2 text-xs text-gray-500">
Optional. Used as the blurred full-frame fill; the cover stays the sharp corner square.
Leave empty to blur the cover itself.
</p>
<div className="flex flex-wrap items-center gap-2">
<label
className={`cursor-pointer rounded border border-gray-600 bg-surface-dark px-3 py-1.5 text-xs text-gray-200 hover:border-gray-400 ${
locked || bgUploading ? "pointer-events-none opacity-40" : ""
}`}
>
{bgUploading ? "Uploading…" : hasCustomBackground ? "Replace image" : "Upload image"}
<input
type="file"
accept="image/*"
className="hidden"
disabled={locked || bgUploading || !onUploadBackground}
onChange={handleBackground}
/>
</label>
{hasCustomBackground && (
<button
type="button"
disabled={locked}
onClick={() => onClearBackground?.()}
className="rounded border border-gray-700 px-3 py-1.5 text-xs text-gray-400 hover:border-gray-500 hover:text-gray-200 disabled:opacity-40"
>
Use cover as background
</button>
)}
</div>
{bgError && <p className="mt-2 text-xs text-red-400">{bgError}</p>}
</div>
)}
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<label className="text-sm text-gray-400">
@@ -619,7 +854,7 @@ export function LayoutStudio({
type="range"
min={BLUR_AMOUNT_MIN}
max={BLUR_AMOUNT_MAX}
disabled={locked || !artTrack}
disabled={locked || !(artTrack || L.blurFill)}
value={L.blurAmount}
onChange={(e) => patchLayout({ blurAmount: Number(e.target.value) })}
className="mt-1 w-full disabled:opacity-40"
@@ -631,7 +866,7 @@ export function LayoutStudio({
type="range"
min={BLUR_OPACITY_MIN}
max={BLUR_OPACITY_MAX}
disabled={locked || !artTrack}
disabled={locked || !(artTrack || L.blurFill)}
value={L.blurOpacity}
onChange={(e) => patchLayout({ blurOpacity: Number(e.target.value) })}
className="mt-1 w-full disabled:opacity-40"
@@ -687,6 +922,20 @@ export function LayoutStudio({
className="mt-1 w-full disabled:opacity-40"
/>
</label>
<label
className={`flex items-center gap-2 text-sm text-gray-300 sm:col-span-2 ${
locked || !artTrack ? "opacity-40" : ""
}`}
>
<input
type="checkbox"
className="rounded border-gray-600 bg-surface-dark text-accent focus:ring-accent"
disabled={locked || !artTrack}
checked={L.titleBold}
onChange={(e) => patchLayout({ titleBold: e.target.checked })}
/>
Bold song title
</label>
</div>
{/* Typography (matches FFmpeg art-track + watermark text fonts) */}
@@ -700,7 +949,7 @@ export function LayoutStudio({
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
className="input-field mt-1"
>
<option value="system">Arial (system default)</option>
<option value="system">{SYSTEM_FONT.label}</option>
{CURATED_FONTS.map((f) => (
<option key={f.key} value={f.key}>
{f.label}
+30 -1
View File
@@ -8,7 +8,7 @@ import { audioTagsToMetadata } from "@/lib/audio-tags";
import { resolveYouTubeTitle } from "@/lib/titles";
import { SONG_TITLE_MAX } from "@/lib/layout";
import type { ItemMetadata } from "@/lib/types";
import { DEFAULT_LAYOUT, type LayoutSettings } from "@/lib/layout";
import { DEFAULT_LAYOUT, isLowerCornerTemplate, type LayoutSettings } from "@/lib/layout";
import { DEFAULT_WATERMARK, type WatermarkSettings } from "@/lib/watermark";
import { CategorySelect } from "./CategorySelect";
import { LayoutStudio } from "./LayoutStudio";
@@ -65,6 +65,8 @@ export function UploadForm() {
const [jobWatermark, setJobWatermark] = useState<WatermarkSettings>({ ...DEFAULT_WATERMARK });
const [jobLayout, setJobLayout] = useState<LayoutSettings>({ ...DEFAULT_LAYOUT });
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
const [backgroundImagePath, setBackgroundImagePath] = useState<string | null>(null);
const [backgroundPreviewUrl, setBackgroundPreviewUrl] = useState<string | null>(null);
const [quota, setQuota] = useState<{
maxBatchSize: number;
used: number;
@@ -89,6 +91,7 @@ export function UploadForm() {
return () => {
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
if (logoPreviewUrl) URL.revokeObjectURL(logoPreviewUrl);
if (backgroundPreviewUrl) URL.revokeObjectURL(backgroundPreviewUrl);
audioItems.forEach((a) => {
if (a.itemImagePreview) URL.revokeObjectURL(a.itemImagePreview);
});
@@ -258,6 +261,21 @@ export function UploadForm() {
return uploaded.path;
}
async function handleBackgroundUpload(file: File) {
const preview = URL.createObjectURL(file);
if (backgroundPreviewUrl) URL.revokeObjectURL(backgroundPreviewUrl);
setBackgroundPreviewUrl(preview);
const uploaded = await uploadFile(file, "image");
setBackgroundImagePath(uploaded.path);
return uploaded.path;
}
function clearBackgroundImage() {
if (backgroundPreviewUrl) URL.revokeObjectURL(backgroundPreviewUrl);
setBackgroundPreviewUrl(null);
setBackgroundImagePath(null);
}
function applyWatermarkToAll(next: WatermarkSettings) {
const normalized = { ...DEFAULT_WATERMARK, ...next };
setJobWatermark(normalized);
@@ -276,6 +294,9 @@ export function UploadForm() {
function applyLayoutToAll(next: LayoutSettings) {
const normalized = { ...DEFAULT_LAYOUT, ...next };
setJobLayout(normalized);
if (!isLowerCornerTemplate(normalized.template)) {
clearBackgroundImage();
}
setAudioItems((prev) =>
prev.map((item) => ({
...item,
@@ -371,6 +392,10 @@ export function UploadForm() {
includeWatermark: effectiveWatermark.mode !== "none",
watermark: effectiveWatermark,
layout: jobLayout,
backgroundImagePath:
isLowerCornerTemplate(jobLayout.template) && backgroundImagePath
? backgroundImagePath
: null,
},
})),
}),
@@ -620,6 +645,7 @@ export function UploadForm() {
previewImageUrl={
audioItems.find((a) => a.itemImagePreview)?.itemImagePreview || imagePreviewUrl
}
previewBackgroundUrl={backgroundPreviewUrl}
songTitle={
audioItems[0]?.metadata.songTitle ||
audioItems[0]?.metadata.title ||
@@ -633,6 +659,9 @@ export function UploadForm() {
onWatermarkChange={applyWatermarkToAll}
onUploadLogo={handleLogoUpload}
onUploadFont={handleFontUpload}
onUploadBackground={handleBackgroundUpload}
onClearBackground={clearBackgroundImage}
hasCustomBackground={Boolean(backgroundImagePath)}
logoPreviewUrl={logoPreviewUrl}
/>
+33 -6
View File
@@ -11,9 +11,14 @@ 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,
@@ -78,10 +83,7 @@ function previewStyle(
}
function previewFontFamily(fontKey: WatermarkFontKey | undefined): string {
if (!fontKey || fontKey === "system") return "ui-sans-serif, system-ui, sans-serif";
if (fontKey === "custom") return `'${CUSTOM_PREVIEW_FAMILY}', sans-serif`;
const meta = CURATED_FONTS.find((f) => f.key === fontKey);
return meta ? `'${meta.cssFamily}', sans-serif` : "sans-serif";
return previewFontFamilyCss(fontKey);
}
export function WatermarkPreview({
@@ -110,7 +112,32 @@ export function WatermarkPreview({
[value.fontKey],
);
// Load curated Google Fonts for live canvas preview
// 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;
@@ -287,7 +314,7 @@ export function WatermarkPreview({
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
className="input-field mt-1"
>
<option value="system">System default</option>
<option value="system">{SYSTEM_FONT.label}</option>
{CURATED_FONTS.map((f) => (
<option key={f.key} value={f.key} style={{ fontFamily: f.cssFamily }}>
{f.label}