"use client"; import { useEffect, useMemo, useState, type CSSProperties, type ChangeEvent, } from "react"; import { getVideoAttributionText } from "@/lib/branding"; import { CURATED_FONTS, type CuratedFontKey, type WatermarkFontKey, } from "@/lib/fonts"; import { BLUR_AMOUNT_MAX, BLUR_AMOUNT_MIN, BLUR_OPACITY_DEFAULT, BLUR_OPACITY_MAX, BLUR_OPACITY_MIN, 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, type LayoutSettings, type LayoutTemplate, } from "@/lib/layout"; import { WATERMARK_WIDTH_FRACTION, artistFontSizeForWidth, curatedFontApiUrl, previewFontFamilyCss, scaleFontToPreview, titleFontSizeForWidth, watermarkFontSizeForWidth, } from "@/lib/preview-typography"; import { DEFAULT_WATERMARK, WATERMARK_OFFSET_MAX, WATERMARK_OFFSET_MIN, WATERMARK_POSITIONS, WATERMARK_TEXT_MAX, type WatermarkMode, type WatermarkPosition, type WatermarkSettings, } from "@/lib/watermark"; import { UpgradeProButton } from "./UpgradeProButton"; type Props = { locked: boolean; previewImageUrl: string | null; /** On-video song title (art-track layouts). */ songTitle: string; artist: string; /** Encode width from selected resolution (e.g. 1280). */ encodeWidth?: number; layout: LayoutSettings; onLayoutChange: (next: LayoutSettings) => void; watermark: WatermarkSettings; onWatermarkChange: (next: WatermarkSettings) => void; onUploadLogo: (file: File) => Promise; onUploadFont: (file: File) => Promise; logoPreviewUrl?: string | null; }; const WM_POSITION_LABELS: Record = { "top-left": "Top left", "top-right": "Top right", "bottom-left": "Bottom left", "bottom-right": "Bottom right", center: "Center", }; const CUSTOM_PREVIEW_FAMILY = "S2VIDCustomWm"; function previewFontFamily(fontKey: WatermarkFontKey | undefined): string { return previewFontFamilyCss(fontKey); } function watermarkOverlayStyle( position: WatermarkPosition, offsetX: number, offsetY: number, sizedBox = false, ): CSSProperties { const base: CSSProperties = { position: "absolute", maxWidth: `${WATERMARK_WIDTH_FRACTION * 100}%`, pointerEvents: "none", zIndex: 5, ...(sizedBox ? { width: `${WATERMARK_WIDTH_FRACTION * 100}%` } : null), }; 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 MiniThumb({ template, active, onClick, disabled, }: { template: LayoutTemplate | null; active: boolean; onClick: () => void; disabled: boolean; }) { const isClassic = template === null; return ( ); } function previewCoverStyle( template: LayoutTemplate, padPct: number, ): CSSProperties { const p = `${padPct}%`; switch (template) { case "COVER_LEFT_TEXT_RIGHT": return { position: "absolute", left: p, top: "50%", transform: "translateY(-50%)", width: "38%", maxHeight: "72%", objectFit: "contain", }; case "COVER_RIGHT_TEXT_LEFT": return { position: "absolute", right: p, top: "50%", transform: "translateY(-50%)", width: "38%", maxHeight: "72%", objectFit: "contain", }; case "COVER_TOP_TEXT_BOTTOM": return { position: "absolute", left: "50%", top: p, transform: "translateX(-50%)", width: `calc(100% - ${padPct * 2}%)`, maxHeight: "56%", objectFit: "contain", }; case "CENTERED_COMPACT": return { position: "absolute", left: "50%", top: "16%", transform: "translateX(-50%)", width: "38%", maxHeight: "38%", objectFit: "contain", }; } } function previewTextStyle( template: LayoutTemplate, padPct: number, textOffsetX: number, textOffsetY: number, titleArtistGap: number, ): CSSProperties { const p = `${padPct}%`; const shift = { transform: undefined as string | undefined, }; const baseGap = { display: "flex", flexDirection: "column" as const, gap: `${titleArtistGap}px` }; switch (template) { case "COVER_LEFT_TEXT_RIGHT": return { ...baseGap, position: "absolute", left: `calc(48% + ${textOffsetX}px)`, right: p, top: "50%", transform: `translateY(calc(-50% + ${textOffsetY}px))`, textAlign: "left", }; case "COVER_RIGHT_TEXT_LEFT": return { ...baseGap, position: "absolute", left: p, right: `calc(48% - ${textOffsetX}px)`, top: "50%", transform: `translateY(calc(-50% + ${textOffsetY}px))`, textAlign: "left", }; case "COVER_TOP_TEXT_BOTTOM": return { ...baseGap, position: "absolute", left: p, right: p, bottom: `calc(10% - ${textOffsetY}px)`, transform: textOffsetX ? `translateX(${textOffsetX}px)` : undefined, textAlign: "center", alignItems: "center", }; case "CENTERED_COMPACT": return { ...baseGap, position: "absolute", left: p, right: p, top: `calc(58% + ${textOffsetY}px)`, transform: textOffsetX ? `translateX(${textOffsetX}px)` : undefined, textAlign: "center", alignItems: "center", }; } void shift; } export function LayoutStudio({ locked, previewImageUrl, songTitle, artist, encodeWidth = 1280, layout, onLayoutChange, watermark, onWatermarkChange, onUploadLogo, onUploadFont, logoPreviewUrl, }: Props) { const [logoUploading, setLogoUploading] = useState(false); const [logoError, setLogoError] = useState(null); const [fontUploading, setFontUploading] = useState(false); const [fontError, setFontError] = useState(null); const [customFontObjectUrl, setCustomFontObjectUrl] = useState(null); // Always coalesce HMR / older session state may omit newly added fields const L: LayoutSettings = { ...DEFAULT_LAYOUT, ...layout, 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, }; const W: WatermarkSettings = { ...DEFAULT_WATERMARK, ...watermark, text: watermark.text ?? "", offsetX: watermark.offsetX ?? DEFAULT_WATERMARK.offsetX, offsetY: watermark.offsetY ?? DEFAULT_WATERMARK.offsetY, fontKey: watermark.fontKey ?? "system", }; const blurPx = useMemo( () => Math.round((L.blurAmount / 100) * 28), [L.blurAmount], ); const padPct = useMemo( () => 3 + ((L.textPadding - TEXT_PADDING_MIN) / (TEXT_PADDING_MAX - TEXT_PADDING_MIN)) * 5, [L.textPadding], ); const wmOverlay = useMemo( () => watermarkOverlayStyle( W.position, W.offsetX, W.offsetY, W.mode === "default" || W.mode === "logo", ), [W.position, W.offsetX, W.offsetY, W.mode], ); const textFontStyle = useMemo( () => ({ fontFamily: previewFontFamily(W.fontKey) }), [W.fontKey], ); const titlePreviewPx = useMemo( () => scaleFontToPreview(titleFontSizeForWidth(encodeWidth), encodeWidth), [encodeWidth], ); const artistPreviewPx = useMemo( () => scaleFontToPreview(artistFontSizeForWidth(encodeWidth), encodeWidth), [encodeWidth], ); const wmTextPreviewPx = useMemo( () => scaleFontToPreview(watermarkFontSizeForWidth(encodeWidth), encodeWidth), [encodeWidth], ); useEffect(() => { const styleId = "s2vid-preview-curated-fonts"; let el = document.getElementById(styleId) as HTMLStyleElement | null; if (!el) { el = document.createElement("style"); 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'); font-display: swap; }`, ).join("\n"); }, []); 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; }`; }, [customFontObjectUrl]); useEffect(() => { return () => { if (customFontObjectUrl) URL.revokeObjectURL(customFontObjectUrl); }; }, [customFontObjectUrl]); function patchLayout(partial: Partial) { if (locked) return; onLayoutChange({ ...DEFAULT_LAYOUT, ...layout, ...partial }); } function patchWm(partial: Partial) { if (locked) return; onWatermarkChange({ ...DEFAULT_WATERMARK, ...watermark, ...partial }); } async function handleLogo(e: ChangeEvent) { const file = e.target.files?.[0]; e.target.value = ""; if (!file || locked) return; setLogoError(null); setLogoUploading(true); try { const path = await onUploadLogo(file); patchWm({ mode: "logo", logoPath: path }); } catch (err) { setLogoError(err instanceof Error ? err.message : "Logo upload failed"); } finally { setLogoUploading(false); } } async function handleFont(e: ChangeEvent) { 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); patchWm({ 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") { patchWm({ fontKey: "custom", fontPath: watermark.fontPath ?? null }); return; } patchWm({ fontKey: key, fontPath: null }); } const artTrack = Boolean(L.template); return (
{locked && (
Pro

Art-track layouts, blur backgrounds, typography, and watermark studio are Pro features.

)}

Video layout

Pro
{/* Single live preview: art-track + watermark */}
{previewImageUrl ? ( <> {artTrack ? ( <>
{/* eslint-disable-next-line @next/next/no-img-element */} 0 ? `blur(${blurPx}px)` : undefined, transform: "scale(1.15)", opacity: L.blurOpacity / 100, }} /> {/* eslint-disable-next-line @next/next/no-img-element */}

{songTitle.trim() || "Track title"}

{artist.trim() || "Artist"}

) : (
{/* eslint-disable-next-line @next/next/no-img-element */}
)} {W.mode !== "none" && (
{W.mode === "default" ? ( // eslint-disable-next-line @next/next/no-img-element ) : W.mode === "logo" && logoPreviewUrl ? ( // eslint-disable-next-line @next/next/no-img-element ) : W.mode === "text" ? ( {W.text?.trim() ? W.text.trim().slice(0, WATERMARK_TEXT_MAX) : getVideoAttributionText()} ) : null}
)} ) : (
Upload a cover image to preview
)}
{/* Art-track templates */}

Composition

patchLayout({ template: null })} /> {LAYOUT_TEMPLATES.map((t) => ( patchLayout({ template: t })} /> ))}
{/* Typography (matches FFmpeg art-track + watermark text fonts) */}

Typography

{W.fontKey === "custom" && (
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 && (

Uploading font…

)} {fontError &&

{fontError}

}
)} {(W.fontKey === "custom" || (W.fontKey && W.fontKey !== "system" && CURATED_FONTS.some((f) => f.key === (W.fontKey as CuratedFontKey)))) && (

Preview: The quick brown fox jumps over the lazy dog

)}
{/* Watermark section */}

Watermark

{( [ ["none", "No watermark"], ["default", "Songs2VID badge"], ["text", "Custom text"], ["logo", "PNG logo"], ] as const ).map(([mode, label]) => ( ))}
{W.mode === "text" && (
)} {W.mode === "logo" && (
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 && (

Uploading logo…

)} {logoError &&

{logoError}

}
)}

Watermark position

{WATERMARK_POSITIONS.map((pos) => ( ))}
); }