diff --git a/app/api/fonts/[key]/route.ts b/app/api/fonts/[key]/route.ts index 3ad57fc..a13fcfa 100644 --- a/app/api/fonts/[key]/route.ts +++ b/app/api/fonts/[key]/route.ts @@ -1,25 +1,33 @@ import { NextRequest, NextResponse } from "next/server"; import fs from "fs/promises"; -import { CURATED_FONTS, isCuratedFontKey } from "@/lib/fonts"; -import { resolveCuratedFontPath } from "@/lib/fonts-server"; +import { CURATED_FONTS, isCuratedFontKey, SYSTEM_FONT } from "@/lib/fonts"; +import { resolveCuratedFontPath, resolveSystemFontPath } from "@/lib/fonts-server"; export async function GET( _req: NextRequest, { params }: { params: Promise<{ key: string }> }, ) { const { key } = await params; - if (!isCuratedFontKey(key)) { + const fontPath = + key === SYSTEM_FONT.key + ? resolveSystemFontPath() + : isCuratedFontKey(key) + ? resolveCuratedFontPath(key) + : null; + if (!fontPath) { return NextResponse.json({ error: "Unknown font" }, { status: 404 }); } - const fontPath = resolveCuratedFontPath(key); try { const buf = await fs.readFile(fontPath); - const meta = CURATED_FONTS.find((f) => f.key === key)!; + const fileName = + key === SYSTEM_FONT.key + ? SYSTEM_FONT.file + : CURATED_FONTS.find((f) => f.key === key)!.file; return new NextResponse(buf, { headers: { "Content-Type": "font/ttf", - "Content-Disposition": `inline; filename="${meta.file}"`, + "Content-Disposition": `inline; filename="${fileName}"`, "Cache-Control": "public, max-age=86400, immutable", }, }); diff --git a/app/api/v1/route.ts b/app/api/v1/route.ts index fcc0786..6a12a6f 100644 --- a/app/api/v1/route.ts +++ b/app/api/v1/route.ts @@ -1,4 +1,9 @@ import { NextResponse } from "next/server"; +import { + COMPOSITION_FAMILIES, + LAYOUT_TEMPLATE_LABELS, + LAYOUT_TEMPLATES, +} from "@/lib/layout"; import { API_DOCS_URL } from "@/lib/plans"; export async function GET() { @@ -17,6 +22,19 @@ export async function GET() { batch: "POST /api/v1/jobs/batch is for small packs only. Large multipart bodies may fail with 'failed to parse body as FormData'", }, + layoutTemplates: LAYOUT_TEMPLATES.map((id) => ({ + id, + label: LAYOUT_TEMPLATE_LABELS[id], + })), + compositionFamilies: COMPOSITION_FAMILIES.map((family) => ({ + id: family.id, + label: family.label, + defaultTemplate: family.defaultTemplate, + variants: family.variants.map((id) => ({ + id, + label: LAYOUT_TEMPLATE_LABELS[id], + })), + })), endpoints: [ { method: "POST", diff --git a/assets/fonts/Arimo-Bold.ttf b/assets/fonts/Arimo-Bold.ttf new file mode 100644 index 0000000..6453fed Binary files /dev/null and b/assets/fonts/Arimo-Bold.ttf differ diff --git a/assets/fonts/Arimo-Regular.ttf b/assets/fonts/Arimo-Regular.ttf new file mode 100644 index 0000000..d6b78c1 Binary files /dev/null and b/assets/fonts/Arimo-Regular.ttf differ diff --git a/assets/watermark.png b/assets/watermark.png index 3fc2584..756edc2 100644 Binary files a/assets/watermark.png and b/assets/watermark.png differ diff --git a/components/LayoutStudio.tsx b/components/LayoutStudio.tsx index 2e0a286..0082eb4 100644 --- a/components/LayoutStudio.tsx +++ b/components/LayoutStudio.tsx @@ -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; onUploadFont: (file: File) => Promise; + onUploadBackground?: (file: File) => Promise; + onClearBackground?: () => void; logoPreviewUrl?: string | null; + hasCustomBackground?: boolean; }; const WM_POSITION_LABELS: Record = { @@ -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({ ) : (
- {template === "COVER_LEFT_TEXT_RIGHT" && ( + {(template === "COVER_LEFT_TEXT_RIGHT" || + template === "COVER_RIGHT_TEXT_LEFT") && ( <>
)} - {template === "COVER_RIGHT_TEXT_LEFT" && ( - <> -
-
-
- - )} {template === "COVER_TOP_TEXT_BOTTOM" && ( <>
@@ -178,12 +183,18 @@ function MiniThumb({
)} + {(template === "LOWER_LEFT_COVER_TEXT" || + template === "LOWER_RIGHT_COVER_TEXT") && ( + <> +
+
+
+ + )}
)}
-

- {isClassic ? "Classic letterbox" : LAYOUT_TEMPLATE_LABELS[template]} -

+

{label}

); } @@ -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(null); const [fontUploading, setFontUploading] = useState(false); const [fontError, setFontError] = useState(null); const [customFontObjectUrl, setCustomFontObjectUrl] = useState(null); + const [bgUploading, setBgUploading] = useState(false); + const [bgError, setBgError] = useState(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) { + 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 (
{/* eslint-disable-next-line @next/next/no-img-element */}

) : ( -

- {/* eslint-disable-next-line @next/next/no-img-element */} - +
+ {L.blurFill && ( + // 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 */} + +
)} @@ -595,22 +738,114 @@ export function LayoutStudio({ {/* Art-track templates */}

Composition

- patchLayout({ template: null })} - /> - {LAYOUT_TEMPLATES.map((t) => ( - patchLayout({ template: t })} - /> - ))} + {COMPOSITION_FAMILIES.map((family) => { + const active = + family.id === "classic" + ? L.template === null + : family.variants.includes(L.template as LayoutTemplate); + return ( + { + 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 }); + }} + /> + ); + })}
+ {(() => { + const family = compositionFamilyForTemplate(L.template); + if (family.variants.length < 2) return null; + return ( +
+ {family.variants.map((variant) => ( + + ))} +
+ ); + })()} + + {!artTrack && ( + + )} + + {lowerCorner && ( +
+

Background image

+

+ Optional. Used as the blurred full-frame fill; the cover stays the sharp corner square. + Leave empty to blur the cover itself. +

+
+ + {hasCustomBackground && ( + + )} +
+ {bgError &&

{bgError}

} +
+ )}
+
{/* 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" > - + {CURATED_FONTS.map((f) => (