Sync layouts, typography, and watermark studio for self-hosted OSS.
Unlock features without credits/Stripe, point docs to docs.songs2vid.com, and drop leftover billing admin surfaces. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,792 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ChangeEvent,
|
||||
} from "react";
|
||||
import { getVideoAttributionText } from "@/lib/branding";
|
||||
import {
|
||||
CURATED_FONTS,
|
||||
googleFontsStylesheetUrl,
|
||||
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 {
|
||||
DEFAULT_WATERMARK,
|
||||
WATERMARK_OFFSET_MAX,
|
||||
WATERMARK_OFFSET_MIN,
|
||||
WATERMARK_POSITIONS,
|
||||
WATERMARK_TEXT_MAX,
|
||||
type WatermarkMode,
|
||||
type WatermarkPosition,
|
||||
type WatermarkSettings,
|
||||
} from "@/lib/watermark";
|
||||
|
||||
type Props = {
|
||||
locked?: boolean;
|
||||
previewImageUrl: string | null;
|
||||
title: string;
|
||||
artist: string;
|
||||
layout: LayoutSettings;
|
||||
onLayoutChange: (next: LayoutSettings) => void;
|
||||
watermark: WatermarkSettings;
|
||||
onWatermarkChange: (next: WatermarkSettings) => void;
|
||||
onUploadLogo: (file: File) => Promise<string>;
|
||||
onUploadFont: (file: File) => Promise<string>;
|
||||
logoPreviewUrl?: string | null;
|
||||
};
|
||||
|
||||
const WM_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 watermarkOverlayStyle(
|
||||
position: WatermarkPosition,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
): CSSProperties {
|
||||
const base: CSSProperties = {
|
||||
position: "absolute",
|
||||
maxWidth: "32%",
|
||||
pointerEvents: "none",
|
||||
zIndex: 5,
|
||||
};
|
||||
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 {
|
||||
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";
|
||||
}
|
||||
|
||||
function MiniThumb({
|
||||
template,
|
||||
active,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
template: LayoutTemplate | null;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const isClassic = template === null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={`overflow-hidden rounded border text-left transition-colors ${
|
||||
active
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-gray-700 bg-black/40 hover:border-gray-500"
|
||||
} disabled:cursor-not-allowed disabled:opacity-50`}
|
||||
>
|
||||
<div className="relative aspect-video w-full overflow-hidden bg-gradient-to-br from-gray-800 to-gray-950 p-1">
|
||||
{isClassic ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="h-[70%] w-[45%] rounded-sm bg-gray-600" />
|
||||
</div>
|
||||
) : (
|
||||
<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" && (
|
||||
<>
|
||||
<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" />
|
||||
<div className="absolute left-[20%] bottom-[18%] h-1 w-[60%] rounded bg-white/80" />
|
||||
<div className="absolute left-[28%] bottom-[8%] h-0.5 w-[44%] rounded bg-white/50" />
|
||||
</>
|
||||
)}
|
||||
{template === "CENTERED_COMPACT" && (
|
||||
<>
|
||||
<div className="absolute left-[32%] top-[14%] h-[42%] w-[36%] bg-gray-300" />
|
||||
<div className="absolute left-[28%] bottom-[22%] h-1 w-[44%] rounded bg-white/80" />
|
||||
<div className="absolute left-[34%] bottom-[12%] h-0.5 w-[32%] 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>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 = false,
|
||||
previewImageUrl,
|
||||
title,
|
||||
artist,
|
||||
layout,
|
||||
onLayoutChange,
|
||||
watermark,
|
||||
onWatermarkChange,
|
||||
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);
|
||||
|
||||
// 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.position, W.offsetX, W.offsetY],
|
||||
);
|
||||
|
||||
const textFontStyle = useMemo(
|
||||
() => ({ fontFamily: previewFontFamily(W.fontKey) }),
|
||||
[W.fontKey],
|
||||
);
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
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<LayoutSettings>) {
|
||||
if (locked) return;
|
||||
onLayoutChange({ ...DEFAULT_LAYOUT, ...layout, ...partial });
|
||||
}
|
||||
|
||||
function patchWm(partial: Partial<WatermarkSettings>) {
|
||||
if (locked) return;
|
||||
onWatermarkChange({ ...DEFAULT_WATERMARK, ...watermark, ...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);
|
||||
patchWm({ 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);
|
||||
patchWm({ 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") {
|
||||
patchWm({ fontKey: "custom", fontPath: watermark.fontPath ?? null });
|
||||
return;
|
||||
}
|
||||
patchWm({ fontKey: key, fontPath: null });
|
||||
}
|
||||
|
||||
const artTrack = Boolean(L.template);
|
||||
|
||||
return (
|
||||
<div className="relative rounded-lg border border-gray-700 bg-surface p-6">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-lg font-medium text-white">Video layout</h3>
|
||||
</div>
|
||||
|
||||
{/* Single live preview: art-track + watermark */}
|
||||
<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 ? (
|
||||
<>
|
||||
{artTrack ? (
|
||||
<>
|
||||
<div className="absolute inset-0 bg-black" />
|
||||
{/* 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,
|
||||
}}
|
||||
/>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={previewImageUrl}
|
||||
alt=""
|
||||
style={previewCoverStyle(L.template!, padPct)}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<div
|
||||
style={previewTextStyle(
|
||||
L.template!,
|
||||
padPct,
|
||||
L.textOffsetX,
|
||||
L.textOffsetY,
|
||||
L.titleArtistGap,
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className="max-w-full truncate text-sm font-semibold text-white drop-shadow"
|
||||
style={
|
||||
W.mode === "text" ? textFontStyle : undefined
|
||||
}
|
||||
>
|
||||
{title.trim() || "Track title"}
|
||||
</p>
|
||||
<p
|
||||
className="max-w-full truncate text-xs text-white/75 drop-shadow"
|
||||
style={
|
||||
W.mode === "text" ? textFontStyle : undefined
|
||||
}
|
||||
>
|
||||
{artist.trim() || "Artist"}
|
||||
</p>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{W.mode !== "none" && (
|
||||
<div style={wmOverlay} className="rounded bg-black/40 px-2 py-1">
|
||||
{W.mode === "logo" && logoPreviewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={logoPreviewUrl}
|
||||
alt=""
|
||||
className="max-h-14 w-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-[11px] font-medium text-white/90 drop-shadow"
|
||||
style={W.mode === "text" ? textFontStyle : undefined}
|
||||
>
|
||||
{W.mode === "text" && W.text?.trim()
|
||||
? W.text.trim().slice(0, WATERMARK_TEXT_MAX)
|
||||
: getVideoAttributionText()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-gray-600">
|
||||
Upload a cover image to preview
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<label className="text-sm text-gray-400">
|
||||
Background blur ({L.blurAmount}%)
|
||||
<input
|
||||
type="range"
|
||||
min={BLUR_AMOUNT_MIN}
|
||||
max={BLUR_AMOUNT_MAX}
|
||||
disabled={locked || !artTrack}
|
||||
value={L.blurAmount}
|
||||
onChange={(e) => patchLayout({ blurAmount: Number(e.target.value) })}
|
||||
className="mt-1 w-full disabled:opacity-40"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-gray-400">
|
||||
Background opacity ({L.blurOpacity}%)
|
||||
<input
|
||||
type="range"
|
||||
min={BLUR_OPACITY_MIN}
|
||||
max={BLUR_OPACITY_MAX}
|
||||
disabled={locked || !artTrack}
|
||||
value={L.blurOpacity}
|
||||
onChange={(e) => patchLayout({ blurOpacity: Number(e.target.value) })}
|
||||
className="mt-1 w-full disabled:opacity-40"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-gray-400">
|
||||
Cover / edge padding ({L.textPadding}px)
|
||||
<input
|
||||
type="range"
|
||||
min={TEXT_PADDING_MIN}
|
||||
max={TEXT_PADDING_MAX}
|
||||
disabled={locked || !artTrack}
|
||||
value={L.textPadding}
|
||||
onChange={(e) => patchLayout({ textPadding: Number(e.target.value) })}
|
||||
className="mt-1 w-full disabled:opacity-40"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-gray-400">
|
||||
Title ↔ artist gap ({L.titleArtistGap}px)
|
||||
<input
|
||||
type="range"
|
||||
min={TITLE_ARTIST_GAP_MIN}
|
||||
max={TITLE_ARTIST_GAP_MAX}
|
||||
disabled={locked || !artTrack}
|
||||
value={L.titleArtistGap}
|
||||
onChange={(e) =>
|
||||
patchLayout({ titleArtistGap: Number(e.target.value) })
|
||||
}
|
||||
className="mt-1 w-full disabled:opacity-40"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-gray-400">
|
||||
Text horizontal ({L.textOffsetX}px)
|
||||
<input
|
||||
type="range"
|
||||
min={TEXT_OFFSET_MIN}
|
||||
max={TEXT_OFFSET_MAX}
|
||||
disabled={locked || !artTrack}
|
||||
value={L.textOffsetX}
|
||||
onChange={(e) => patchLayout({ textOffsetX: Number(e.target.value) })}
|
||||
className="mt-1 w-full disabled:opacity-40"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-gray-400 sm:col-span-2">
|
||||
Text vertical ({L.textOffsetY}px)
|
||||
<input
|
||||
type="range"
|
||||
min={TEXT_OFFSET_MIN}
|
||||
max={TEXT_OFFSET_MAX}
|
||||
disabled={locked || !artTrack}
|
||||
value={L.textOffsetY}
|
||||
onChange={(e) => patchLayout({ textOffsetY: Number(e.target.value) })}
|
||||
className="mt-1 w-full disabled:opacity-40"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Watermark section */}
|
||||
<div className="mt-6 border-t border-gray-800 pt-5">
|
||||
<p className="mb-3 text-sm font-medium text-gray-300">Watermark</p>
|
||||
<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={() => patchWm({ mode: mode as WatermarkMode })}
|
||||
className={`rounded border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
W.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>
|
||||
|
||||
{W.mode === "text" && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<label className="block text-sm text-gray-400">
|
||||
Watermark text
|
||||
<input
|
||||
type="text"
|
||||
maxLength={WATERMARK_TEXT_MAX}
|
||||
disabled={locked}
|
||||
value={W.text ?? ""}
|
||||
onChange={(e) => patchWm({ 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={W.fontKey ?? "system"}
|
||||
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
|
||||
className="input-field mt-1"
|
||||
>
|
||||
<option value="system">System default</option>
|
||||
{CURATED_FONTS.map((f) => (
|
||||
<option key={f.key} value={f.key}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">Custom upload (.ttf / .otf)</option>
|
||||
</select>
|
||||
</label>
|
||||
{W.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>}
|
||||
</div>
|
||||
)}
|
||||
{(W.fontKey === "custom" ||
|
||||
(W.fontKey &&
|
||||
W.fontKey !== "system" &&
|
||||
CURATED_FONTS.some(
|
||||
(f) => f.key === (W.fontKey as CuratedFontKey),
|
||||
))) && (
|
||||
<p className="text-xs text-gray-500" style={textFontStyle}>
|
||||
Preview: The quick brown fox jumps over the lazy dog
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{W.mode === "logo" && (
|
||||
<div className="mt-3">
|
||||
<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 className="mt-4">
|
||||
<p className="mb-2 text-sm text-gray-400">Watermark 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 || W.mode === "none"}
|
||||
onClick={() => patchWm({ position: pos })}
|
||||
className={`rounded border px-2 py-2 text-[11px] font-medium ${
|
||||
W.position === pos
|
||||
? "border-accent bg-accent/15 text-accent"
|
||||
: "border-gray-700 text-gray-400"
|
||||
} disabled:opacity-40`}
|
||||
>
|
||||
{WM_POSITION_LABELS[pos]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<label className="text-sm text-gray-400">
|
||||
Watermark offset X ({W.offsetX}px)
|
||||
<input
|
||||
type="range"
|
||||
min={WATERMARK_OFFSET_MIN}
|
||||
max={WATERMARK_OFFSET_MAX}
|
||||
disabled={locked || W.mode === "none"}
|
||||
value={W.offsetX}
|
||||
onChange={(e) => patchWm({ offsetX: Number(e.target.value) })}
|
||||
className="mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-gray-400">
|
||||
Watermark offset Y ({W.offsetY}px)
|
||||
<input
|
||||
type="range"
|
||||
min={WATERMARK_OFFSET_MIN}
|
||||
max={WATERMARK_OFFSET_MAX}
|
||||
disabled={locked || W.mode === "none"}
|
||||
value={W.offsetY}
|
||||
onChange={(e) => patchWm({ offsetY: Number(e.target.value) })}
|
||||
className="mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user