Initial OSS scaffold from Songs2VID (pre-strip)

This commit is contained in:
Songs2VID OSS
2026-08-03 06:15:05 +02:00
commit 7d7043b150
195 changed files with 25023 additions and 0 deletions
+874
View File
@@ -0,0 +1,874 @@
"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<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 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 (
<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,
previewImageUrl,
songTitle,
artist,
encodeWidth = 1280,
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.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<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({ 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 ${
locked ? "opacity-80" : ""
}`}
>
{locked && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 rounded-lg bg-black/55 px-4 text-center backdrop-blur-[1px]">
<span className="rounded border border-accent/50 bg-accent/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-accent">
Pro
</span>
<p className="max-w-sm text-sm text-gray-200">
Art-track layouts, blur backgrounds, typography, and watermark studio are Pro features.
</p>
<UpgradeProButton
label="Unlock video layout studio"
className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
/>
</div>
)}
<div className="mb-4 flex flex-wrap items-center gap-2">
<h3 className="text-lg font-medium text-white">Video layout</h3>
<span className="rounded border border-accent/40 bg-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-accent">
Pro
</span>
</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 font-semibold text-white drop-shadow"
style={{
...textFontStyle,
fontSize: `${titlePreviewPx}px`,
lineHeight: 1.15,
}}
>
{songTitle.trim() || "Track title"}
</p>
<p
className="max-w-full truncate text-white/75 drop-shadow"
style={{
...textFontStyle,
fontSize: `${artistPreviewPx}px`,
lineHeight: 1.15,
}}
>
{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}>
{W.mode === "default" ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src="/api/branding/watermark"
alt=""
className="h-auto w-full object-contain"
/>
) : W.mode === "logo" && logoPreviewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={logoPreviewUrl}
alt=""
className="h-auto w-full object-contain"
/>
) : W.mode === "text" ? (
<span
className="font-medium text-white/90 drop-shadow"
style={{
...textFontStyle,
fontSize: `${wmTextPreviewPx}px`,
}}
>
{W.text?.trim()
? W.text.trim().slice(0, WATERMARK_TEXT_MAX)
: getVideoAttributionText()}
</span>
) : null}
</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>
{/* Typography (matches FFmpeg art-track + watermark text fonts) */}
<div className="mt-6 border-t border-gray-800 pt-5">
<p className="mb-3 text-sm font-medium text-gray-300">Typography</p>
<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">Arial (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 className="mt-3">
<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="mt-2 text-xs text-gray-500"
style={{ ...textFontStyle, fontSize: `${scaleFontToPreview(16, encodeWidth)}px` }}
>
Preview: The quick brown fox jumps over the lazy dog
</p>
)}
</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>
</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>
);
}