From d951ecb4898a330cd7076db6e65ba8b498ba58d8 Mon Sep 17 00:00:00 2001 From: Songs2YT Date: Sat, 25 Jul 2026 03:50:06 +0200 Subject: [PATCH] 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 --- .env.example | 13 +- .gitignore | 2 +- Dockerfile | 3 +- README.md | 81 +- .../api-rate-limit-extension-request/route.ts | 51 -- app/api/account/cancel-subscription/route.ts | 30 - .../account/quota-extension-request/route.ts | 36 - .../quota-extension-request/[id]/route.ts | 53 -- app/api/upload/route.ts | 21 +- app/api/v1/route.ts | 11 +- app/api/v1/upload/route.ts | 16 +- app/dashboard/api-docs/page.tsx | 219 ----- app/dashboard/settings/page.tsx | 133 +-- app/page.tsx | 34 +- assets/fonts/Inter-Regular.ttf | Bin 0 -> 876576 bytes assets/fonts/Montserrat-Regular.ttf | Bin 0 -> 744936 bytes assets/fonts/Oswald-Regular.ttf | Bin 0 -> 172088 bytes assets/fonts/PlayfairDisplay-Regular.ttf | Bin 0 -> 300724 bytes assets/fonts/README.md | 4 + assets/fonts/Roboto-Regular.ttf | Bin 0 -> 488584 bytes components/ApiKeySettings.tsx | 154 +--- components/BenefitsSection.tsx | 8 +- components/Footer.tsx | 23 +- components/JobProgress.tsx | 16 +- components/LandingNavbar.tsx | 72 +- components/LayoutStudio.tsx | 792 ++++++++++++++++++ components/PlanBillingActions.tsx | 199 ----- components/PlaylistSelect.tsx | 10 +- components/PricingSection.tsx | 330 -------- components/UpgradeProLink.tsx | 33 - components/UploadForm.tsx | 273 ++++-- components/WatermarkPreview.tsx | 397 +++++++++ docker-compose.yml | 4 +- lib/api-auth.ts | 4 +- lib/branding.ts | 2 +- lib/edition.ts | 14 +- lib/entitlements.ts | 41 + lib/ffmpeg/encode.ts | 235 +++++- lib/fonts-server.ts | 44 + lib/fonts.ts | 75 ++ lib/jobs/create-job.ts | 364 +++++++- lib/layout.ts | 417 +++++++++ lib/plans.ts | 69 +- lib/quota-extensions.ts | 186 ---- lib/quota.ts | 299 ++----- lib/types.ts | 42 + lib/watermark.ts | 192 +++++ next.config.ts | 14 + package-lock.json | 25 +- package.json | 2 +- prisma/schema.prisma | 77 +- scripts/fetch-watermark-fonts.mjs | 71 ++ worker/index.ts | 44 +- 53 files changed, 3258 insertions(+), 1977 deletions(-) delete mode 100644 app/api/account/api-rate-limit-extension-request/route.ts delete mode 100644 app/api/account/cancel-subscription/route.ts delete mode 100644 app/api/account/quota-extension-request/route.ts delete mode 100644 app/api/admin/quota-extension-request/[id]/route.ts delete mode 100644 app/dashboard/api-docs/page.tsx create mode 100644 assets/fonts/Inter-Regular.ttf create mode 100644 assets/fonts/Montserrat-Regular.ttf create mode 100644 assets/fonts/Oswald-Regular.ttf create mode 100644 assets/fonts/PlayfairDisplay-Regular.ttf create mode 100644 assets/fonts/README.md create mode 100644 assets/fonts/Roboto-Regular.ttf create mode 100644 components/LayoutStudio.tsx delete mode 100644 components/PlanBillingActions.tsx delete mode 100644 components/PricingSection.tsx delete mode 100644 components/UpgradeProLink.tsx create mode 100644 components/WatermarkPreview.tsx create mode 100644 lib/entitlements.ts create mode 100644 lib/fonts-server.ts create mode 100644 lib/fonts.ts create mode 100644 lib/layout.ts delete mode 100644 lib/quota-extensions.ts create mode 100644 lib/watermark.ts create mode 100644 scripts/fetch-watermark-fonts.mjs diff --git a/.env.example b/.env.example index e65c022..cb418df 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,12 @@ NEXTAUTH_SECRET="replace-with-a-long-random-secret" GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com" GOOGLE_CLIENT_SECRET="your-google-client-secret" UPLOAD_DIR="./uploads" -S2YT_EDITION="selfhosted" -NEXT_PUBLIC_GITEA_ISSUES_URL="https://git.atakanozban.com/Songs2YT/songs2yt/issues" -NEXT_PUBLIC_GITEA_URL="https://git.atakanozban.com/Songs2YT/songs2yt" -NEXT_PUBLIC_DOCKER_HUB_URL="https://hub.docker.com/r/atakanozban/songs2yt" +# Self-hosted OSS edition (unlocked features, no Stripe / credits) +S2VID_EDITION="selfhosted" +# Optional legacy alias (also accepted by lib/edition.ts): +# S2YT_EDITION="selfhosted" +# Public docs (default: https://docs.songs2vid.com) +# NEXT_PUBLIC_DOCS_URL="https://docs.songs2vid.com" +NEXT_PUBLIC_GITEA_ISSUES_URL="https://git.atakanozban.com/Songs2VID/songs2vid/issues" +NEXT_PUBLIC_GITEA_URL="https://git.atakanozban.com/Songs2VID/songs2vid" +NEXT_PUBLIC_DOCKER_HUB_URL="https://hub.docker.com/r/atakanozban/songs2vid" diff --git a/.gitignore b/.gitignore index 672c1b2..8c3877b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,5 @@ website/.docusaurus/ .DS_Store basibozuk_cover.jpg bg-video/ -scripts/ _restore/ +tsconfig.tsbuildinfo diff --git a/Dockerfile b/Dockerfile index c08491a..280da81 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ FROM node:22-bookworm-slim AS runner WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 -ENV S2YT_EDITION=selfhosted +ENV S2VID_EDITION=selfhosted ENV UPLOAD_DIR=/app/uploads RUN apt-get update && apt-get install -y --no-install-recommends \ openssl ca-certificates ffmpeg \ @@ -36,6 +36,7 @@ COPY --from=builder /app/package-lock.json ./package-lock.json COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/assets ./assets COPY --from=builder /app/worker ./worker COPY --from=builder /app/lib ./lib COPY --from=builder /app/tsconfig.json ./tsconfig.json diff --git a/README.md b/README.md index 9778477..2b12983 100644 --- a/README.md +++ b/README.md @@ -8,82 +8,79 @@

- Turn your cover art + audio files into YouTube videos automatically. No manual rendering BS.
- The ultimate life-saver for creators who waste hours opening Premiere/Vegas just to upload a single track. + Turn your cover art + audio files into YouTube videos automatically.
+ Art-track layouts, blur backgrounds, typography, watermark studio, and per-track covers β€” unlocked for self-hosting.

--- -## πŸ”₯ What's the deal? +## What's the deal? -**Songs2VID** is a self-hosted automation beast that takes your audio files (MP3/WAV) and cover images, smashes them together with FFmpeg in a background queue, and uploads them straight to your YouTube channel. +**Songs2VID** (npm package `songs2yt`) is a self-hosted automation app that takes your audio files (MP3/WAV/FLAC) and cover images, encodes them with FFmpeg in a background queue, and uploads them to your YouTube channel. This is the **open-source (self-hosted) edition**: -* 🚫 **Zero limits:** No video upload quotas, no API rate limit bullshit. -* πŸ”“ **Fully Unlocked:** Playlists and REST API are completely unlocked (`S2VID_EDITION=selfhosted`). -* ☁️ **Full Control:** Run it on your own server. Your data, your rules. +* **Zero practical limits:** no video quotas, credits, or Stripe paywalls +* **Fully unlocked:** playlists, REST API, art-track layouts, custom watermarks/fonts, per-track covers (`S2VID_EDITION=selfhosted`) +* **Your infra:** run it on your own server -> Check out the hosted version: [songs2vid.com](https://songs2vid.com) +> Hosted cloud product: [songs2vid.com](https://songs2vid.com) Β· Docs: [docs.songs2vid.com](https://docs.songs2vid.com) Β· API: [docs.songs2vid.com/docs/api/overview](https://docs.songs2vid.com/docs/api/overview) ![Songs2VID](https://www.atakanozban.com/Content/uploads/projects-media/f59f2b3c290042ceb8d12fe9395b0dda.png) + --- -## πŸ› οΈ Requirements +## Requirements -* **Docker & Docker Compose** (highly recommended, saves your mental health) +* **Docker & Docker Compose** (recommended) * *Or:* Node 20+, Postgres, Redis, FFmpeg * Google Cloud OAuth client with **YouTube Data API v3** enabled --- -## πŸš€ Quick Start (Docker) - -Skip the setup headache. Copy the env, spin it up, and go touch grass: +## Quick Start (Docker) ```bash -# Copy env template cp .env.example .env - # Fill in GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, NEXTAUTH_SECRET, and NEXTAUTH_URL -nano .env - -# Fire up the engine docker compose up -d --build ``` + * Open `http://localhost:3000` -* OAuth Redirect URL on Google Console: `{NEXTAUTH_URL}/api/auth/callback/google` +* OAuth redirect URL: `{NEXTAUTH_URL}/api/auth/callback/google` +* `S2VID_EDITION=selfhosted` is set by compose (no Stripe keys required) -## πŸ’» Local Development - -Want to hack on the codebase? Follow this: +## Local Development ```bash -# 1. Prepare env cp .env.example .env - -# 2. Spin up Postgres & Redis docker compose -f docker-compose.dev.yml up -d - -# 3. Get dependencies npm install - -# 4. Push database schema npm run db:push - -# 5. Run it (open two terminals) -npm run dev # Terminal 1 (Next.js) -npm run worker # Terminal 2 (BullMQ/FFmpeg Worker) +npm run dev # Terminal 1 +npm run worker # Terminal 2 ``` -## πŸ—οΈ Stack +Optional: fetch curated watermark fonts into `assets/fonts/` with `node scripts/fetch-watermark-fonts.mjs`. -* Next.js (Frontend & API) -* Postgres (Database) -* Redis / BullMQ (Background render queue) -* NextAuth (Google OAuth) -* FFmpeg (The engine merging audio + image) -* YouTube Data API (The rocket launcher pushing to YT) +## Features -## πŸ“„ License +* Batch audio β†’ YouTube video jobs +* Art-track layout templates + blur background + fine-tuning offsets +* Watermark studio (text / logo / curated + custom fonts) +* Per-track cover images and artist field +* YouTube playlists + REST API (`/api/v1`) -This project is licensed under the MIT License. \ No newline at end of file +## Stack + +* Next.js Β· Postgres Β· Redis / BullMQ Β· NextAuth (Google) Β· FFmpeg Β· YouTube Data API + +## Links + +* Docs: https://docs.songs2vid.com +* Hosted: https://songs2vid.com +* Gitea: https://git.atakanozban.com/Songs2YT/songs2yt +* Docker Hub: https://hub.docker.com/r/atakanozban/songs2yt + +## License + +MIT diff --git a/app/api/account/api-rate-limit-extension-request/route.ts b/app/api/account/api-rate-limit-extension-request/route.ts deleted file mode 100644 index 78c95ca..0000000 --- a/app/api/account/api-rate-limit-extension-request/route.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { - createQuotaExtensionRequest, - EXTENSION_KIND, - getQuotaExtensionUsage, -} from "@/lib/quota-extensions"; -import { hasProFeatures } from "@/lib/edition"; -import { getSessionUser } from "@/lib/session"; - -export async function GET() { - const user = await getSessionUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - if (!hasProFeatures(user.plan)) { - return NextResponse.json({ error: "Pro plan required" }, { status: 403 }); - } - - const usage = await getQuotaExtensionUsage(user.id, EXTENSION_KIND.API_RATE_LIMIT); - return NextResponse.json(usage); -} - -export async function POST(req: NextRequest) { - const user = await getSessionUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - if (!hasProFeatures(user.plan)) { - return NextResponse.json({ error: "Pro plan required" }, { status: 403 }); - } - - let message = ""; - try { - const body = await req.json(); - if (typeof body.message === "string") message = body.message; - } catch { - // optional body - } - - try { - const result = await createQuotaExtensionRequest( - user.id, - message, - EXTENSION_KIND.API_RATE_LIMIT, - ); - return NextResponse.json(result); - } catch (err) { - const msg = err instanceof Error ? err.message : "Failed to submit request"; - return NextResponse.json({ error: msg }, { status: 400 }); - } -} diff --git a/app/api/account/cancel-subscription/route.ts b/app/api/account/cancel-subscription/route.ts deleted file mode 100644 index 5c40f1c..0000000 --- a/app/api/account/cancel-subscription/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { NextResponse } from "next/server"; -import { prisma } from "@/lib/db"; -import { getInitialQuotaResetAt } from "@/lib/quota"; -import { getSessionUser } from "@/lib/session"; - -export async function POST() { - const user = await getSessionUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - if (user.plan !== "PREMIUM") { - return NextResponse.json({ error: "No active subscription to cancel" }, { status: 400 }); - } - - await prisma.user.update({ - where: { id: user.id }, - data: { - plan: "FREE", - cardLast4: null, - subscribedAt: null, - apiKeyHash: null, - apiKeyPrefix: null, - apiRateLimitBonus: 0, - quotaResetAt: getInitialQuotaResetAt(), - }, - }); - - return NextResponse.json({ ok: true }); -} diff --git a/app/api/account/quota-extension-request/route.ts b/app/api/account/quota-extension-request/route.ts deleted file mode 100644 index 2111378..0000000 --- a/app/api/account/quota-extension-request/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { createQuotaExtensionRequest, getQuotaExtensionUsage } from "@/lib/quota-extensions"; -import { getSessionUser } from "@/lib/session"; - -export async function GET() { - const user = await getSessionUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const usage = await getQuotaExtensionUsage(user.id); - return NextResponse.json(usage); -} - -export async function POST(req: NextRequest) { - const user = await getSessionUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - let message = ""; - try { - const body = await req.json(); - if (typeof body.message === "string") message = body.message; - } catch { - // optional body - } - - try { - const result = await createQuotaExtensionRequest(user.id, message); - return NextResponse.json(result); - } catch (err) { - const msg = err instanceof Error ? err.message : "Failed to submit request"; - return NextResponse.json({ error: msg }, { status: 400 }); - } -} diff --git a/app/api/admin/quota-extension-request/[id]/route.ts b/app/api/admin/quota-extension-request/[id]/route.ts deleted file mode 100644 index 48bdde5..0000000 --- a/app/api/admin/quota-extension-request/[id]/route.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { - approveQuotaExtensionRequest, - rejectQuotaExtensionRequest, -} from "@/lib/quota-extensions"; - -function isAuthorized(req: NextRequest) { - const key = process.env.ADMIN_API_KEY; - if (!key) return false; - const auth = req.headers.get("authorization"); - return auth === `Bearer ${key}`; -} - -export async function POST( - req: NextRequest, - { params }: { params: Promise<{ id: string }> }, -) { - if (!isAuthorized(req)) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { id } = await params; - let action: "approve" | "reject" = "approve"; - let bonusQuota: number | undefined; - let bonusRateLimit: number | undefined; - let adminNote: string | undefined; - - try { - const body = await req.json(); - if (body.action === "reject") action = "reject"; - if (typeof body.bonusQuota === "number" && Number.isFinite(body.bonusQuota)) { - bonusQuota = body.bonusQuota; - } - if (typeof body.bonusRateLimit === "number" && Number.isFinite(body.bonusRateLimit)) { - bonusRateLimit = body.bonusRateLimit; - } - if (typeof body.adminNote === "string") adminNote = body.adminNote; - } catch { - // defaults - } - - try { - if (action === "reject") { - await rejectQuotaExtensionRequest(id, adminNote); - } else { - await approveQuotaExtensionRequest(id, { bonusQuota, bonusRateLimit, adminNote }); - } - return NextResponse.json({ ok: true }); - } catch (err) { - const msg = err instanceof Error ? err.message : "Failed to process request"; - return NextResponse.json({ error: msg }, { status: 400 }); - } -} diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 8262965..1abdf42 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { saveUploadedFile } from "@/lib/jobs/create-job"; +import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements"; +import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job"; import { getSessionUser } from "@/lib/session"; export const maxDuration = 120; @@ -15,14 +16,26 @@ export async function POST(req: NextRequest) { const type = formData.get("type") as string | null; const sessionKey = (formData.get("session") as string | null)?.trim() || undefined; - if (!file || !type || (type !== "image" && type !== "audio")) { - return NextResponse.json({ error: "Missing file or type" }, { status: 400 }); + if ( + !file || + !type || + (type !== "image" && type !== "audio" && type !== "logo" && type !== "font") + ) { + return NextResponse.json( + { error: "Missing file or type (image | audio | logo | font)" }, + { status: 400 }, + ); } try { - const result = await saveUploadedFile(user.id, file, type, user.plan, { sessionKey }); + const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan, { + sessionKey, + }); return NextResponse.json(result); } catch (err) { + if (err instanceof PremiumRequiredError) { + return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 }); + } const message = err instanceof Error ? err.message : "Upload failed"; return NextResponse.json({ error: message }, { status: 400 }); } diff --git a/app/api/v1/route.ts b/app/api/v1/route.ts index f00f845..32bfcf9 100644 --- a/app/api/v1/route.ts +++ b/app/api/v1/route.ts @@ -1,12 +1,13 @@ import { NextResponse } from "next/server"; +import { API_DOCS_URL } from "@/lib/plans"; export async function GET() { return NextResponse.json({ - name: "Songs2YT API", + name: "Songs2VID API", version: "1.0", authentication: "Authorization: Bearer ", - requirements: ["Pro subscription", "YouTube account connected"], - rateLimit: "60 requests per minute per account (plus any approved bonus)", + requirements: ["Self-hosted edition", "YouTube account connected"], + rateLimit: "Generous self-hosted limits (see docs)", guidance: { recommended: "For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/jobs with the returned paths", @@ -36,7 +37,7 @@ export async function GET() { { method: "GET", path: "/api/v1/playlists", - description: "List YouTube playlists for the authenticated Pro account", + description: "List YouTube playlists for the authenticated account", }, { method: "POST", @@ -56,6 +57,6 @@ export async function GET() { description: "Get job status and item details", }, ], - docs: "/dashboard/api-docs", + docs: API_DOCS_URL, }); } diff --git a/app/api/v1/upload/route.ts b/app/api/v1/upload/route.ts index 454b12a..2809834 100644 --- a/app/api/v1/upload/route.ts +++ b/app/api/v1/upload/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { requirePaidApiUser } from "@/lib/api-auth"; -import { saveUploadedFile } from "@/lib/jobs/create-job"; +import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements"; +import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job"; export const maxDuration = 120; @@ -12,17 +13,24 @@ export async function POST(req: NextRequest) { const file = formData.get("file") as File | null; const type = formData.get("type") as string | null; - if (!file || !type || (type !== "image" && type !== "audio")) { + if ( + !file || + !type || + (type !== "image" && type !== "audio" && type !== "logo" && type !== "font") + ) { return NextResponse.json( - { error: "Provide multipart fields: file, type (image|audio)" }, + { error: "Provide multipart fields: file, type (image|audio|logo|font)" }, { status: 400 }, ); } try { - const result = await saveUploadedFile(user.id, file, type, user.plan); + const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan); return NextResponse.json(result); } catch (err) { + if (err instanceof PremiumRequiredError) { + return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 }); + } const message = err instanceof Error ? err.message : "Upload failed"; return NextResponse.json({ error: message }, { status: 400 }); } diff --git a/app/dashboard/api-docs/page.tsx b/app/dashboard/api-docs/page.tsx deleted file mode 100644 index 6f13ade..0000000 --- a/app/dashboard/api-docs/page.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import Link from "next/link"; -import { redirect } from "next/navigation"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; -import { hasProFeatures } from "@/lib/edition"; -import { prisma } from "@/lib/db"; -import { DashboardShell } from "@/components/DashboardShell"; -import { UpgradeProLink } from "@/components/UpgradeProLink"; - -function CodeBlock({ children }: { children: string }) { - return ( -
-      {children}
-    
- ); -} - -export default async function ApiDocsPage() { - const session = await getServerSession(authOptions); - if (!session?.user?.email) redirect("/"); - - const user = await prisma.user.findUnique({ - where: { email: session.user.email }, - include: { youtubeConnection: true }, - }); - if (!user) redirect("/"); - - const isPro = hasProFeatures(user.plan); - - return ( - -
-
- - ← Back to settings - -

REST API

-

- Programmatic uploads and batch jobs for Pro subscribers. Generate your API key in{" "} - - Settings - - . -

- {!isPro && ( -

- API access requires Pro. -

- )} -
- -
-

Authentication

-

- Send your API key on every request. Keys start with s2yt_live_. -

- {`Authorization: Bearer s2yt_live_your_key_here`} -
- -
-

Rate limits

-

- Default is 60 requests per minute per account. Approved rate-limit extensions increase - that ceiling. Check live usage and request an extension in{" "} - - Settings β†’ API access - - . Video quota limits from your Pro plan still apply to job creation. -

-
- -
-

Choosing a flow

-
-

- Recommended for most jobs (especially 5+ audio files):{" "} - two-step: upload each file with POST /api/v1/upload, then - create the job with POST /api/v1/jobs (JSON). -

-

- One-shot batch{" "} - (POST /api/v1/jobs/batch) is for small packs only, typically - 1 cover + up to a few audio files. Large multipart bodies often fail with{" "} - failed to parse body as FormData. Prefer two-step for albums - or long tracklists. -

-
    -
  • Do not set Content-Type manually for multipart; the client must include the boundary.
  • -
  • For Postman: Body β†’ form-data, each audio row key must be exactly audio (type File).
  • -
  • If a file field shows a warning triangle, re-select the file from disk.
  • -
-
-
- -
-

Discovery

- {`GET /api/v1`} -

Returns endpoint list and requirements (no auth).

-
- -
-

1. Upload a file (two-step)

- {`curl -X POST "$BASE_URL/api/v1/upload" \\ - -H "Authorization: Bearer $API_KEY" \\ - -F "file=@cover.jpg" \\ - -F "type=image"`} - {`curl -X POST "$BASE_URL/api/v1/upload" \\ - -H "Authorization: Bearer $API_KEY" \\ - -F "file=@track1.mp3" \\ - -F "type=audio"`} -

- Response includes path,{" "} - filename, and{" "} - size. Upload the image once, then each audio file. Keep the{" "} - path values for the next step. -

-
- -
-

2. Create job from paths (recommended)

-

- Use the exact path strings returned by upload. Add one{" "} - items[] entry per track. -

- {`curl -X POST "$BASE_URL/api/v1/jobs" \\ - -H "Authorization: Bearer $API_KEY" \\ - -H "Content-Type: application/json" \\ - -d '{ - "imagePath": "/uploads/.../cover.jpg", - "items": [{ - "audioPath": "/uploads/.../track.mp3", - "audioFilename": "track.mp3", - "metadata": { - "title": "My Track", - "description": "", - "tags": "electronic", - "privacy": "PUBLIC", - "categoryId": "10", - "resolution": "1920x1080", - "notifySubscribers": true, - "madeForKids": false, - "embeddable": true, - "creativeCommons": false, - "includeWatermark": false, - "playlistId": null - } - }] - }'`} -
- -
-

YouTube playlists (Pro)

-

- List existing playlists, create a new one, or create one inline when starting a job. - Pass playlistId in item metadata / batch{" "} - defaults, or use{" "} - createPlaylist to make a playlist and attach all - videos to it. Privacy may be public,{" "} - unlisted, or{" "} - private. If playlist permission was just added, - sign out and sign in again for youtube.force-ssl. -

- {`# List playlists -curl "$BASE_URL/api/v1/playlists" \\ - -H "Authorization: Bearer $API_KEY"`} - {`# Create a playlist -curl -X POST "$BASE_URL/api/v1/playlists" \\ - -H "Authorization: Bearer $API_KEY" \\ - -H "Content-Type: application/json" \\ - -d '{"title":"My Album","description":"From Songs2YT","privacy":"unlisted"}'`} - {`# Use an existing playlist ID in metadata / defaults: -{ "playlistId": "PLxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } - -# Or create one inline with a job / batch request: -{ - "createPlaylist": { - "title": "My Album", - "description": "Uploaded via Songs2YT", - "privacy": "private" - }, - "defaults": { "privacy": "PUBLIC" }, - "items": [{ "title": "Track One" }] -}`} -
- -
-

One-shot batch (small packs only)

-

- Upload one cover image and a few audio files in a single multipart request. Not recommended for - large batches: use two-step instead if you see FormData parse errors. -

- {`curl -X POST "$BASE_URL/api/v1/jobs/batch" \\ - -H "Authorization: Bearer $API_KEY" \\ - -F "image=@cover.jpg" \\ - -F "audio=@track1.mp3" \\ - -F "audio=@track2.mp3" \\ - -F 'metadata={"createPlaylist":{"title":"My Album","privacy":"unlisted"},"defaults":{"privacy":"PUBLIC"},"items":[{"title":"Track One"},{"title":"Track Two"}]}'`} -

- Optional metadata JSON supports{" "} - defaults applied to every item and per-item overrides in{" "} - items. Item order should match the order of{" "} - audio files. -

-
- -
-

Poll job status

- {`curl "$BASE_URL/api/v1/jobs/JOB_ID" \\ - -H "Authorization: Bearer $API_KEY"`} - {`curl "$BASE_URL/api/v1/jobs?limit=10" \\ - -H "Authorization: Bearer $API_KEY"`} -
-
-
- ); -} diff --git a/app/dashboard/settings/page.tsx b/app/dashboard/settings/page.tsx index 6c4a3d6..e90ba61 100644 --- a/app/dashboard/settings/page.tsx +++ b/app/dashboard/settings/page.tsx @@ -4,21 +4,12 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { prisma } from "@/lib/db"; import { AccountPrivacyActions } from "@/components/AccountPrivacyActions"; -import { PlanBillingActions } from "@/components/PlanBillingActions"; import { DashboardShell } from "@/components/DashboardShell"; -import { UpgradeProLink } from "@/components/UpgradeProLink"; import { getUserApiKeyStatus } from "@/lib/api-keys"; import { getApiRateLimitStatus } from "@/lib/api-rate-limit"; import { ApiKeySettings } from "@/components/ApiKeySettings"; -import { hasProFeatures, isSelfHostedEdition } from "@/lib/edition"; -import { EXTENSION_KIND, getQuotaExtensionUsage } from "@/lib/quota-extensions"; import { getQuotaInfo } from "@/lib/quota"; -const PLAN_LABELS = { - FREE: "Bedroom Producer", - PREMIUM: "Pro", -} as const; - function InfoRow({ label, value }: { label: string; value: string }) { return (
@@ -39,16 +30,8 @@ export default async function SettingsPage() { if (!user) redirect("/"); const quota = await getQuotaInfo(user.id); - const selfHosted = isSelfHostedEdition(); - const proFeatures = hasProFeatures(user.plan); - const extensionUsage = - !selfHosted && user.plan === "PREMIUM" ? await getQuotaExtensionUsage(user.id) : null; - const apiRateExtensionUsage = proFeatures - ? await getQuotaExtensionUsage(user.id, EXTENSION_KIND.API_RATE_LIMIT) - : null; - const apiKeyStatus = proFeatures ? await getUserApiKeyStatus(user.id) : null; - const apiRateLimit = proFeatures ? await getApiRateLimitStatus(user.id) : null; - const planLabel = selfHosted ? "Self-hosted" : PLAN_LABELS[user.plan]; + const apiKeyStatus = await getUserApiKeyStatus(user.id); + const apiRateLimit = await getApiRateLimitStatus(user.id); const youtube = user.youtubeConnection; const channelUrl = youtube ? `https://www.youtube.com/channel/${youtube.channelId}` : null; @@ -100,115 +83,31 @@ export default async function SettingsPage() {
-

- {selfHosted ? "Usage" : "Plan & billing"} -

+

Usage

-
-
-

- Videos processed -

-

- {selfHosted ? ( - quota.used - ) : ( - <> - {quota.remaining} - - {" "} - of {quota.limit} videos - - - )} -

-
- {!selfHosted && ( -

- {quota.used} used - {quota.bonusQuota > 0 ? ` Β· +${quota.bonusQuota} bonus` : ""} -

- )} -
- {!selfHosted && ( - <> -
-
-
-

- {user.plan === "PREMIUM" - ? `Monthly quota resets on ${quota.resetsIn}` - : `Quota resets in ${quota.resetsIn}`} -

- - )} - {selfHosted && ( -

- Self-hosted edition: no video or API quotas. +

+

+ Videos processed

- )} +

{quota.used}

+
+

+ Self-hosted edition: no video credits or quotas. +

- - {!selfHosted && user.plan === "PREMIUM" && user.subscribedAt && ( - - )} - {!selfHosted && user.plan === "PREMIUM" && ( - - )} - {extensionUsage && ( - - )} +
- - {!selfHosted && user.plan === "FREE" && ( -

- for more videos, 1080p, and lossless audio. -

- )} - {!selfHosted && user.plan === "PREMIUM" && extensionUsage && ( - - )}
- {proFeatures && apiKeyStatus && apiRateLimit && apiRateExtensionUsage && ( -
-

API access

- -
- )} +
+

API access

+ +

Account deletion & data

diff --git a/app/page.tsx b/app/page.tsx index 4ff6d01..3c72c4b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,10 +5,10 @@ import { BenefitsSection } from "@/components/BenefitsSection"; import { DownloadSection } from "@/components/DownloadSection"; import { LandingNavbar } from "@/components/LandingNavbar"; import { SignInButton } from "@/components/SignInButton"; -import { PricingSection } from "@/components/PricingSection"; import { StepsSection } from "@/components/StepsSection"; import { Footer } from "@/components/Footer"; import { SupportSection } from "@/components/SupportSection"; +import { DOCS_URL, WEBSITE_URL } from "@/lib/plans"; export default async function HomePage() { const session = await getServerSession(authOptions); @@ -34,14 +34,13 @@ export default async function HomePage() { - {/* Purpose + brand above the fold for Google OAuth verification */}

- Songs2YT + Songs2VID

- Songs2YT is an automation tool that converts your audio and image files into - high-quality videos and uploads them directly to YouTube. + Self-hosted automation that turns your audio and cover art into YouTube videos β€” + including art-track layouts, typography, and watermark studio. No quotas or paywalls.

@@ -74,19 +73,34 @@ export default async function HomePage() {

)} +

+ + Documentation + + {" Β· "} + + Hosted product + +

- - - -
- Β© {year} Songs2YT. All rights reserved. + Β© {year} Songs2VID. All rights reserved. @@ -178,10 +189,6 @@ export function Footer() { - Refund Policy - Service Status diff --git a/components/JobProgress.tsx b/components/JobProgress.tsx index ace0739..218cefd 100644 --- a/components/JobProgress.tsx +++ b/components/JobProgress.tsx @@ -20,12 +20,7 @@ const STATUS_LABELS: Record = { export function JobProgress({ jobId }: Props) { const [job, setJob] = useState(null); - const [quota, setQuota] = useState<{ - remaining: number; - limit: number; - plan: string; - resetsIn: string; - } | null>(null); + const [videosProcessed, setVideosProcessed] = useState(null); const [error, setError] = useState(null); useEffect(() => { @@ -44,7 +39,7 @@ export function JobProgress({ jobId }: Props) { if (quotaRes.ok) { const quotaData = await quotaRes.json(); - if (active) setQuota({ remaining: quotaData.remaining, limit: quotaData.limit, plan: quotaData.plan, resetsIn: quotaData.resetsIn }); + if (active) setVideosProcessed(quotaData.used ?? 0); } } catch (err) { if (active) setError(err instanceof Error ? err.message : "Error loading job"); @@ -81,12 +76,9 @@ export function JobProgress({ jobId }: Props) { Overall: {job.status}

- {quota && ( + {videosProcessed !== null && (
- {quota.remaining} / {quota.limit} videos remaining Β·{" "} - {quota.plan === "FREE" - ? `resets in ${quota.resetsIn}` - : `monthly quota resets on ${quota.resetsIn}`} + Self-hosted Β· {videosProcessed} videos processed
)} diff --git a/components/LandingNavbar.tsx b/components/LandingNavbar.tsx index 49e885b..095a845 100644 --- a/components/LandingNavbar.tsx +++ b/components/LandingNavbar.tsx @@ -3,12 +3,14 @@ import { useState } from "react"; import { Logo } from "@/components/Logo"; import { MobileMenuButton, MobileSidebar, sidebarLinkClass } from "@/components/MobileSidebar"; +import { DOCS_URL, WEBSITE_URL } from "@/lib/plans"; const NAV_LINKS = [ - { href: "#benefits", label: "Benefits" }, - { href: "#download", label: "Download" }, - { href: "#pricing", label: "Pricing" }, - { href: "#support", label: "Support" }, + { href: "#benefits", label: "Benefits", kind: "anchor" as const }, + { href: "#download", label: "Download", kind: "anchor" as const }, + { href: `${DOCS_URL}/docs/intro`, label: "Docs", kind: "external" as const }, + { href: WEBSITE_URL, label: "Hosted", kind: "external" as const }, + { href: "#support", label: "Support", kind: "anchor" as const }, ] as const; function NavAnchor({ @@ -37,6 +39,30 @@ function NavAnchor({ ); } +function ExternalLink({ + href, + label, + onNavigate, + className = "text-base font-medium text-gray-300 transition-colors duration-200 hover:text-white", +}: { + href: string; + label: string; + onNavigate?: () => void; + className?: string; +}) { + return ( + onNavigate?.()} + className={className} + > + {label} + + ); +} + export function LandingNavbar() { const [menuOpen, setMenuOpen] = useState(false); @@ -48,9 +74,13 @@ export function LandingNavbar() {
setMenuOpen(false)} title="Menu"> - {NAV_LINKS.map(({ href, label }) => ( - setMenuOpen(false)} - className={sidebarLinkClass()} - /> - ))} + {NAV_LINKS.map((link) => + link.kind === "external" ? ( + setMenuOpen(false)} + className={sidebarLinkClass()} + /> + ) : ( + setMenuOpen(false)} + className={sidebarLinkClass()} + /> + ), + )} ); diff --git a/components/LayoutStudio.tsx b/components/LayoutStudio.tsx new file mode 100644 index 0000000..52735ee --- /dev/null +++ b/components/LayoutStudio.tsx @@ -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; + 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 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 ( + + ); +} + +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(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.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) { + 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({ 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 ( +
+
+

Video layout

+
+ + {/* 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 */} + +
+

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

+

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

+
+ + ) : ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ )} + + {W.mode !== "none" && ( +
+ {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()} + + )} +
+ )} + + ) : ( +
+ Upload a cover image to preview +
+ )} +
+ + {/* Art-track templates */} +

Composition

+
+ patchLayout({ template: null })} + /> + {LAYOUT_TEMPLATES.map((t) => ( + patchLayout({ template: t })} + /> + ))} +
+ +
+ + + + + + +
+ + {/* Watermark section */} +
+

Watermark

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

+ )} +
+ )} + + {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) => ( + + ))} +
+
+ +
+ + +
+
+
+ ); +} diff --git a/components/PlanBillingActions.tsx b/components/PlanBillingActions.tsx deleted file mode 100644 index 0ee3088..0000000 --- a/components/PlanBillingActions.tsx +++ /dev/null @@ -1,199 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { useState } from "react"; - -type ExtensionRequest = { - id: string; - status: string; - message: string; - requestedAt: string; - processedAt: string | null; - adminNote: string | null; -}; - -type ExtensionUsage = { - used: number; - limit: number; - remaining: number; - requests: ExtensionRequest[]; -}; - -type Props = { - initialUsage: ExtensionUsage; -}; - -export function PlanBillingActions({ initialUsage }: Props) { - const router = useRouter(); - const [usage, setUsage] = useState(initialUsage); - const [cancelling, setCancelling] = useState(false); - const [requesting, setRequesting] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); - - async function handleCancelPlan() { - if ( - !confirm( - "Cancel your Pro plan? You will be moved to the free plan immediately and lose Pro benefits.", - ) - ) { - return; - } - - setCancelling(true); - setError(null); - setSuccess(null); - - try { - const res = await fetch("/api/account/cancel-subscription", { method: "POST" }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || "Failed to cancel subscription"); - router.refresh(); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to cancel subscription"); - } finally { - setCancelling(false); - } - } - - async function handleQuotaRequest() { - if (usage.remaining <= 0) return; - - const reason = prompt( - "Optional: tell us why you need a quota reset or extension (leave blank to skip).", - ); - if (reason === null) return; - - setRequesting(true); - setError(null); - setSuccess(null); - - try { - const res = await fetch("/api/account/quota-extension-request", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: reason }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || "Failed to submit request"); - - setUsage({ - used: data.used, - limit: data.limit, - remaining: data.remaining, - requests: data.requests ?? usage.requests, - }); - setSuccess( - `Request submitted. ${data.used} of ${data.limit} extension requests used this year. Support will process it shortly.`, - ); - router.refresh(); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to submit request"); - } finally { - setRequesting(false); - } - } - - const hasPending = usage.requests.some((r) => r.status === "PENDING"); - - return ( -
-
-

- Extension requests this year:{" "} - - {usage.used} / {usage.limit} - - {usage.remaining > 0 ? ( - Β· {usage.remaining} remaining - ) : ( - Β· limit reached - )} -

-
- - {error && ( -
- {error} -
- )} - - {success && ( -
- {success} -
- )} - -
- - - -
- - {usage.requests.length > 0 && ( -
-

- Request history -

-
    - {usage.requests.slice(0, 5).map((req) => ( -
  • -
    - - {new Date(req.requestedAt).toLocaleDateString(undefined, { - month: "short", - day: "numeric", - year: "numeric", - })} - - - {req.status} - -
    - {req.message &&

    {req.message}

    } -

    ID: {req.id}

    -
  • - ))} -
-
- )} - -

- Pro users may request up to 5 manual quota resets or extensions per calendar year. See our{" "} - - Terms of Service - - . -

-
- ); -} diff --git a/components/PlaylistSelect.tsx b/components/PlaylistSelect.tsx index 63b9c59..28170cd 100644 --- a/components/PlaylistSelect.tsx +++ b/components/PlaylistSelect.tsx @@ -1,7 +1,6 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { UpgradeProLink } from "./UpgradeProLink"; type Playlist = { id: string; @@ -80,14 +79,7 @@ export function PlaylistSelect({ value, onChange, enabled }: Props) { } } - if (!enabled) { - return ( -

- Add uploaded videos to a YouTube playlist with{" "} - . -

- ); - } + if (!enabled) { return null; } return (
diff --git a/components/PricingSection.tsx b/components/PricingSection.tsx deleted file mode 100644 index c775989..0000000 --- a/components/PricingSection.tsx +++ /dev/null @@ -1,330 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useRef } from "react"; -import { ScrollReveal } from "@/components/ScrollReveal"; -import { SectionScrollTitle } from "@/components/SectionScrollTitle"; -import { SignInButton } from "@/components/SignInButton"; -import { SALES_EMAIL } from "@/lib/plans"; - -const FEATURE_LABELS = [ - "Infrastructure", - "Limit", - "Batch mode", - "File type", - "Playlists", - "API support", - "ID3 tags", - "Support", - "Watermark", -] as const; - -type PlanFeatures = Record<(typeof FEATURE_LABELS)[number], string>; - -type InfrastructureChip = "cloud" | "self-hosted"; - -type PlanConfig = { - name: string; - badge: string; - price: string; - priceNote: string; - features: PlanFeatures; - infrastructureChips: InfrastructureChip[]; - watermarkNote?: string; - cta: - | { type: "signin" } - | { type: "link"; label: string; href: string } - | { type: "disabled"; label: string } - | { type: "mailto"; label: string; email: string; subject: string }; - highlighted: boolean; - hover: string; - accent: string; - badgeClass: string; - ctaClass?: string; -}; - -const PLANS: PlanConfig[] = [ - { - name: "Bedroom Producer", - badge: "Free", - price: "€0", - priceNote: "/ forever", - features: { - Infrastructure: "Cloud", - Limit: "14 videos / 12 hours Β· 720p", - "Batch mode": "Limited batch Β· up to 3 files", - "File type": "MP3", - Playlists: "Not included", - "API support": "Not included", - "ID3 tags": "Auto-fill title & metadata from MP3 tags", - Support: "Community", - Watermark: "Optional Β· support badge", - }, - infrastructureChips: ["cloud"], - watermarkNote: - "Show \"Uploaded through Songs2YT.com\" to support open-source development - opt out anytime for a clean video.", - cta: { type: "signin" }, - highlighted: false, - hover: - "hover:border-red-500/40 hover:bg-red-500/[0.04] hover:shadow-lg hover:shadow-red-500/15", - accent: "text-red-400", - badgeClass: "bg-gray-800 text-gray-400", - }, - { - name: "Independent Artist", - badge: "Pro", - price: "€5", - priceNote: "/ mo", - features: { - Infrastructure: "Cloud", - Limit: "50 videos* / month Β· 1080p", - "Batch mode": "Full batch Β· up to 5 files", - "File type": "MP3 / WAV / FLAC", - Playlists: "Create & add uploads to YouTube playlists", - "API support": "REST API Β· upload, batch & playlists", - "ID3 tags": "Extended metadata support", - Support: "E-Mail", - Watermark: "Fully customizable Β· remove completely", - }, - infrastructureChips: ["cloud"], - cta: { type: "disabled", label: "Coming soon" }, - highlighted: true, - hover: - "hover:border-accent/50 hover:bg-accent/[0.06] hover:shadow-lg hover:shadow-accent/20", - accent: "text-accent", - badgeClass: "bg-accent/15 text-accent", - }, - { - name: "Record Label / Studio", - badge: "Enterprise", - price: "Custom", - priceNote: "/ contact sales", - features: { - Infrastructure: "Cloud or self-hosted", - Limit: "Unlimited 4K Β· zero limit", - "Batch mode": "Unlimited synchronized batch processing", - "File type": "WAV / FLAC / lossless", - Playlists: "Org-wide playlist workflows", - "API support": "Full API access Β· custom integrations & SLAs", - "ID3 tags": "Full metadata Β· custom mapping", - Support: "Top-priority**", - Watermark: "Fully customizable Β· remove completely", - }, - infrastructureChips: ["cloud", "self-hosted"], - cta: { - type: "mailto", - label: "Contact Sales", - email: SALES_EMAIL, - subject: "Songs2YT Enterprise Inquiry", - }, - highlighted: false, - hover: - "hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20", - accent: "text-[#609926]", - badgeClass: "bg-[#609926]/15 text-[#609926]", - ctaClass: - "border-[#609926]/40 bg-[#609926]/10 text-[#609926] hover:border-[#609926]/60 hover:bg-[#609926]/20", - }, -]; - -function FeatureValue({ value }: { value: string }) { - return {value}; -} - -function CloudIcon({ className }: { className?: string }) { - return ( - - ); -} - -function ServerIcon({ className }: { className?: string }) { - return ( - - ); -} - -function InfrastructureChips({ - chips, -}: { - chips: InfrastructureChip[]; -}) { - return ( -
- {chips.includes("cloud") && ( - - - Cloud - - )} - {chips.includes("self-hosted") && ( - - - Self-hosted - - )} -
- ); -} - -function PricingCard({ plan }: { plan: PlanConfig }) { - const { - name, - badge, - price, - priceNote, - features, - watermarkNote, - infrastructureChips, - cta, - highlighted, - hover, - accent, - badgeClass, - ctaClass, - } = plan; - - return ( -
- {highlighted && ( - - Most popular - - )} - -
-
-

{name}

- - {badge} - -
- -
- {price} - {priceNote} -
-
- -
    - {FEATURE_LABELS.map((label) => ( -
  • -

    {label}

    - {label === "Infrastructure" ? ( - - ) : ( - - )} - {label === "Watermark" && watermarkNote && ( -

    - {watermarkNote} -

    - )} -
  • - ))} -
- -
- {cta.type === "signin" && } - {cta.type === "link" && ( - - {cta.label} - - )} - {cta.type === "disabled" && ( - - )} - {cta.type === "mailto" && ( - - {cta.label} - - )} -
-
- ); -} - -export function PricingSection() { - const sectionRef = useRef(null); - - return ( -
- - -
- -

Pricing

-

- Free for getting started, Pro for creators, or Enterprise for teams. -

-
- -
- {PLANS.map((plan, index) => ( - - - - ))} -
- - -

- *Pro is a monthly subscription with included quota (extensions via support). Self-hosted - open-source deployments have no quotas. -

-

- Technical support is strictly reserved for Managed Cloud and paid Professional Setup - agreements; independent self-hosted deployments are community-supported. -

-
-
-
- ); -} diff --git a/components/UpgradeProLink.tsx b/components/UpgradeProLink.tsx deleted file mode 100644 index cb82ad5..0000000 --- a/components/UpgradeProLink.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import Link from "next/link"; -import type { ReactNode } from "react"; -import { UPGRADE_URL } from "@/lib/plans"; - -const PRO_UPGRADE_SUFFIX = " Upload up to 50 videos with Pro!"; - -type Props = { - className?: string; - children?: ReactNode; -}; - -export function UpgradeProLink({ className = "text-accent hover:underline", children }: Props) { - return ( - - {children ?? "Upgrade to Pro"} - - ); -} - -export function QuotaErrorMessage({ message }: { message: string }) { - if (message.endsWith(PRO_UPGRADE_SUFFIX)) { - return ( - <> - {message.slice(0, -PRO_UPGRADE_SUFFIX.length)}{" "} - - Upload up to 50 videos with Pro! - - - ); - } - - return <>{message}; -} diff --git a/components/UploadForm.tsx b/components/UploadForm.tsx index c28e25c..1721144 100644 --- a/components/UploadForm.tsx +++ b/components/UploadForm.tsx @@ -4,14 +4,16 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Plan, Privacy } from "@prisma/client"; import { filenameWithoutExtension } from "@/lib/constants"; +import { getVideoAttributionText } from "@/lib/branding"; import { audioTagsToMetadata } from "@/lib/audio-tags"; import type { ItemMetadata } from "@/lib/types"; +import { DEFAULT_LAYOUT, type LayoutSettings } from "@/lib/layout"; +import { DEFAULT_WATERMARK, type WatermarkSettings } from "@/lib/watermark"; import { CategorySelect } from "./CategorySelect"; +import { LayoutStudio } from "./LayoutStudio"; import { PlaylistSelect } from "./PlaylistSelect"; import { PrivacyToggle } from "./PrivacyToggle"; import { ResolutionSelect } from "./ResolutionSelect"; -import { QuotaErrorMessage, UpgradeProLink } from "./UpgradeProLink"; - const UPLOAD_CONCURRENCY = 4; type AudioItem = { @@ -19,6 +21,9 @@ type AudioItem = { file: File; path: string | null; uploading: boolean; + itemImagePath: string | null; + itemImageName: string | null; + itemImagePreview: string | null; metadata: ItemMetadata; }; @@ -36,6 +41,10 @@ function defaultMetadata(title = ""): ItemMetadata { creativeCommons: false, includeWatermark: true, playlistId: null, + imagePath: null, + artist: null, + watermark: { ...DEFAULT_WATERMARK }, + layout: { ...DEFAULT_LAYOUT }, }; } @@ -44,21 +53,19 @@ export function UploadForm() { const uploadSessionRef = useRef(crypto.randomUUID()); const [imageFile, setImageFile] = useState(null); const [imagePath, setImagePath] = useState(null); + const [imagePreviewUrl, setImagePreviewUrl] = useState(null); const [imageUploading, setImageUploading] = useState(false); const [audioItems, setAudioItems] = useState([]); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [playlistId, setPlaylistId] = useState(""); + const [jobWatermark, setJobWatermark] = useState({ ...DEFAULT_WATERMARK }); + const [jobLayout, setJobLayout] = useState({ ...DEFAULT_LAYOUT }); + const [logoPreviewUrl, setLogoPreviewUrl] = useState(null); const [quota, setQuota] = useState<{ - remaining: number; - limit: number; plan: Plan; maxBatchSize: number; - resetsIn: string; - videoCredits: number; - totalAvailable: number; used: number; - selfHosted?: boolean; } | null>(null); const loadQuota = useCallback(async () => { @@ -66,15 +73,9 @@ export function UploadForm() { if (res.ok) { const data = await res.json(); setQuota({ - remaining: data.remaining, - limit: data.limit, plan: data.plan, maxBatchSize: data.maxBatchSize, - resetsIn: data.resetsIn, - videoCredits: data.videoCredits ?? 0, - totalAvailable: data.totalAvailable ?? data.remaining, used: data.used ?? 0, - selfHosted: Boolean(data.selfHosted), }); } }, []); @@ -83,20 +84,30 @@ export function UploadForm() { loadQuota(); }, [loadQuota]); + useEffect(() => { + return () => { + if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl); + if (logoPreviewUrl) URL.revokeObjectURL(logoPreviewUrl); + audioItems.forEach((a) => { + if (a.itemImagePreview) URL.revokeObjectURL(a.itemImagePreview); + }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup on unmount only + }, []); + async function uploadFile( file: File, - type: "image" | "audio", + type: "image" | "audio" | "logo" | "font", ): Promise<{ path: string; audioTags?: Parameters[0] | null }> { const formData = new FormData(); formData.append("file", file); formData.append("type", type); formData.append("session", uploadSessionRef.current); const res = await fetch("/api/upload", { method: "POST", body: formData }); + const data = await res.json(); if (!res.ok) { - const data = await res.json(); throw new Error(data.error || "Upload failed"); } - const data = await res.json(); return { path: data.path, audioTags: data.audioTags }; } @@ -112,9 +123,14 @@ export function UploadForm() { file, path: null, uploading: true, + itemImagePath: null, + itemImageName: null, + itemImagePreview: null, metadata: { ...defaultMetadata(autoTitle), playlistId: playlistId || null, + watermark: { ...jobWatermark }, + layout: { ...jobLayout }, }, }, ]); @@ -165,6 +181,8 @@ export function UploadForm() { setError(null); setImageFile(file); setImageUploading(true); + if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl); + setImagePreviewUrl(URL.createObjectURL(file)); try { const path = await uploadFile(file, "image"); setImagePath(path.path); @@ -172,11 +190,106 @@ export function UploadForm() { setError(err instanceof Error ? err.message : "Image upload failed"); setImageFile(null); setImagePath(null); + if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl); + setImagePreviewUrl(null); } finally { setImageUploading(false); } } + async function handleItemImageChange(itemId: string, file: File | null) { + if (!true) { + setError("Matching a unique image per audio requires Pro."); + return; + } + if (!file) return; + setError(null); + const preview = URL.createObjectURL(file); + setAudioItems((prev) => + prev.map((item) => { + if (item.id !== itemId) return item; + if (item.itemImagePreview) URL.revokeObjectURL(item.itemImagePreview); + return { + ...item, + itemImageName: file.name, + itemImagePreview: preview, + itemImagePath: null, + }; + }), + ); + try { + const uploaded = await uploadFile(file, "image"); + setAudioItems((prev) => + prev.map((item) => + item.id === itemId + ? { + ...item, + itemImagePath: uploaded.path, + metadata: { ...item.metadata, imagePath: uploaded.path }, + } + : item, + ), + ); + } catch (err) { + setError(err instanceof Error ? err.message : "Per-item image upload failed"); + setAudioItems((prev) => + prev.map((item) => { + if (item.id !== itemId) return item; + if (item.itemImagePreview) URL.revokeObjectURL(item.itemImagePreview); + return { + ...item, + itemImagePath: null, + itemImageName: null, + itemImagePreview: null, + metadata: { ...item.metadata, imagePath: null }, + }; + }), + ); + } + } + + async function handleLogoUpload(file: File) { + const preview = URL.createObjectURL(file); + if (logoPreviewUrl) URL.revokeObjectURL(logoPreviewUrl); + setLogoPreviewUrl(preview); + const uploaded = await uploadFile(file, "logo"); + return uploaded.path; + } + + async function handleFontUpload(file: File) { + const uploaded = await uploadFile(file, "font"); + return uploaded.path; + } + + function applyWatermarkToAll(next: WatermarkSettings) { + const normalized = { ...DEFAULT_WATERMARK, ...next }; + setJobWatermark(normalized); + setAudioItems((prev) => + prev.map((item) => ({ + ...item, + metadata: { + ...item.metadata, + includeWatermark: normalized.mode !== "none", + watermark: { ...normalized }, + }, + })), + ); + } + + function applyLayoutToAll(next: LayoutSettings) { + const normalized = { ...DEFAULT_LAYOUT, ...next }; + setJobLayout(normalized); + setAudioItems((prev) => + prev.map((item) => ({ + ...item, + metadata: { + ...item.metadata, + layout: { ...normalized }, + }, + })), + ); + } + async function handleAudioChange(e: React.ChangeEvent) { const files = Array.from(e.target.files || []); if (!files.length) return; @@ -244,13 +357,31 @@ export function UploadForm() { items: readyAudios.map((item) => ({ audioPath: item.path!, audioFilename: item.file.name, - metadata: item.metadata, + metadata: { + ...item.metadata, + imagePath: true ? item.itemImagePath || item.metadata.imagePath || null : null, + includeWatermark: jobWatermark.mode !== "none", + watermark: true + ? jobWatermark + : { + mode: jobWatermark.mode === "none" ? "none" : "default", + position: "bottom-right", + offsetX: 20, + offsetY: 20, + }, + layout: jobLayout, + }, })), }), }); const data = await res.json(); - if (!res.ok) throw new Error(data.error || "Failed to create job"); + if (!res.ok) { + if (data.code === "PREMIUM_REQUIRED") { + throw new Error(data.error || "This feature requires Pro."); + } + throw new Error(data.error || "Failed to create job"); + } router.push(`/jobs/${data.jobId}`); } catch (err) { @@ -263,30 +394,13 @@ export function UploadForm() {
{quota && (
- {quota.selfHosted ? ( - <> - Self-hosted Β· {quota.used} videos processed Β· up to {quota.maxBatchSize} files per - batch - - ) : ( - <> - {quota.remaining} of {quota.limit} plan videos remaining - {quota.plan === "FREE" && quota.videoCredits > 0 - ? ` Β· ${quota.videoCredits} pay-as-you-go credits` - : ""}{" "} - Β·{" "} - {quota.plan === "FREE" - ? `resets in ${quota.resetsIn}` - : `monthly quota resets on ${quota.resetsIn}`}{" "} - Β· up to {quota.maxBatchSize} files per batch - - )} + Self-hosted Β· {quota.used} videos processed Β· up to {quota.maxBatchSize} files per batch
)} {error && (
- + {error}
)} @@ -308,17 +422,16 @@ export function UploadForm() { {imageUploading ? "Uploading…" : imageFile?.name || "No image selected"}
+

+ Shared cover for all tracks. Optionally override per audio below. +

@@ -384,6 +497,19 @@ export function UploadForm() { /> + + updateItemMetadata(item.id, { artist: e.target.value })} + className="input-field" + placeholder="Optional shown in layout templates" + maxLength={80} + /> + +
+ +
+
+ + + { + const f = e.target.files?.[0] ?? null; + void handleItemImageChange(item.id, f); + e.target.value = ""; + }} + 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 disabled:opacity-50" + /> + + {item.itemImageName && ( +

+ {item.itemImagePath ? `Using ${item.itemImageName}` : `Uploading ${item.itemImageName}…`} +

+ )} + +
+
updateItemMetadata(item.id, { creativeCommons: v })} /> - updateItemMetadata(item.id, { includeWatermark: v })} - /> +
))} )} - {quota && !quota.selfHosted && quota.plan === "FREE" && ( - <> -

- Watermark is optional on the free plan - turn it off anytime for a clean video. -

-

- for 50 videos/month, 1080p, - lossless audio, and full watermark control. -

- - )} + a.itemImagePreview)?.itemImagePreview || imagePreviewUrl + } + title={audioItems[0]?.metadata.title || "Track title"} + artist={audioItems[0]?.metadata.artist || ""} + layout={jobLayout} + onLayoutChange={applyLayoutToAll} + watermark={jobWatermark} + onWatermarkChange={applyWatermarkToAll} + onUploadLogo={handleLogoUpload} + onUploadFont={handleFontUpload} + logoPreviewUrl={logoPreviewUrl} + /> + + + ))} + + + {value.mode === "text" && ( + <> + + + + + {(value.fontKey === "custom" || + (value.fontKey && + value.fontKey !== "system" && + CURATED_FONTS.some((f) => f.key === (value.fontKey as CuratedFontKey)))) && ( +

+ Preview: The quick brown fox jumps over the lazy dog +

+ )} + + {value.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}

} + {value.fontPath && !fontError && ( +

Custom font ready for render

+ )} +
+ )} + + )} + + {value.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}

} +
+ )} + +
+

Position

+
+ {WATERMARK_POSITIONS.map((pos) => ( + + ))} +
+
+ +
+ + +
+ + + ); +} diff --git a/docker-compose.yml b/docker-compose.yml index 5cc4582..83f83df 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,7 +34,7 @@ services: env_file: - .env environment: - S2YT_EDITION: selfhosted + S2VID_EDITION: selfhosted DATABASE_URL: postgresql://s2yt:s2yt@postgres:5432/s2yt REDIS_URL: redis://redis:6379 UPLOAD_DIR: /app/uploads @@ -55,7 +55,7 @@ services: env_file: - .env environment: - S2YT_EDITION: selfhosted + S2VID_EDITION: selfhosted DATABASE_URL: postgresql://s2yt:s2yt@postgres:5432/s2yt REDIS_URL: redis://redis:6379 UPLOAD_DIR: /app/uploads diff --git a/lib/api-auth.ts b/lib/api-auth.ts index 5c165e8..ba713ae 100644 --- a/lib/api-auth.ts +++ b/lib/api-auth.ts @@ -33,7 +33,7 @@ export async function requirePaidApiUser(req: NextRequest) { if (!hasProFeatures(user.plan)) { return { error: NextResponse.json( - { error: "API access requires an active Pro subscription" }, + { error: "API access is unavailable for this account" }, { status: 403 }, ), user: null, @@ -87,7 +87,7 @@ export async function requirePaidUserFromSessionOrApi(req: NextRequest) { if (!hasProFeatures(sessionUser.plan)) { return { error: NextResponse.json( - { error: "API access requires an active Pro subscription" }, + { error: "API access is unavailable for this account" }, { status: 403 }, ), user: null, diff --git a/lib/branding.ts b/lib/branding.ts index b68b8a1..bbb30c2 100644 --- a/lib/branding.ts +++ b/lib/branding.ts @@ -1,4 +1,4 @@ -export const BRAND_NAME = "Songs2YT"; +export const BRAND_NAME = "Songs2VID"; export const BRAND_DOMAIN = `${BRAND_NAME}.com`; export const VIDEO_ATTRIBUTION_PREFIX = "Uploaded through"; diff --git a/lib/edition.ts b/lib/edition.ts index 755cf00..d7d2e5e 100644 --- a/lib/edition.ts +++ b/lib/edition.ts @@ -1,9 +1,17 @@ ο»Ώimport { Plan } from "@prisma/client"; +/** + * Self-hosted / OSS edition. Prefer S2VID_EDITION; S2YT_EDITION kept for older compose files. + */ export function isSelfHostedEdition(): boolean { - return process.env.S2YT_EDITION === "selfhosted"; + const edition = + process.env.S2VID_EDITION?.trim() || process.env.S2YT_EDITION?.trim() || ""; + // Default to self-hosted for this OSS package when unset + if (!edition) return true; + return edition === "selfhosted"; } -export function hasProFeatures(plan: Plan): boolean { - return isSelfHostedEdition() || plan === "PREMIUM"; +/** All Pro features are unlocked in the OSS / self-hosted edition. */ +export function hasProFeatures(_plan?: Plan): boolean { + return true; } diff --git a/lib/entitlements.ts b/lib/entitlements.ts new file mode 100644 index 0000000..9dfa42f --- /dev/null +++ b/lib/entitlements.ts @@ -0,0 +1,41 @@ +import { Plan } from "@prisma/client"; +import type { LayoutSettings } from "./layout"; +import { + PREMIUM_REQUIRED_CODE, + type WatermarkSettings, +} from "./watermark"; + +export class PremiumRequiredError extends Error { + readonly code = PREMIUM_REQUIRED_CODE; + readonly status = 403; + constructor(message: string) { + super(message); + this.name = "PremiumRequiredError"; + } +} + +/** OSS: all watermark / typography features unlocked. */ +export function assertCustomWatermarkAllowed(_plan: Plan, _settings: WatermarkSettings) { + return; +} + +/** OSS: all art-track layout features unlocked. */ +export function assertArtTrackLayoutAllowed(_plan: Plan, _settings: LayoutSettings) { + return; +} + +/** OSS: per-track cover images always allowed. */ +export function assertPerItemImagesAllowed( + _plan: Plan, + _sharedImagePath: string, + _itemImagePaths: Array, +) { + return; +} + +export function premiumRequiredResponse(message: string) { + return { + error: message, + code: PREMIUM_REQUIRED_CODE, + }; +} diff --git a/lib/ffmpeg/encode.ts b/lib/ffmpeg/encode.ts index ca699a0..ecfadcc 100644 --- a/lib/ffmpeg/encode.ts +++ b/lib/ffmpeg/encode.ts @@ -4,7 +4,23 @@ import path from "path"; import ffmpegStatic from "ffmpeg-static"; import { getVideoAttributionText } from "../branding"; import { getResolution } from "../constants"; +import { + isCuratedFontKey, + sanitizeFontfileForFilter, +} from "../fonts"; +import { resolveCuratedFontPath } from "../fonts-server"; +import { + buildArtTrackFilterComplex, + type LayoutSettings, +} from "../layout"; import { getWatermarkPath } from "../storage"; +import { + buildDrawtextFilter, + normalizeWatermarkSettings, + overlayXy, + sanitizeDrawtext, + type WatermarkSettings, +} from "../watermark"; function getFfmpegPath(): string { if (process.env.FFMPEG_PATH) return process.env.FFMPEG_PATH; @@ -14,59 +30,207 @@ function getFfmpegPath(): string { export { getFfmpegPath }; +async function fileExists(p: string): Promise { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +/** Verify PNG magic bytes (prevents MIME spoof β†’ FFmpeg surprises). */ +export async function assertPngFile(filePath: string): Promise { + const fh = await fs.open(filePath, "r"); + try { + const buf = Buffer.alloc(8); + await fh.read(buf, 0, 8, 0); + const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + if (!buf.equals(sig)) { + throw new Error("Logo must be a valid PNG file"); + } + } finally { + await fh.close(); + } +} + +async function resolveFontfileEscaped( + settings: WatermarkSettings, +): Promise { + const key = settings.fontKey ?? "system"; + if (key === "system") return null; + + if (key === "custom") { + if (!settings.fontPath) return null; + if (!(await fileExists(settings.fontPath))) { + throw new Error("Custom watermark font file not found"); + } + return sanitizeFontfileForFilter(path.resolve(settings.fontPath)); + } + + if (isCuratedFontKey(key)) { + const fontPath = resolveCuratedFontPath(key); + if (!(await fileExists(fontPath))) { + console.warn(`[ffmpeg] curated font missing: ${key} at ${fontPath}; using system font`); + return null; + } + return sanitizeFontfileForFilter(fontPath); + } + + return null; +} + +function classicScaleFilter(width: number, height: number): string { + return `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`; +} + +/** + * Art-track filter that ends at `outLabel` instead of hardcoded [laid]. + */ +function artTrackFilterEndingAt( + opts: Parameters[0], + outLabel: string, +): string { + return buildArtTrackFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`); +} + export async function encodeVideo(options: { imagePath: string; audioPath: string; outputPath: string; resolution: string; includeWatermark: boolean; + watermark?: Partial | null; + layout?: LayoutSettings | null; + title?: string; + artist?: string | null; }): Promise { const res = getResolution(options.resolution); if (!res) throw new Error(`Invalid resolution: ${options.resolution}`); await fs.mkdir(path.dirname(options.outputPath), { recursive: true }); - const scaleFilter = `scale=${res.width}:${res.height}:force_original_aspect_ratio=decrease,pad=${res.width}:${res.height}:(ow-iw)/2:(oh-ih)/2:black`; - - const watermarkPath = getWatermarkPath(); - let watermarkExists = false; - try { - await fs.access(watermarkPath); - watermarkExists = true; - } catch { - watermarkExists = false; - } - - const useWatermark = options.includeWatermark && watermarkExists; - const attributionText = getVideoAttributionText().replace(/:/g, "\\:").replace(/'/g, "\\'"); + const settings = normalizeWatermarkSettings(options.watermark, options.includeWatermark); + const layout = options.layout?.template ? options.layout : null; const watermarkWidth = Math.max(1, Math.round(res.width * 0.32)); + const fontSize = Math.max(16, Math.round(res.width * 0.018)); + const fontfile = await resolveFontfileEscaped(settings); - // Base template: ffmpeg -loop 1 -r 1 -i image -i audio -c:v libx264 -tune stillimage -c:a copy -shortest -pix_fmt yuv420p output.mp4 const args = ["-y", "-loop", "1", "-r", "1", "-i", options.imagePath, "-i", options.audioPath]; - if (useWatermark) { - args.push("-i", watermarkPath); - args.push( - "-filter_complex", - `[0:v]${scaleFilter}[scaled];[2:v]scale=${watermarkWidth}:-1[wm];[scaled][wm]overlay=W-w-20:H-h-20[vout]`, - "-map", - "[vout]", - "-map", - "1:a", - ); - } else if (options.includeWatermark && !watermarkExists) { - args.push( - "-filter_complex", - `[0:v]${scaleFilter},drawtext=text='${attributionText}':fontsize=22:fontcolor=white@0.85:x=w-text_w-20:y=h-th-20[vout]`, - "-map", - "[vout]", - "-map", - "1:a", - ); - } else { - args.push("-vf", scaleFilter); + let nextInput = 2; + let logoInputIndex: number | null = null; + let defaultWmInputIndex: number | null = null; + + const needsLogo = settings.mode === "logo" && Boolean(settings.logoPath); + const defaultPath = getWatermarkPath(); + const useDefaultPng = + settings.mode === "default" && (await fileExists(defaultPath)); + + if (needsLogo && settings.logoPath) { + if (!(await fileExists(settings.logoPath))) { + throw new Error("Watermark logo file not found"); + } + await assertPngFile(settings.logoPath); + args.push("-i", settings.logoPath); + logoInputIndex = nextInput++; + } else if (useDefaultPng) { + args.push("-i", defaultPath); + defaultWmInputIndex = nextInput++; } + const applyWm = + settings.mode === "logo" && logoInputIndex !== null + ? ("logo" as const) + : settings.mode === "text" && settings.text?.trim() + ? ("text" as const) + : settings.mode === "default" && defaultWmInputIndex !== null + ? ("default-png" as const) + : settings.mode === "default" + ? ("default-text" as const) + : ("none" as const); + + // Fast path: classic letterbox, no watermark + if (!layout && applyWm === "none") { + args.push("-vf", classicScaleFilter(res.width, res.height)); + args.push( + "-c:v", + "libx264", + "-tune", + "stillimage", + "-c:a", + "copy", + "-shortest", + "-pix_fmt", + "yuv420p", + options.outputPath, + ); + await runFfmpeg(args); + return; + } + + const outDirect = applyWm === "none"; + const baseLabel = outDirect ? "vout" : "base"; + const filterParts: string[] = []; + + if (layout) { + const titleEscaped = sanitizeDrawtext(options.title?.trim() || "Untitled"); + const artistRaw = options.artist?.trim(); + const artistEscaped = artistRaw ? sanitizeDrawtext(artistRaw) : null; + filterParts.push( + artTrackFilterEndingAt( + { + width: res.width, + height: res.height, + layout, + titleEscaped, + artistEscaped, + fontfileEscaped: fontfile, + }, + baseLabel, + ), + ); + } else { + filterParts.push(`[0:v]${classicScaleFilter(res.width, res.height)}[${baseLabel}]`); + } + + if (applyWm === "logo" && logoInputIndex !== null) { + const { x, y } = overlayXy(settings.position, settings.offsetX, settings.offsetY); + filterParts.push( + `[${logoInputIndex}:v]scale=${watermarkWidth}:-1[wm]`, + `[${baseLabel}][wm]overlay=${x}:${y}[vout]`, + ); + } else if (applyWm === "text") { + const draw = buildDrawtextFilter({ + text: settings.text!.trim(), + fontSize, + fontColor: "white@0.9", + position: settings.position, + offsetX: settings.offsetX, + offsetY: settings.offsetY, + fontfileEscaped: fontfile, + }); + filterParts.push(`[${baseLabel}]${draw}[vout]`); + } else if (applyWm === "default-png" && defaultWmInputIndex !== null) { + const { x, y } = overlayXy(settings.position, settings.offsetX, settings.offsetY); + filterParts.push( + `[${defaultWmInputIndex}:v]scale=${watermarkWidth}:-1[wm]`, + `[${baseLabel}][wm]overlay=${x}:${y}[vout]`, + ); + } else if (applyWm === "default-text") { + const draw = buildDrawtextFilter({ + text: getVideoAttributionText(), + fontSize, + fontColor: "white@0.85", + position: settings.position, + offsetX: settings.offsetX, + offsetY: settings.offsetY, + fontfileEscaped: null, + }); + filterParts.push(`[${baseLabel}]${draw}[vout]`); + } + + args.push("-filter_complex", filterParts.join(";"), "-map", "[vout]", "-map", "1:a"); args.push( "-c:v", "libx264", @@ -89,6 +253,7 @@ function runFfmpeg(args: string[]): Promise { let stderr = ""; proc.stderr.on("data", (chunk) => { stderr += chunk.toString(); + if (stderr.length > 64_000) stderr = stderr.slice(-32_000); }); proc.on("close", (code) => { if (code === 0) resolve(); diff --git a/lib/fonts-server.ts b/lib/fonts-server.ts new file mode 100644 index 0000000..fc6b9d5 --- /dev/null +++ b/lib/fonts-server.ts @@ -0,0 +1,44 @@ +/** Server-only font filesystem helpers. Do not import from client components. */ + +import fs from "fs/promises"; +import path from "path"; +import { CURATED_FONTS, type CuratedFontKey } from "./fonts"; + +export function getFontsDir(): string { + return path.join(process.cwd(), "assets", "fonts"); +} + +/** Resolve a curated font file on disk (must exist under assets/fonts). */ +export function resolveCuratedFontPath(key: CuratedFontKey): string { + const meta = CURATED_FONTS.find((f) => f.key === key); + if (!meta) throw new Error("Unknown font"); + // Whitelist filename only never accept user-controlled path segments + const safe = meta.file.replace(/[^a-zA-Z0-9._-]/g, ""); + if (safe !== meta.file) throw new Error("Invalid font asset name"); + return path.join(getFontsDir(), safe); +} + +/** + * Validate TTF / OTF magic bytes. + * TTF: 00 01 00 00 | true | typ1 + * OTF: OTTO + */ +export async function assertFontFile(filePath: string): Promise<"ttf" | "otf"> { + const fh = await fs.open(filePath, "r"); + try { + const buf = Buffer.alloc(4); + await fh.read(buf, 0, 4, 0); + const asStr = buf.toString("ascii"); + if (asStr === "OTTO") return "otf"; + if (asStr === "true" || asStr === "typ1") return "ttf"; + if (buf[0] === 0x00 && buf[1] === 0x01 && buf[2] === 0x00 && buf[3] === 0x00) { + return "ttf"; + } + if (asStr === "wOFF" || asStr === "wOF2") { + throw new Error("WOFF fonts are not supported. Upload a .ttf or .otf file."); + } + throw new Error("File is not a valid TTF or OTF font"); + } finally { + await fh.close(); + } +} diff --git a/lib/fonts.ts b/lib/fonts.ts new file mode 100644 index 0000000..3e7d3eb --- /dev/null +++ b/lib/fonts.ts @@ -0,0 +1,75 @@ +/** Shared font catalog safe for client and server bundles (no Node APIs). */ + +/** Max custom font upload size (10 MB). */ +export const FONT_UPLOAD_MAX_BYTES = 10 * 1024 * 1024; + +export const CURATED_FONTS = [ + { + key: "inter", + label: "Inter", + cssFamily: "Inter", + googleCss: "Inter:wght@400;600", + file: "Inter-Regular.ttf", + }, + { + key: "montserrat", + label: "Montserrat", + cssFamily: "Montserrat", + googleCss: "Montserrat:wght@400;600", + file: "Montserrat-Regular.ttf", + }, + { + key: "roboto", + label: "Roboto", + cssFamily: "Roboto", + googleCss: "Roboto:wght@400;500", + file: "Roboto-Regular.ttf", + }, + { + key: "oswald", + label: "Oswald", + cssFamily: "Oswald", + googleCss: "Oswald:wght@400;500", + file: "Oswald-Regular.ttf", + }, + { + key: "playfair", + label: "Playfair Display", + cssFamily: "Playfair Display", + googleCss: "Playfair+Display:wght@400;600", + file: "PlayfairDisplay-Regular.ttf", + }, +] as const; + +export type CuratedFontKey = (typeof CURATED_FONTS)[number]["key"]; +export type WatermarkFontKey = CuratedFontKey | "custom" | "system"; + +const CURATED_KEYS = new Set(CURATED_FONTS.map((f) => f.key)); + +export function isCuratedFontKey(v: unknown): v is CuratedFontKey { + return typeof v === "string" && CURATED_KEYS.has(v); +} + +export function isWatermarkFontKey(v: unknown): v is WatermarkFontKey { + return v === "custom" || v === "system" || isCuratedFontKey(v); +} + +/** + * Escape an absolute font path for use inside an FFmpeg filtergraph `fontfile=` value. + * Paths are never passed through a shell; this only escapes filter special chars. + */ +export function sanitizeFontfileForFilter(absPath: string): string { + return absPath + .replace(/\\/g, "/") + .replace(/:/g, "\\:") + .replace(/'/g, "\\'") + .replace(/\[/g, "\\[") + .replace(/\]/g, "\\]"); +} + +export function googleFontsStylesheetUrl(keys: CuratedFontKey[]): string { + const families = CURATED_FONTS.filter((f) => keys.includes(f.key)) + .map((f) => `family=${f.googleCss}`) + .join("&"); + return `https://fonts.googleapis.com/css2?${families}&display=swap`; +} diff --git a/lib/jobs/create-job.ts b/lib/jobs/create-job.ts index e90aaec..0319eba 100644 --- a/lib/jobs/create-job.ts +++ b/lib/jobs/create-job.ts @@ -5,7 +5,22 @@ import { readAudioTags } from "../audio-tags"; import { isAllowedResolution } from "../constants"; import { prisma } from "../db"; import { hasProFeatures } from "../edition"; +import { + assertArtTrackLayoutAllowed, + assertCustomWatermarkAllowed, + assertPerItemImagesAllowed, + PremiumRequiredError, +} from "../entitlements"; +import { FONT_UPLOAD_MAX_BYTES } from "../fonts"; +import { assertFontFile } from "../fonts-server"; +import { assertPngFile } from "../ffmpeg/encode"; import { moveFile, writeUploadedFile } from "../fs-utils"; +import { + ARTIST_MAX, + INVALID_LAYOUT_TEMPLATE_MESSAGE, + normalizeLayoutSettings, + type LayoutSettings, +} from "../layout"; import { enqueueVideoJob } from "../queue/client"; import { getPlanLimits, @@ -19,7 +34,54 @@ import { getUserStagingDir, sanitizeUploadSessionKey, } from "../upload-paths"; -import type { CreateJobPayload } from "../types"; +import type { CreateJobPayload, ItemMetadata } from "../types"; +import { + normalizeWatermarkSettings, + WATERMARK_TEXT_MAX, +} from "../watermark"; + +/** Merge nested + flat layout fields, then normalize (throws on bad template / coords). */ +export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSettings { + return normalizeLayoutSettings({ + ...(metadata.layout ?? {}), + template: + metadata.layout?.template ?? + metadata.layout?.layoutTemplate ?? + metadata.layout?.layout_template ?? + metadata.layoutTemplate ?? + metadata.layout_template, + blurAmount: + metadata.layout?.blurAmount ?? + metadata.layout?.blur_amount ?? + metadata.blurAmount ?? + metadata.blur_amount, + blurOpacity: + metadata.layout?.blurOpacity ?? + (metadata.layout as { blur_opacity?: number } | null | undefined)?.blur_opacity ?? + metadata.blurOpacity ?? + metadata.blur_opacity, + textPadding: + metadata.layout?.textPadding ?? + metadata.layout?.text_padding ?? + metadata.textPadding ?? + metadata.text_padding, + titleArtistGap: + metadata.layout?.titleArtistGap ?? + (metadata.layout as { title_artist_gap?: number } | null | undefined)?.title_artist_gap ?? + metadata.titleArtistGap ?? + metadata.title_artist_gap, + textOffsetX: + metadata.layout?.textOffsetX ?? + (metadata.layout as { text_offset_x?: number } | null | undefined)?.text_offset_x ?? + metadata.textOffsetX ?? + metadata.text_offset_x, + textOffsetY: + metadata.layout?.textOffsetY ?? + (metadata.layout as { text_offset_y?: number } | null | undefined)?.text_offset_y ?? + metadata.textOffsetY ?? + metadata.text_offset_y, + }); +} export function validateItemMetadata(metadata: CreateJobPayload["items"][0]["metadata"]) { if (!metadata.title?.trim()) return "Each video must have a title"; @@ -27,6 +89,20 @@ export function validateItemMetadata(metadata: CreateJobPayload["items"][0]["met if (!["PUBLIC", "PRIVATE", "UNLISTED"].includes(metadata.privacy)) { return "Invalid privacy setting"; } + if (metadata.watermark?.text && metadata.watermark.text.length > WATERMARK_TEXT_MAX) { + return `Watermark text must be at most ${WATERMARK_TEXT_MAX} characters`; + } + if (metadata.artist && metadata.artist.length > ARTIST_MAX) { + return `Artist must be at most ${ARTIST_MAX} characters`; + } + try { + resolveLayoutFromMetadata(metadata); + } catch (err) { + if (err instanceof Error && err.message === INVALID_LAYOUT_TEMPLATE_MESSAGE) { + return INVALID_LAYOUT_TEMPLATE_MESSAGE; + } + throw err; + } return null; } @@ -35,6 +111,8 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body: return "Image and at least one audio file required"; } + const itemImagePaths: Array = []; + for (const item of body.items) { const metaError = validateItemMetadata(item.metadata); if (metaError) return metaError; @@ -42,8 +120,32 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body: return `Resolution ${item.metadata.resolution} is not available on your plan`; } if (item.metadata.playlistId && !hasProFeatures(user.plan)) { - return "Adding videos to a YouTube playlist requires the Pro plan"; + return "Adding videos to a YouTube playlist is unavailable for this account"; } + + const wm = normalizeWatermarkSettings( + item.metadata.watermark, + item.metadata.includeWatermark, + ); + try { + assertCustomWatermarkAllowed(user.plan, wm); + assertArtTrackLayoutAllowed(user.plan, resolveLayoutFromMetadata(item.metadata)); + } catch (err) { + if (err instanceof PremiumRequiredError) return err.message; + if (err instanceof Error && err.message === INVALID_LAYOUT_TEMPLATE_MESSAGE) { + return INVALID_LAYOUT_TEMPLATE_MESSAGE; + } + throw err; + } + + itemImagePaths.push(item.metadata.imagePath); + } + + try { + assertPerItemImagesAllowed(user.plan, body.imagePath, itemImagePaths); + } catch (err) { + if (err instanceof PremiumRequiredError) return err.message; + throw err; } const limits = getPlanLimits(user.plan); @@ -66,11 +168,60 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body: if (stat.size > limits.maxFileSizeBytes) { return `Audio file ${item.audioFilename} exceeds size limit`; } + + if (item.metadata.imagePath) { + const itemImg = assertPathInUserUploads(user.id, item.metadata.imagePath); + await fs.access(itemImg); + const st = await fs.stat(itemImg); + if (st.size > limits.maxFileSizeBytes) { + return `Per-item image for ${item.audioFilename} exceeds size limit`; + } + } + + const wm = normalizeWatermarkSettings( + item.metadata.watermark, + item.metadata.includeWatermark, + ); + if (wm.mode === "logo" && wm.logoPath) { + const logoPath = assertPathInUserUploads(user.id, wm.logoPath); + await fs.access(logoPath); + await assertPngFile(logoPath); + const st = await fs.stat(logoPath); + if (st.size > limits.maxFileSizeBytes) { + return "Watermark logo exceeds size limit"; + } + } + if (wm.mode === "text" && !wm.text?.trim()) { + return "Watermark text mode requires non-empty text"; + } + if (wm.mode === "text" && wm.fontKey === "custom") { + if (!wm.fontPath) { + return "Custom font selected but no font file uploaded"; + } + const fontPath = assertPathInUserUploads(user.id, wm.fontPath); + await fs.access(fontPath); + await assertFontFile(fontPath); + const st = await fs.stat(fontPath); + if (st.size > FONT_UPLOAD_MAX_BYTES) { + return "Custom font exceeds 10 MB limit"; + } + } } } catch (err) { + if (err instanceof PremiumRequiredError) return err.message; if (err instanceof Error && err.message === "Invalid upload path") { return "Invalid upload path"; } + if ( + err instanceof Error && + (err.message.includes("PNG") || + err.message.includes("font") || + err.message.includes("TTF") || + err.message.includes("OTF") || + err.message.includes("WOFF")) + ) { + return err.message; + } return "One or more uploaded files not found"; } @@ -83,13 +234,32 @@ export async function createVideoJob( ) { const validationError = await validateJobPayload(user, body); if (validationError) { - throw new Error(validationError); + if (validationError === INVALID_LAYOUT_TEMPLATE_MESSAGE) { + throw new Error(validationError); + } + const isPremium = + validationError.includes("requires Pro") || + validationError.includes("Pro plan"); + const err = isPremium + ? new PremiumRequiredError(validationError) + : new Error(validationError); + throw err; } const imagePath = assertPathInUserUploads(user.id, body.imagePath); const items = body.items.map((item) => ({ ...item, audioPath: assertPathInUserUploads(user.id, item.audioPath), + itemImagePath: item.metadata.imagePath + ? assertPathInUserUploads(user.id, item.metadata.imagePath) + : null, + watermarkLogoPath: item.metadata.watermark?.logoPath + ? assertPathInUserUploads(user.id, item.metadata.watermark.logoPath) + : null, + watermarkFontPath: + item.metadata.watermark?.fontKey === "custom" && item.metadata.watermark?.fontPath + ? assertPathInUserUploads(user.id, item.metadata.watermark.fontPath) + : null, })); const reservation = await reserveQuota(user.id, items.length); @@ -100,23 +270,54 @@ export async function createVideoJob( userId: user.id, imagePath, items: { - create: items.map((item, index) => ({ - audioPath: item.audioPath, - audioFilename: item.audioFilename, - title: item.metadata.title.trim(), - description: item.metadata.description || "", - tags: item.metadata.tags || "", - privacy: item.metadata.privacy as Privacy, - categoryId: item.metadata.categoryId || "10", - resolution: item.metadata.resolution, - notifySubscribers: item.metadata.notifySubscribers, - madeForKids: item.metadata.madeForKids, - embeddable: item.metadata.embeddable, - creativeCommons: item.metadata.creativeCommons, - includeWatermark: item.metadata.includeWatermark, - playlistId: item.metadata.playlistId?.trim() || null, - billingSource: index < reservation.fromQuota ? "QUOTA" : "CREDIT", - })), + create: items.map((item) => { + const wm = normalizeWatermarkSettings( + item.metadata.watermark + ? { + ...item.metadata.watermark, + logoPath: item.watermarkLogoPath, + fontPath: item.watermarkFontPath, + } + : null, + item.metadata.includeWatermark, + ); + const layout = resolveLayoutFromMetadata(item.metadata); + return { + audioPath: item.audioPath, + audioFilename: item.audioFilename, + title: item.metadata.title.trim(), + description: item.metadata.description || "", + tags: item.metadata.tags || "", + privacy: item.metadata.privacy as Privacy, + categoryId: item.metadata.categoryId || "10", + resolution: item.metadata.resolution, + notifySubscribers: item.metadata.notifySubscribers, + madeForKids: item.metadata.madeForKids, + embeddable: item.metadata.embeddable, + creativeCommons: item.metadata.creativeCommons, + includeWatermark: wm.mode !== "none", + itemImagePath: item.itemImagePath, + watermarkMode: wm.mode, + watermarkText: wm.mode === "text" ? wm.text?.trim() || null : null, + watermarkLogoPath: wm.mode === "logo" ? item.watermarkLogoPath : null, + watermarkFontKey: wm.mode === "text" ? wm.fontKey ?? "system" : null, + watermarkFontPath: + wm.mode === "text" && wm.fontKey === "custom" ? item.watermarkFontPath : null, + watermarkPosition: wm.position, + watermarkOffsetX: wm.offsetX, + watermarkOffsetY: wm.offsetY, + artist: item.metadata.artist?.trim().slice(0, ARTIST_MAX) || null, + layoutTemplate: layout.template, + blurAmount: layout.blurAmount, + blurOpacity: layout.blurOpacity, + textPadding: layout.textPadding, + titleArtistGap: layout.titleArtistGap, + textOffsetX: layout.textOffsetX, + textOffsetY: layout.textOffsetY, + playlistId: item.metadata.playlistId?.trim() || null, + billingSource: "QUOTA", + }; + }), }, }, include: { items: true }, @@ -130,14 +331,61 @@ export async function createVideoJob( await moveFile(imagePath, newImagePath); await prisma.job.update({ where: { id: job.id }, data: { imagePath: newImagePath } }); + const moved = new Map(); + moved.set(imagePath, newImagePath); + await Promise.all( job.items.map(async (item) => { const audioExt = path.extname(item.audioPath); const newAudioPath = path.join(jobDir, `${item.id}${audioExt}`); await moveFile(item.audioPath, newAudioPath); + + let newItemImage: string | null = null; + if (item.itemImagePath) { + const src = item.itemImagePath; + if (moved.has(src)) { + newItemImage = moved.get(src)!; + } else { + const ext = path.extname(src); + newItemImage = path.join(jobDir, `${item.id}-cover${ext}`); + await moveFile(src, newItemImage); + moved.set(src, newItemImage); + } + } + + let newLogo: string | null = null; + if (item.watermarkLogoPath) { + const src = item.watermarkLogoPath; + if (moved.has(src)) { + newLogo = moved.get(src)!; + } else { + newLogo = path.join(jobDir, `${item.id}-logo.png`); + await moveFile(src, newLogo); + moved.set(src, newLogo); + } + } + + let newFont: string | null = null; + if (item.watermarkFontPath) { + const src = item.watermarkFontPath; + if (moved.has(src)) { + newFont = moved.get(src)!; + } else { + const ext = path.extname(src).toLowerCase() === ".otf" ? ".otf" : ".ttf"; + newFont = path.join(jobDir, `${item.id}-font${ext}`); + await moveFile(src, newFont); + moved.set(src, newFont); + } + } + await prisma.jobItem.update({ where: { id: item.id }, - data: { audioPath: newAudioPath }, + data: { + audioPath: newAudioPath, + itemImagePath: newItemImage, + watermarkLogoPath: newLogo, + watermarkFontPath: newFont, + }, }); await enqueueVideoJob({ @@ -155,19 +403,42 @@ export async function createVideoJob( } } +export type UploadFileType = "image" | "audio" | "logo" | "font"; + export async function saveUploadedFile( userId: string, file: File, - type: "image" | "audio", + type: UploadFileType, plan: Plan, options?: { sessionKey?: string }, ) { const limits = getPlanLimits(plan); - if (file.size > limits.maxFileSizeBytes) { + if (type === "font") { + if (!limits.customWatermark && !hasProFeatures(plan)) { + throw new PremiumRequiredError("Custom watermark fonts require Pro."); + } + if (file.size > FONT_UPLOAD_MAX_BYTES) { + throw new Error("Font file must be 10 MB or smaller"); + } + if (!/\.(ttf|otf)$/i.test(file.name)) { + throw new Error("Font must be a .ttf or .otf file"); + } + } else if (file.size > limits.maxFileSizeBytes) { throw new Error(`File exceeds ${limits.maxFileSizeBytes / (1024 * 1024)} MB limit`); } + if (type === "logo") { + if (!limits.customWatermark && !hasProFeatures(plan)) { + throw new PremiumRequiredError("Custom logo watermarks require Pro."); + } + const nameOk = /\.png$/i.test(file.name); + const typeOk = file.type === "image/png" || file.type === ""; + if (!nameOk && !typeOk) { + throw new Error("Watermark logo must be a PNG file"); + } + } + const allowedImageTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"]; const allowedAudioTypes = [ "audio/mpeg", @@ -180,18 +451,25 @@ export async function saveUploadedFile( "audio/mp4", "audio/x-m4a", ]; - const allowedTypes = type === "image" ? allowedImageTypes : allowedAudioTypes; - if ( - !allowedTypes.includes(file.type) && - !file.name.match(/\.(mp3|wav|ogg|flac|aac|m4a|jpg|jpeg|png|webp|gif)$/i) - ) { - throw new Error("Invalid file type"); - } - - if (type === "audio" && !isAudioExtensionAllowed(file.name, plan)) { - const allowed = limits.allowedAudioExtensions.join(", "); - throw new Error(`Your plan supports ${allowed} audio files only`); + if (type === "image") { + if ( + !allowedImageTypes.includes(file.type) && + !file.name.match(/\.(jpg|jpeg|png|webp|gif)$/i) + ) { + throw new Error("Invalid image file type"); + } + } else if (type === "audio") { + if ( + !allowedAudioTypes.includes(file.type) && + !file.name.match(/\.(mp3|wav|ogg|flac|aac|m4a)$/i) + ) { + throw new Error("Invalid audio file type"); + } + if (!isAudioExtensionAllowed(file.name, plan)) { + const allowed = limits.allowedAudioExtensions.join(", "); + throw new Error(`Your plan supports ${allowed} audio files only`); + } } const sessionKey = sanitizeUploadSessionKey(options?.sessionKey); @@ -204,6 +482,24 @@ export async function saveUploadedFile( assertPathInUserUploads(userId, filePath); await writeUploadedFile(file, filePath); + if (type === "logo") { + try { + await assertPngFile(filePath); + } catch (err) { + await fs.unlink(filePath).catch(() => {}); + throw err; + } + } + + if (type === "font") { + try { + await assertFontFile(filePath); + } catch (err) { + await fs.unlink(filePath).catch(() => {}); + throw err; + } + } + let audioTags = null; if (type === "audio" && limits.id3TagSupport && file.name.toLowerCase().endsWith(".mp3")) { audioTags = await readAudioTags(filePath); diff --git a/lib/layout.ts b/lib/layout.ts new file mode 100644 index 0000000..4f9777d --- /dev/null +++ b/lib/layout.ts @@ -0,0 +1,417 @@ +/** + * Art-track layout templates + blur background (Pro). + * Cover/text anchors come from enum templates; only clamped fine-tuning offsets are allowed. + */ + +export const LAYOUT_TEMPLATES = [ + "COVER_LEFT_TEXT_RIGHT", + "COVER_TOP_TEXT_BOTTOM", + "COVER_RIGHT_TEXT_LEFT", + "CENTERED_COMPACT", +] as const; + +export type LayoutTemplate = (typeof LAYOUT_TEMPLATES)[number]; + +export const INVALID_LAYOUT_TEMPLATE_MESSAGE = + "Invalid layout template. Refer to API documentation for valid enum values."; + +export const BLUR_AMOUNT_MIN = 0; +export const BLUR_AMOUNT_MAX = 100; +export const BLUR_AMOUNT_DEFAULT = 55; + +/** Visibility of the blurred cover fill over black (0 = solid black, 100 = full blur). */ +export const BLUR_OPACITY_MIN = 0; +export const BLUR_OPACITY_MAX = 100; +export const BLUR_OPACITY_DEFAULT = 100; + +export const TEXT_PADDING_MIN = 16; +export const TEXT_PADDING_MAX = 120; +export const TEXT_PADDING_DEFAULT = 48; + +export const TITLE_ARTIST_GAP_MIN = 0; +export const TITLE_ARTIST_GAP_MAX = 64; +export const TITLE_ARTIST_GAP_DEFAULT = 10; + +/** Fine-tune title/artist block within the template (not free-form canvas coords). */ +export const TEXT_OFFSET_MIN = -120; +export const TEXT_OFFSET_MAX = 120; +export const TEXT_OFFSET_DEFAULT = 0; + +export const ARTIST_MAX = 80; + +export type LayoutSettings = { + /** When null, classic letterbox (no art-track layout / blur fill). */ + template: LayoutTemplate | null; + /** 0–100 β†’ FFmpeg boxblur intensity. */ + blurAmount: number; + /** 0–100 β†’ how visible the blurred fill is (vs black). */ + blurOpacity: number; + /** Pixel padding / gap around cover + text. */ + textPadding: number; + /** Extra pixels between title and artist lines. */ + titleArtistGap: number; + /** Shift text block horizontally within the template. */ + textOffsetX: number; + /** Shift text block vertically within the template. */ + textOffsetY: number; +}; + +export const DEFAULT_LAYOUT: LayoutSettings = { + template: null, + blurAmount: BLUR_AMOUNT_DEFAULT, + blurOpacity: BLUR_OPACITY_DEFAULT, + textPadding: TEXT_PADDING_DEFAULT, + titleArtistGap: TITLE_ARTIST_GAP_DEFAULT, + textOffsetX: TEXT_OFFSET_DEFAULT, + textOffsetY: TEXT_OFFSET_DEFAULT, +}; + +export function isLayoutTemplate(v: unknown): v is LayoutTemplate { + return typeof v === "string" && (LAYOUT_TEMPLATES as readonly string[]).includes(v); +} + +export function clampBlurAmount(n: unknown, fallback = BLUR_AMOUNT_DEFAULT): number { + const v = typeof n === "number" ? n : Number(n); + if (!Number.isFinite(v)) return fallback; + return Math.max(BLUR_AMOUNT_MIN, Math.min(BLUR_AMOUNT_MAX, Math.round(v))); +} + +export function clampBlurOpacity(n: unknown, fallback = BLUR_OPACITY_DEFAULT): number { + const v = typeof n === "number" ? n : Number(n); + if (!Number.isFinite(v)) return fallback; + return Math.max(BLUR_OPACITY_MIN, Math.min(BLUR_OPACITY_MAX, Math.round(v))); +} + +export function clampTextPadding(n: unknown, fallback = TEXT_PADDING_DEFAULT): number { + const v = typeof n === "number" ? n : Number(n); + if (!Number.isFinite(v)) return fallback; + return Math.max(TEXT_PADDING_MIN, Math.min(TEXT_PADDING_MAX, Math.round(v))); +} + +export function clampTitleArtistGap(n: unknown, fallback = TITLE_ARTIST_GAP_DEFAULT): number { + const v = typeof n === "number" ? n : Number(n); + if (!Number.isFinite(v)) return fallback; + return Math.max(TITLE_ARTIST_GAP_MIN, Math.min(TITLE_ARTIST_GAP_MAX, Math.round(v))); +} + +export function clampTextOffset(n: unknown, fallback = TEXT_OFFSET_DEFAULT): number { + const v = typeof n === "number" ? n : Number(n); + if (!Number.isFinite(v)) return fallback; + return Math.max(TEXT_OFFSET_MIN, Math.min(TEXT_OFFSET_MAX, Math.round(v))); +} + +export function blurToBoxblur(blurAmount: number): { radius: number; power: number } | null { + const amount = clampBlurAmount(blurAmount, 0); + if (amount <= 0) return null; + const radius = Math.max(1, Math.round((amount / 100) * 50)); + const power = Math.max(1, Math.min(4, Math.ceil(amount / 25))); + return { radius, power }; +} + +export function boxblurFilterSegment(blurAmount: number): string { + const bb = blurToBoxblur(blurAmount); + if (!bb) return ""; + return `boxblur=luma_radius=${bb.radius}:luma_power=${bb.power}:chroma_radius=${bb.radius}:chroma_power=${bb.power}`; +} + +type RawLayoutInput = { + template?: unknown; + layoutTemplate?: unknown; + layout_template?: unknown; + blurAmount?: unknown; + blur_amount?: unknown; + blurOpacity?: unknown; + blur_opacity?: unknown; + textPadding?: unknown; + text_padding?: unknown; + titleArtistGap?: unknown; + title_artist_gap?: unknown; + textOffsetX?: unknown; + text_offset_x?: unknown; + textOffsetY?: unknown; + text_offset_y?: unknown; + /** Rejected clients must not send free-form cover coordinates. */ + x?: unknown; + y?: unknown; + coverX?: unknown; + coverY?: unknown; + offsetX?: unknown; + offsetY?: unknown; +}; + +export function normalizeLayoutSettings( + input: RawLayoutInput | Partial | null | undefined, +): LayoutSettings { + if (!input) return { ...DEFAULT_LAYOUT }; + + const forbidden = ["x", "y", "coverX", "coverY", "offsetX", "offsetY"] as const; + for (const k of forbidden) { + if ((input as RawLayoutInput)[k] !== undefined) { + throw new Error(INVALID_LAYOUT_TEMPLATE_MESSAGE); + } + } + + const raw = + (input as RawLayoutInput).template ?? + (input as RawLayoutInput).layoutTemplate ?? + (input as RawLayoutInput).layout_template; + + let template: LayoutTemplate | null = null; + if (raw !== undefined && raw !== null && raw !== "" && raw !== "CLASSIC" && raw !== "classic") { + if (!isLayoutTemplate(raw)) { + throw new Error(INVALID_LAYOUT_TEMPLATE_MESSAGE); + } + template = raw; + } + + const blurRaw = + (input as RawLayoutInput).blurAmount ?? + (input as RawLayoutInput).blur_amount ?? + (input as LayoutSettings).blurAmount; + const opacityRaw = + (input as RawLayoutInput).blurOpacity ?? + (input as RawLayoutInput).blur_opacity ?? + (input as LayoutSettings).blurOpacity; + const padRaw = + (input as RawLayoutInput).textPadding ?? + (input as RawLayoutInput).text_padding ?? + (input as LayoutSettings).textPadding; + const gapRaw = + (input as RawLayoutInput).titleArtistGap ?? + (input as RawLayoutInput).title_artist_gap ?? + (input as LayoutSettings).titleArtistGap; + const oxRaw = + (input as RawLayoutInput).textOffsetX ?? + (input as RawLayoutInput).text_offset_x ?? + (input as LayoutSettings).textOffsetX; + const oyRaw = + (input as RawLayoutInput).textOffsetY ?? + (input as RawLayoutInput).text_offset_y ?? + (input as LayoutSettings).textOffsetY; + + return { + template, + blurAmount: clampBlurAmount(blurRaw, BLUR_AMOUNT_DEFAULT), + blurOpacity: clampBlurOpacity(opacityRaw, BLUR_OPACITY_DEFAULT), + textPadding: clampTextPadding(padRaw, TEXT_PADDING_DEFAULT), + titleArtistGap: clampTitleArtistGap(gapRaw, TITLE_ARTIST_GAP_DEFAULT), + textOffsetX: clampTextOffset(oxRaw, TEXT_OFFSET_DEFAULT), + textOffsetY: clampTextOffset(oyRaw, TEXT_OFFSET_DEFAULT), + }; +} + +export function requiresArtTrackLayoutEntitlement(settings: LayoutSettings): boolean { + return settings.template !== null; +} + +export type LayoutGeometry = { + coverMaxW: number; + coverMaxH: number; + coverX: string; + coverY: string; + titleFontSize: number; + artistFontSize: number; + titleX: string; + titleY: string; + artistX: string; + artistY: string; +}; + +function applyTextOffsets( + geo: LayoutGeometry, + textOffsetX: number, + textOffsetY: number, +): LayoutGeometry { + const ox = clampTextOffset(textOffsetX); + const oy = clampTextOffset(textOffsetY); + if (ox === 0 && oy === 0) return geo; + + const shiftX = (expr: string) => { + if (expr === "(w-text_w)/2") return `(w-text_w)/2+${ox}`; + if (/^-?\d+$/.test(expr)) return String(Number(expr) + ox); + return `${expr}+${ox}`; + }; + const shiftY = (expr: string) => { + if (/^-?\d+$/.test(expr)) return String(Number(expr) + oy); + return `${expr}+${oy}`; + }; + + return { + ...geo, + titleX: shiftX(geo.titleX), + titleY: shiftY(geo.titleY), + artistX: shiftX(geo.artistX), + artistY: shiftY(geo.artistY), + }; +} + +/** Pixel geometry template + padding + title/artist gap + text offsets. */ +export function computeLayoutGeometry( + template: LayoutTemplate, + width: number, + height: number, + textPadding: number, + titleArtistGap: number = TITLE_ARTIST_GAP_DEFAULT, + textOffsetX: number = 0, + textOffsetY: number = 0, +): LayoutGeometry { + const pad = clampTextPadding(textPadding); + const gap = clampTitleArtistGap(titleArtistGap); + const titleFontSize = Math.max(22, Math.round(width * 0.032)); + const artistFontSize = Math.max(16, Math.round(width * 0.02)); + const lineGap = gap; + + let base: LayoutGeometry; + + switch (template) { + case "COVER_LEFT_TEXT_RIGHT": { + const coverMaxW = Math.round(width * 0.42); + const coverMaxH = height - pad * 2; + const textX = pad + coverMaxW + pad; + const midY = Math.round(height / 2); + base = { + coverMaxW, + coverMaxH, + coverX: String(pad), + coverY: "(H-h)/2", + titleFontSize, + artistFontSize, + titleX: String(textX), + titleY: String(midY - titleFontSize - Math.round(lineGap / 2)), + artistX: String(textX), + artistY: String(midY + Math.round(lineGap / 2)), + }; + break; + } + case "COVER_RIGHT_TEXT_LEFT": { + const coverMaxW = Math.round(width * 0.42); + const coverMaxH = height - pad * 2; + const textX = pad; + const midY = Math.round(height / 2); + base = { + coverMaxW, + coverMaxH, + coverX: `W-w-${pad}`, + coverY: "(H-h)/2", + titleFontSize, + artistFontSize, + titleX: String(textX), + titleY: String(midY - titleFontSize - Math.round(lineGap / 2)), + artistX: String(textX), + artistY: String(midY + Math.round(lineGap / 2)), + }; + break; + } + case "COVER_TOP_TEXT_BOTTOM": { + // Near full width avoid empty side gutters next to the cover + const coverMaxW = width - pad * 2; + const coverMaxH = Math.round(height * 0.56); + const textBlockTop = pad + coverMaxH + Math.round(pad * 0.55); + base = { + coverMaxW, + coverMaxH, + coverX: "(W-w)/2", + coverY: String(pad), + titleFontSize, + artistFontSize, + titleX: "(w-text_w)/2", + titleY: String(textBlockTop), + artistX: "(w-text_w)/2", + artistY: String(textBlockTop + titleFontSize + lineGap), + }; + break; + } + case "CENTERED_COMPACT": { + const side = Math.round(Math.min(width, height) * 0.4); + const stackH = side + pad + titleFontSize + lineGap + artistFontSize; + const stackTop = Math.round((height - stackH) / 2); + const coverY = Math.max(pad, stackTop); + const titleY = coverY + side + Math.round(pad * 0.55); + base = { + coverMaxW: side, + coverMaxH: side, + coverX: "(W-w)/2", + coverY: String(coverY), + titleFontSize, + artistFontSize, + titleX: "(w-text_w)/2", + titleY: String(titleY), + artistX: "(w-text_w)/2", + artistY: String(titleY + titleFontSize + lineGap), + }; + break; + } + default: { + throw new Error(INVALID_LAYOUT_TEMPLATE_MESSAGE); + } + } + + return applyTextOffsets(base, textOffsetX, textOffsetY); +} + +export function buildArtTrackFilterComplex(opts: { + width: number; + height: number; + layout: LayoutSettings; + titleEscaped: string; + artistEscaped: string | null; + fontfileEscaped?: string | null; +}): string { + const { width: W, height: H, layout } = opts; + if (!layout.template) { + throw new Error("Art-track filter requires a layout template"); + } + + const geo = computeLayoutGeometry( + layout.template, + W, + H, + layout.textPadding, + layout.titleArtistGap, + layout.textOffsetX, + layout.textOffsetY, + ); + const blurSeg = boxblurFilterSegment(layout.blurAmount); + const bgChain = blurSeg + ? `scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H},${blurSeg}` + : `scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H}`; + + const opacity = clampBlurOpacity(layout.blurOpacity, BLUR_OPACITY_DEFAULT) / 100; + const fontPart = opts.fontfileEscaped ? `:fontfile='${opts.fontfileEscaped}'` : ""; + const titleDraw = `drawtext=text='${opts.titleEscaped}'${fontPart}:fontsize=${geo.titleFontSize}:fontcolor=white@0.95:x=${geo.titleX}:y=${geo.titleY}`; + const artistDraw = opts.artistEscaped + ? `,drawtext=text='${opts.artistEscaped}'${fontPart}:fontsize=${geo.artistFontSize}:fontcolor=white@0.75:x=${geo.artistX}:y=${geo.artistY}` + : ""; + + const parts: string[] = [`[0:v]split=2[bg][fg]`]; + + // Fade blurred fill toward black when opacity < 100% + if (opacity >= 0.999) { + parts.push(`[bg]${bgChain}[blurred]`); + } else if (opacity <= 0.001) { + parts.push(`color=c=black:s=${W}x${H}:d=1[blurred]`); + } else { + const a = opacity.toFixed(3); + const b = (1 - opacity).toFixed(3); + parts.push( + `[bg]${bgChain}[blur_raw]`, + `color=c=black:s=${W}x${H}:d=1[blk]`, + `[blur_raw][blk]blend=all_expr='A*${a}+B*${b}':shortest=1[blurred]`, + ); + } + + parts.push( + `[fg]scale=${geo.coverMaxW}:${geo.coverMaxH}:force_original_aspect_ratio=decrease[cover]`, + `[blurred][cover]overlay=${geo.coverX}:${geo.coverY}[composed]`, + `[composed]${titleDraw}${artistDraw}[laid]`, + ); + + return parts.join(";"); +} + +export const LAYOUT_TEMPLATE_LABELS: Record = { + COVER_LEFT_TEXT_RIGHT: "Cover left Β· text right", + COVER_TOP_TEXT_BOTTOM: "Cover top Β· text bottom", + COVER_RIGHT_TEXT_LEFT: "Cover right Β· text left", + CENTERED_COMPACT: "Centered compact", +}; diff --git a/lib/plans.ts b/lib/plans.ts index 64015f8..afe8657 100644 --- a/lib/plans.ts +++ b/lib/plans.ts @@ -8,54 +8,55 @@ export type PlanLimits = { maxResolutionHeight: number; maxFileSizeBytes: number; watermarkOptional: boolean; + customWatermark: boolean; + perItemImages: boolean; + artTrackLayouts: boolean; allowedAudioExtensions: readonly string[]; id3TagSupport: boolean; }; -export const PLAN_LIMITS: Record = { - FREE: { - monthlyQuota: 14, - maxBatchSize: 3, - maxResolutionHeight: 720, - maxFileSizeBytes: 30 * 1024 * 1024, - watermarkOptional: true, - allowedAudioExtensions: [".mp3"], - id3TagSupport: true, - }, - PREMIUM: { - monthlyQuota: 50, - maxBatchSize: 5, - maxResolutionHeight: 1080, - maxFileSizeBytes: 30 * 1024 * 1024, - watermarkOptional: true, - allowedAudioExtensions: [".mp3", ".wav", ".flac"], - id3TagSupport: true, - }, -}; - +/** Unlimited self-hosted limits β€” used for every plan in this OSS build. */ export const SELFHOSTED_LIMITS: PlanLimits = { monthlyQuota: 1_000_000, - maxBatchSize: 100, + maxBatchSize: 1_000, maxResolutionHeight: Math.max(...RESOLUTIONS.map((r) => r.height)), maxFileSizeBytes: 500 * 1024 * 1024, watermarkOptional: true, + customWatermark: true, + perItemImages: true, + artTrackLayouts: true, allowedAudioExtensions: [".mp3", ".wav", ".flac"], id3TagSupport: true, }; -export const SALES_EMAIL = "songs2yt@atakanozban.com"; -export const SUPPORT_EMAIL = "songs2yt@atakanozban.com"; -export const UPGRADE_URL = "/#pricing"; -export const GITEA_ISSUES_URL = - process.env.NEXT_PUBLIC_GITEA_ISSUES_URL ?? "https://git.atakanozban.com/Songs2YT/issues"; -export const GITEA_URL = - process.env.NEXT_PUBLIC_GITEA_URL ?? "https://git.atakanozban.com/Songs2YT"; -export const DOCKER_HUB_URL = - process.env.NEXT_PUBLIC_DOCKER_HUB_URL ?? "https://hub.docker.com/r/atakanozban/songs2yt"; +/** Kept for type compatibility; OSS always returns SELFHOSTED_LIMITS. */ +export const PLAN_LIMITS: Record = { + FREE: { ...SELFHOSTED_LIMITS }, + PREMIUM: { ...SELFHOSTED_LIMITS }, +}; -export function getPlanLimits(plan: Plan): PlanLimits { - if (isSelfHostedEdition()) return SELFHOSTED_LIMITS; - return PLAN_LIMITS[plan]; +export const SALES_EMAIL = "songs2vid@atakanozban.com"; +export const SUPPORT_EMAIL = "songs2vid@atakanozban.com"; +export const QUOTA_REQUEST_EMAIL = "songs2vid@atakanozban.com"; +export const UPGRADE_URL = "https://songs2vid.com"; +export const WEBSITE_URL = "https://songs2vid.com"; +export const GITEA_ISSUES_URL = + process.env.NEXT_PUBLIC_GITEA_ISSUES_URL ?? + "https://git.atakanozban.com/Songs2VID/songs2vid/issues"; +export const GITEA_URL = + process.env.NEXT_PUBLIC_GITEA_URL ?? "https://git.atakanozban.com/Songs2VID/songs2vid"; +export const DOCKER_HUB_URL = + process.env.NEXT_PUBLIC_DOCKER_HUB_URL ?? "https://hub.docker.com/r/atakanozban/songs2vid"; + +/** Public docs (Docusaurus). Override with NEXT_PUBLIC_DOCS_URL if needed. */ +export const DOCS_URL = ( + process.env.NEXT_PUBLIC_DOCS_URL?.trim() || "https://docs.songs2vid.com" +).replace(/\/$/, ""); +export const API_DOCS_URL = `${DOCS_URL}/docs/api/overview`; + +export function getPlanLimits(_plan?: Plan): PlanLimits { + void isSelfHostedEdition(); + return SELFHOSTED_LIMITS; } export function getResolutionsForPlan(plan: Plan) { diff --git a/lib/quota-extensions.ts b/lib/quota-extensions.ts deleted file mode 100644 index 625dc8a..0000000 --- a/lib/quota-extensions.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { QuotaExtensionRequestStatus } from "@prisma/client"; -import { MAX_ADMIN_API_RATE_BONUS } from "./api-rate-limit"; -import { prisma } from "./db"; -import { getPlanLimits } from "./plans"; - -export const QUOTA_EXTENSION_ANNUAL_LIMIT = 5; -/** Max bonus videos an admin may grant per approval. */ -export const MAX_ADMIN_BONUS_QUOTA = 50; - -export const EXTENSION_KIND = { - VIDEO_QUOTA: "VIDEO_QUOTA", - API_RATE_LIMIT: "API_RATE_LIMIT", -} as const; - -export type ExtensionKind = (typeof EXTENSION_KIND)[keyof typeof EXTENSION_KIND]; - -function getCalendarYearBounds(year = new Date().getFullYear()) { - return { - start: new Date(year, 0, 1), - end: new Date(year + 1, 0, 1), - }; -} - -export async function getQuotaExtensionUsage( - userId: string, - kind: ExtensionKind = EXTENSION_KIND.VIDEO_QUOTA, -) { - const { start, end } = getCalendarYearBounds(); - - const requests = await prisma.quotaExtensionRequest.findMany({ - where: { - userId, - kind, - requestedAt: { gte: start, lt: end }, - }, - orderBy: { requestedAt: "desc" }, - }); - - const used = requests.filter((r) => r.status !== QuotaExtensionRequestStatus.REJECTED).length; - - return { - used, - limit: QUOTA_EXTENSION_ANNUAL_LIMIT, - remaining: Math.max(0, QUOTA_EXTENSION_ANNUAL_LIMIT - used), - kind, - requests: requests.map((r) => ({ - id: r.id, - kind: r.kind, - status: r.status, - message: r.message, - requestedAt: r.requestedAt.toISOString(), - processedAt: r.processedAt?.toISOString() ?? null, - adminNote: r.adminNote, - })), - }; -} - -export async function createQuotaExtensionRequest( - userId: string, - message = "", - kind: ExtensionKind = EXTENSION_KIND.VIDEO_QUOTA, -) { - const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } }); - - if (user.plan !== "PREMIUM") { - throw new Error("Only Pro subscribers can request quota resets or extensions."); - } - - const usage = await getQuotaExtensionUsage(userId, kind); - if (usage.remaining <= 0) { - throw new Error( - `You have used all ${QUOTA_EXTENSION_ANNUAL_LIMIT} ${ - kind === EXTENSION_KIND.API_RATE_LIMIT ? "API rate limit" : "quota" - } extension requests for this year.`, - ); - } - - const pending = await prisma.quotaExtensionRequest.findFirst({ - where: { userId, kind, status: QuotaExtensionRequestStatus.PENDING }, - }); - if (pending) { - throw new Error("You already have a pending request. Please wait for it to be processed."); - } - - const request = await prisma.quotaExtensionRequest.create({ - data: { - userId, - kind, - message: message.trim().slice(0, 1000), - status: QuotaExtensionRequestStatus.PENDING, - }, - }); - - const updatedUsage = await getQuotaExtensionUsage(userId, kind); - - return { - requestId: request.id, - ...updatedUsage, - }; -} - -/** Call when approving a request in the database (admin / support). */ -export async function approveQuotaExtensionRequest( - requestId: string, - options?: { bonusQuota?: number; bonusRateLimit?: number; adminNote?: string }, -) { - const request = await prisma.quotaExtensionRequest.findUniqueOrThrow({ - where: { id: requestId }, - include: { user: true }, - }); - - if (request.status !== QuotaExtensionRequestStatus.PENDING) { - throw new Error("Request is not pending."); - } - - if (request.kind === EXTENSION_KIND.API_RATE_LIMIT) { - const bonus = Math.min( - MAX_ADMIN_API_RATE_BONUS, - Math.max(0, Math.floor(options?.bonusRateLimit ?? 30)), - ); - - await prisma.$transaction([ - prisma.quotaExtensionRequest.update({ - where: { id: requestId }, - data: { - status: QuotaExtensionRequestStatus.APPROVED, - processedAt: new Date(), - adminNote: options?.adminNote?.trim().slice(0, 500) ?? null, - }, - }), - prisma.user.update({ - where: { id: request.userId }, - data: { - apiRateLimitBonus: request.user.apiRateLimitBonus + bonus, - }, - }), - ]); - return; - } - - const bonus = Math.min( - MAX_ADMIN_BONUS_QUOTA, - Math.max(0, Math.floor(options?.bonusQuota ?? 0)), - ); - - await prisma.$transaction([ - prisma.quotaExtensionRequest.update({ - where: { id: requestId }, - data: { - status: QuotaExtensionRequestStatus.APPROVED, - processedAt: new Date(), - adminNote: options?.adminNote?.trim().slice(0, 500) ?? null, - }, - }), - prisma.user.update({ - where: { id: request.userId }, - data: { - videosUsed: 0, - bonusQuota: request.user.bonusQuota + bonus, - }, - }), - ]); -} - -export async function rejectQuotaExtensionRequest(requestId: string, adminNote?: string) { - const request = await prisma.quotaExtensionRequest.findUniqueOrThrow({ - where: { id: requestId }, - }); - - if (request.status !== QuotaExtensionRequestStatus.PENDING) { - throw new Error("Request is not pending."); - } - - await prisma.quotaExtensionRequest.update({ - where: { id: requestId }, - data: { - status: QuotaExtensionRequestStatus.REJECTED, - processedAt: new Date(), - adminNote: adminNote?.trim().slice(0, 500) ?? null, - }, - }); -} - -export function getEffectiveQuotaLimit(plan: Parameters[0], bonusQuota: number) { - return getPlanLimits(plan).monthlyQuota + bonusQuota; -} diff --git a/lib/quota.ts b/lib/quota.ts index 545227b..61695e1 100644 --- a/lib/quota.ts +++ b/lib/quota.ts @@ -1,129 +1,48 @@ ο»Ώimport { Plan } from "@prisma/client"; import { prisma } from "./db"; -import { isSelfHostedEdition } from "./edition"; -import { getEffectiveQuotaLimit } from "./quota-extensions"; import { getNextMonthlyQuotaReset, getPlanLimits } from "./plans"; -const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000; -const CREDIT_BALANCE_MAX = 1000; +/** + * OSS / self-hosted quota: never blocks on credits or plan limits. + * Batch size still enforced via SELFHOSTED_LIMITS.maxBatchSize. + * videosUsed is incremented for display only. + */ -function getNextFreeQuotaReset(from: Date = new Date()): Date { - return new Date(from.getTime() + TWELVE_HOURS_MS); +export function formatQuotaResetCountdown(_resetsAt: Date | string): string { + return "never"; } -function getNextQuotaResetForPlan(plan: Plan, from: Date = new Date()): Date { - return plan === "FREE" ? getNextFreeQuotaReset(from) : getNextMonthlyQuotaReset(from); -} - -function isMonthlyResetDate(date: Date): boolean { - return ( - date.getDate() === 1 && - date.getHours() === 0 && - date.getMinutes() === 0 && - date.getSeconds() === 0 && - date.getMilliseconds() === 0 - ); -} - -export function formatQuotaResetCountdown(resetsAt: Date | string): string { - const target = typeof resetsAt === "string" ? new Date(resetsAt) : resetsAt; - const ms = Math.max(0, target.getTime() - Date.now()); - const totalSec = Math.ceil(ms / 1000); - const hours = Math.floor(totalSec / 3600); - const minutes = Math.floor((totalSec % 3600) / 60); - const seconds = totalSec % 60; - return `${hours}h ${minutes}m ${seconds}s`; -} - -export function formatQuotaResetDisplay(plan: Plan, resetsAt: Date | string): string { - const target = typeof resetsAt === "string" ? new Date(resetsAt) : resetsAt; - if (plan === "PREMIUM") { - return target.toLocaleDateString(undefined, { - month: "long", - day: "numeric", - year: "numeric", - }); - } - return formatQuotaResetCountdown(target); -} - -function getQuotaExceededMessage(plan: Plan, resetsAt: Date, videoCredits: number): string { - if (plan === "PREMIUM") { - const resetLabel = formatQuotaResetDisplay(plan, resetsAt); - return `Quota exceeded. Your monthly Pro quota resets on ${resetLabel}. Request an extension in Settings if needed.`; - } - - const countdown = formatQuotaResetCountdown(resetsAt); - const creditHint = videoCredits > 0 ? "" : " Credits are not available in this build."; - return `Not enough free quota or credits. Your free quota resets in ${countdown}.${creditHint}`; +export function formatQuotaResetDisplay(_plan: Plan, _resetsAt: Date | string): string { + return "never (self-hosted)"; } export async function ensureQuotaReset(userId: string) { - const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } }); - const now = new Date(); - - if (user.plan === "PREMIUM") { - if (now >= user.quotaResetAt) { - return prisma.user.update({ - where: { id: userId }, - data: { - videosUsed: 0, - bonusQuota: 0, - quotaResetAt: getNextMonthlyQuotaReset(now), - }, - }); - } - - if (!isMonthlyResetDate(user.quotaResetAt)) { - return prisma.user.update({ - where: { id: userId }, - data: { - quotaResetAt: getNextMonthlyQuotaReset(now), - }, - }); - } - - return user; - } - - if (now >= user.quotaResetAt) { - return prisma.user.update({ - where: { id: userId }, - data: { - videosUsed: 0, - quotaResetAt: getNextQuotaResetForPlan(user.plan, now), - }, - }); - } - - return user; + return prisma.user.findUniqueOrThrow({ where: { id: userId } }); } export async function getQuotaInfo(userId: string) { const user = await ensureQuotaReset(userId); const limits = getPlanLimits(user.plan); - const limit = isSelfHostedEdition() - ? limits.monthlyQuota - : getEffectiveQuotaLimit(user.plan, user.bonusQuota); - const remaining = Math.max(0, limit - user.videosUsed); - const videoCredits = - isSelfHostedEdition() || user.plan !== "FREE" ? 0 : user.videoCredits; return { used: user.videosUsed, - limit, + limit: limits.monthlyQuota, baseLimit: limits.monthlyQuota, - bonusQuota: isSelfHostedEdition() ? 0 : user.bonusQuota, - remaining, - videoCredits, - creditBalanceMax: CREDIT_BALANCE_MAX, - totalAvailable: remaining + videoCredits, + bonusQuota: 0, + remaining: limits.monthlyQuota, + videoCredits: 0, + extraCredits: 0, + creditBalanceMax: 0, + totalAvailable: limits.monthlyQuota, resetsAt: user.quotaResetAt.toISOString(), resetsIn: formatQuotaResetDisplay(user.plan, user.quotaResetAt), plan: user.plan, maxBatchSize: limits.maxBatchSize, watermarkOptional: limits.watermarkOptional, + customWatermark: limits.customWatermark, + perItemImages: limits.perItemImages, + artTrackLayouts: limits.artTrackLayouts, maxResolutionHeight: limits.maxResolutionHeight, - selfHosted: isSelfHostedEdition(), + selfHosted: true, }; } @@ -132,150 +51,55 @@ export type ReservationSplit = { fromCredits: number; }; -/** - * Free plan: use included quota first, then pay-as-you-go credits. - * Pro plan: subscription quota only (PAYG is a separate product). - */ +/** Always succeed (except empty). Credits are never used in OSS. */ export function planReservation( - plan: Plan, - remainingQuota: number, - videoCredits: number, + _plan: Plan, + _remainingQuota: number, + _videoCredits: number, count: number, ): ReservationSplit | null { if (count <= 0) return { fromQuota: 0, fromCredits: 0 }; - - if (plan === "PREMIUM") { - if (count > remainingQuota) return null; - return { fromQuota: count, fromCredits: 0 }; - } - - const fromQuota = Math.min(count, Math.max(0, remainingQuota)); - const fromCredits = count - fromQuota; - if (fromCredits > videoCredits) return null; - return { fromQuota, fromCredits }; + return { fromQuota: count, fromCredits: 0 }; } export async function checkQuota(userId: string, requestedCount: number) { - if (isSelfHostedEdition()) { - const info = await getQuotaInfo(userId); - if (requestedCount > info.maxBatchSize) { - return { - ok: false as const, - error: `Batch limit exceeded. Max ${info.maxBatchSize} files per batch.`, - used: info.used, - limit: info.limit, - remaining: info.remaining, - videoCredits: 0, - resetsAt: info.resetsAt, - }; - } - return { - ok: true as const, - ...info, - reservation: { fromQuota: requestedCount, fromCredits: 0 }, - }; - } - - const user = await ensureQuotaReset(userId); - const limits = getPlanLimits(user.plan); - const limit = getEffectiveQuotaLimit(user.plan, user.bonusQuota); - const remaining = Math.max(0, limit - user.videosUsed); - - if (requestedCount > limits.maxBatchSize) { - return { - ok: false as const, - error: `Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.`, - used: user.videosUsed, - limit, - remaining, - videoCredits: user.videoCredits, - resetsAt: user.quotaResetAt.toISOString(), - }; - } - - const split = planReservation(user.plan, remaining, user.videoCredits, requestedCount); - if (!split) { - return { - ok: false as const, - error: getQuotaExceededMessage(user.plan, user.quotaResetAt, user.videoCredits), - used: user.videosUsed, - limit, - remaining, - videoCredits: user.plan === "FREE" ? user.videoCredits : 0, - resetsAt: user.quotaResetAt.toISOString(), - }; - } - const info = await getQuotaInfo(userId); - return { ok: true as const, ...info, reservation: split }; + if (requestedCount > info.maxBatchSize) { + return { + ok: false as const, + error: `Batch limit exceeded. Max ${info.maxBatchSize} files per batch.`, + used: info.used, + limit: info.limit, + remaining: info.remaining, + videoCredits: 0, + resetsAt: info.resetsAt, + }; + } + return { + ok: true as const, + ...info, + reservation: { fromQuota: requestedCount, fromCredits: 0 }, + }; } -/** - * Atomically reserve plan quota first, then prepaid credits. - * Returns how many of each were taken (for per-item billingSource). - */ +/** Track usage for display; never deduct credits or block. */ export async function reserveQuota(userId: string, count: number): Promise { if (count <= 0) return { fromQuota: 0, fromCredits: 0 }; - if (isSelfHostedEdition()) { - const limits = getPlanLimits("FREE"); - if (count > limits.maxBatchSize) { - throw new Error(`Batch limit exceeded. Max ${limits.maxBatchSize} files per batch.`); - } - await ensureQuotaReset(userId); - await prisma.user.update({ - where: { id: userId }, - data: { videosUsed: { increment: count } }, - }); - return { fromQuota: count, fromCredits: 0 }; + const limits = getPlanLimits(); + if (count > limits.maxBatchSize) { + throw new Error(`Batch limit exceeded. Max ${limits.maxBatchSize} files per batch.`); } - await ensureQuotaReset(userId); - - return prisma.$transaction(async (tx) => { - const rows = await tx.$queryRaw< - Array<{ - id: string; - plan: Plan; - videosUsed: number; - bonusQuota: number; - videoCredits: number; - quotaResetAt: Date; - }> - >`SELECT id, plan, "videosUsed", "bonusQuota", "videoCredits", "quotaResetAt" FROM "User" WHERE id = ${userId} FOR UPDATE`; - - const user = rows[0]; - if (!user) throw new Error("User not found"); - - const limits = getPlanLimits(user.plan); - if (count > limits.maxBatchSize) { - throw new Error( - `Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.`, - ); - } - - const limit = getEffectiveQuotaLimit(user.plan, user.bonusQuota); - const remaining = Math.max(0, limit - user.videosUsed); - const split = planReservation(user.plan, remaining, user.videoCredits, count); - if (!split) { - throw new Error(getQuotaExceededMessage(user.plan, user.quotaResetAt, user.videoCredits)); - } - - await tx.user.update({ - where: { id: userId }, - data: { - ...(split.fromQuota > 0 ? { videosUsed: { increment: split.fromQuota } } : {}), - ...(split.fromCredits > 0 ? { videoCredits: { decrement: split.fromCredits } } : {}), - }, - }); - - return split; + await prisma.user.update({ + where: { id: userId }, + data: { videosUsed: { increment: count } }, }); + return { fromQuota: count, fromCredits: 0 }; } export async function releaseQuota(userId: string, count: number) { if (count <= 0) return; - await ensureQuotaReset(userId); await prisma.$executeRaw` UPDATE "User" SET "videosUsed" = GREATEST(0, "videosUsed" - ${count}) @@ -283,38 +107,27 @@ export async function releaseQuota(userId: string, count: number) { `; } -export async function releaseCredits(userId: string, count: number) { - if (count <= 0) return; - await prisma.$executeRaw` - UPDATE "User" - SET "videoCredits" = LEAST(${CREDIT_BALANCE_MAX}, "videoCredits" + ${count}) - WHERE id = ${userId} - `; +/** No-op in OSS (no prepaid credits). */ +export async function releaseCredits(_userId: string, _count: number) { + return; } -/** Release one reserved unit based on how the job item was billed. */ export async function releaseReservation( userId: string, - billingSource: "QUOTA" | "CREDIT", + _billingSource: "QUOTA" | "CREDIT", count = 1, ) { - if (billingSource === "CREDIT") { - await releaseCredits(userId, count); - } else { - await releaseQuota(userId, count); - } + await releaseQuota(userId, count); } export async function releaseReservationSplit(userId: string, split: ReservationSplit) { if (split.fromQuota > 0) await releaseQuota(userId, split.fromQuota); - if (split.fromCredits > 0) await releaseCredits(userId, split.fromCredits); } -/** @deprecated Prefer reserveQuota at create; kept for callers that still increment on success. */ export async function incrementQuota(userId: string, count: number) { await reserveQuota(userId, count); } export function getInitialQuotaResetAt(): Date { - return getNextFreeQuotaReset(); + return getNextMonthlyQuotaReset(); } diff --git a/lib/types.ts b/lib/types.ts index 9749823..e6dc1e8 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,4 +1,6 @@ import { Privacy } from "@prisma/client"; +import type { LayoutSettings } from "./layout"; +import type { WatermarkSettings } from "./watermark"; export type ItemMetadata = { title: string; @@ -11,9 +13,48 @@ export type ItemMetadata = { madeForKids: boolean; embeddable: boolean; creativeCommons: boolean; + /** Legacy Free toggle default Songs2VID branding when watermark.mode is omitted. */ includeWatermark: boolean; /** YouTube playlist ID (Pro only). Video is added after upload. */ playlistId?: string | null; + /** + * Optional per-item cover image path (Pro / perItemImages). + * When omitted, the job-level shared imagePath is used. + */ + imagePath?: string | null; + /** Pro custom branding. Free may only use mode none|default at bottom-right. */ + watermark?: Partial | null; + /** Artist line for art-track layouts (optional). */ + artist?: string | null; + /** + * Pro art-track layout. Prefer camelCase; snake_case aliases accepted: + * layout_template, blur_amount, text_padding. + */ + layout?: Partial & { + layoutTemplate?: string | null; + layout_template?: string | null; + blur_amount?: number; + blur_opacity?: number; + text_padding?: number; + title_artist_gap?: number; + text_offset_x?: number; + text_offset_y?: number; + } | null; + /** Flat aliases (also accepted). */ + layoutTemplate?: string | null; + layout_template?: string | null; + blurAmount?: number; + blur_amount?: number; + blurOpacity?: number; + blur_opacity?: number; + textPadding?: number; + text_padding?: number; + titleArtistGap?: number; + title_artist_gap?: number; + textOffsetX?: number; + text_offset_x?: number; + textOffsetY?: number; + text_offset_y?: number; }; export type UploadedAudio = { @@ -29,6 +70,7 @@ export type CreatePlaylistRequest = { }; export type CreateJobPayload = { + /** Shared cover for Free / default when items omit imagePath. */ imagePath: string; items: Array<{ audioPath: string; diff --git a/lib/watermark.ts b/lib/watermark.ts new file mode 100644 index 0000000..833411c --- /dev/null +++ b/lib/watermark.ts @@ -0,0 +1,192 @@ +/** + * Watermark / branding helpers position math + FFmpeg-safe sanitization. + * Never interpolate unsanitized user text into filter graphs. + */ + +import { + isWatermarkFontKey, + type WatermarkFontKey, +} from "./fonts"; + +export const WATERMARK_POSITIONS = [ + "top-left", + "top-right", + "bottom-left", + "bottom-right", + "center", +] as const; + +export type WatermarkPosition = (typeof WATERMARK_POSITIONS)[number]; + +export const WATERMARK_MODES = ["none", "default", "text", "logo"] as const; +export type WatermarkMode = (typeof WATERMARK_MODES)[number]; + +export type WatermarkSettings = { + mode: WatermarkMode; + text?: string | null; + logoPath?: string | null; + position: WatermarkPosition; + /** Pixel offset from the chosen anchor (0–200). */ + offsetX: number; + offsetY: number; + /** + * Typography (Pro / text mode). + * `system` = FFmpeg default; curated keys map to assets/fonts; `custom` uses fontPath. + */ + fontKey?: WatermarkFontKey; + /** Absolute/staging path to uploaded .ttf/.otf when fontKey === "custom". */ + fontPath?: string | null; +}; + +export const DEFAULT_WATERMARK: WatermarkSettings = { + mode: "default", + text: null, + logoPath: null, + position: "bottom-right", + offsetX: 20, + offsetY: 20, + fontKey: "system", + fontPath: null, +}; + +export const WATERMARK_TEXT_MAX = 80; +export const WATERMARK_OFFSET_MIN = 0; +export const WATERMARK_OFFSET_MAX = 200; + +export function isWatermarkPosition(v: unknown): v is WatermarkPosition { + return typeof v === "string" && (WATERMARK_POSITIONS as readonly string[]).includes(v); +} + +export function isWatermarkMode(v: unknown): v is WatermarkMode { + return typeof v === "string" && (WATERMARK_MODES as readonly string[]).includes(v); +} + +export function clampOffset(n: unknown, fallback = 20): number { + const v = typeof n === "number" ? n : Number(n); + if (!Number.isFinite(v)) return fallback; + return Math.max(WATERMARK_OFFSET_MIN, Math.min(WATERMARK_OFFSET_MAX, Math.round(v))); +} + +/** + * Escape user text for FFmpeg drawtext. + * Strips control chars; escapes \, :, ', %, and [. + */ +export function sanitizeDrawtext(raw: string): string { + return raw + .slice(0, WATERMARK_TEXT_MAX) + .replace(/[\u0000-\u001f\u007f]/g, "") + .replace(/\\/g, "\\\\") + .replace(/:/g, "\\:") + .replace(/'/g, "\\'") + .replace(/%/g, "%%") + .replace(/\[/g, "\\["); +} + +/** FFmpeg overlay=x:y expressions for a scaled watermark layer `[wm]`. */ +export function overlayXy( + position: WatermarkPosition, + offsetX: number, + offsetY: number, +): { x: string; y: string } { + const ox = clampOffset(offsetX); + const oy = clampOffset(offsetY); + switch (position) { + case "top-left": + return { x: String(ox), y: String(oy) }; + case "top-right": + return { x: `W-w-${ox}`, y: String(oy) }; + case "bottom-left": + return { x: String(ox), y: `H-h-${oy}` }; + case "center": + return { x: `(W-w)/2+${ox}`, y: `(H-h)/2+${oy}` }; + case "bottom-right": + default: + return { x: `W-w-${ox}`, y: `H-h-${oy}` }; + } +} + +/** drawtext x/y for text watermarks. */ +export function drawtextXy( + position: WatermarkPosition, + offsetX: number, + offsetY: number, +): { x: string; y: string } { + const ox = clampOffset(offsetX); + const oy = clampOffset(offsetY); + switch (position) { + case "top-left": + return { x: String(ox), y: String(oy) }; + case "top-right": + return { x: `w-text_w-${ox}`, y: String(oy) }; + case "bottom-left": + return { x: String(ox), y: `h-th-${oy}` }; + case "center": + return { x: `(w-text_w)/2+${ox}`, y: `(h-th)/2+${oy}` }; + case "bottom-right": + default: + return { x: `w-text_w-${ox}`, y: `h-th-${oy}` }; + } +} + +/** + * Build a drawtext filter segment (without leading comma). + * `fontfileEscaped` must already be sanitized via sanitizeFontfileForFilter. + */ +export function buildDrawtextFilter(opts: { + text: string; + fontSize: number; + fontColor?: string; + position: WatermarkPosition; + offsetX: number; + offsetY: number; + fontfileEscaped?: string | null; +}): string { + const text = sanitizeDrawtext(opts.text); + const { x, y } = drawtextXy(opts.position, opts.offsetX, opts.offsetY); + const color = opts.fontColor ?? "white@0.9"; + const fontPart = opts.fontfileEscaped + ? `:fontfile='${opts.fontfileEscaped}'` + : ""; + return `drawtext=text='${text}'${fontPart}:fontsize=${opts.fontSize}:fontcolor=${color}:x=${x}:y=${y}`; +} + +export function normalizeWatermarkSettings( + input: Partial | null | undefined, + includeWatermarkFallback: boolean, +): WatermarkSettings { + if (!input) { + return { + ...DEFAULT_WATERMARK, + mode: includeWatermarkFallback ? "default" : "none", + }; + } + const mode = isWatermarkMode(input.mode) + ? input.mode + : includeWatermarkFallback + ? "default" + : "none"; + const fontKey = isWatermarkFontKey(input.fontKey) ? input.fontKey : "system"; + return { + mode, + text: typeof input.text === "string" ? input.text.slice(0, WATERMARK_TEXT_MAX) : null, + logoPath: typeof input.logoPath === "string" ? input.logoPath : null, + position: isWatermarkPosition(input.position) ? input.position : "bottom-right", + offsetX: clampOffset(input.offsetX, 20), + offsetY: clampOffset(input.offsetY, 20), + fontKey, + fontPath: typeof input.fontPath === "string" ? input.fontPath : null, + }; +} + +/** True when settings go beyond Free-tier default branding toggle. */ +export function requiresCustomWatermarkEntitlement(settings: WatermarkSettings): boolean { + if (settings.mode === "text" || settings.mode === "logo") return true; + if (settings.mode === "none") return false; + if (settings.position !== "bottom-right") return true; + if (settings.offsetX !== 20 || settings.offsetY !== 20) return true; + if (settings.fontKey && settings.fontKey !== "system") return true; + if (settings.fontPath) return true; + return false; +} + +export const PREMIUM_REQUIRED_CODE = "PREMIUM_REQUIRED" as const; diff --git a/next.config.ts b/next.config.ts index 0c2e159..71b0f9e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,10 @@ import type { NextConfig } from "next"; +const docsUrl = ( + process.env.NEXT_PUBLIC_DOCS_URL?.trim() || "https://docs.songs2vid.com" +).replace(/\/$/, ""); +const apiDocsUrl = `${docsUrl}/docs/api/overview`; + const nextConfig: NextConfig = { output: "standalone", images: { @@ -15,6 +20,15 @@ const nextConfig: NextConfig = { bodySizeLimit: "32mb", }, }, + async redirects() { + return [ + { + source: "/dashboard/api-docs", + destination: apiDocsUrl, + permanent: false, + }, + ]; + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 0b4a723..1f54724 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,8 +19,7 @@ "next": "^15.1.3", "next-auth": "^4.24.11", "react": "^19.0.0", - "react-dom": "^19.0.0", - "stripe": "^22.3.2" + "react-dom": "^19.0.0" }, "devDependencies": { "@types/node": "^22.10.2", @@ -1887,9 +1886,8 @@ "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", - "devOptional": true, + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -7690,23 +7688,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/stripe": { - "version": "22.3.2", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.3.2.tgz", - "integrity": "sha512-O13QOvgEIQvDlTy6Ubb5kB980wpbhmoZNsgCXKILjCMZS67f+bW+6w99k3gnSi/N1lkryoj1WYdpGT5Wc5edjg==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/strtok3": { "version": "10.3.5", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", @@ -8274,7 +8255,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { diff --git a/package.json b/package.json index 6774256..8efa841 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,4 @@ -ο»Ώ{ +{ "name": "songs2yt", "version": "0.1.0", "private": true, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e48f44b..2eb020d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -57,36 +57,16 @@ model User { subscribedAt DateTime? bonusQuota Int @default(0) apiRateLimitBonus Int @default(0) - /** Prepaid pay-as-you-go video credits (1 credit = 1 video). Cap: 1000. */ + /// Legacy column retained for schema compatibility; unused in OSS (always 0). videoCredits Int @default(0) apiKeyHash String? @unique apiKeyPrefix String? youtubeConnection YouTubeConnection? jobs Job[] quotaExtensionRequests QuotaExtensionRequest[] - creditPurchases CreditPurchase[] createdAt DateTime @default(now()) } -enum CreditPurchaseStatus { - PENDING - COMPLETED - FAILED -} - -model CreditPurchase { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - credits Int - amountCents Int - status CreditPurchaseStatus @default(PENDING) - stripeSessionId String? @unique - stripePaymentIntentId String? - createdAt DateTime @default(now()) - completedAt DateTime? -} - enum JobItemBilling { QUOTA CREDIT @@ -127,25 +107,54 @@ model Job { } model JobItem { - id String @id @default(cuid()) + id String @id @default(cuid()) jobId String - job Job @relation(fields: [jobId], references: [id], onDelete: Cascade) + job Job @relation(fields: [jobId], references: [id], onDelete: Cascade) audioPath String audioFilename String title String - description String @default("") - tags String @default("") - privacy Privacy @default(PUBLIC) - categoryId String @default("10") - resolution String @default("1280x720") - notifySubscribers Boolean @default(true) - madeForKids Boolean @default(false) - embeddable Boolean @default(true) - creativeCommons Boolean @default(false) - includeWatermark Boolean @default(true) + description String @default("") + tags String @default("") + privacy Privacy @default(PUBLIC) + categoryId String @default("10") + resolution String @default("1280x720") + notifySubscribers Boolean @default(true) + madeForKids Boolean @default(false) + embeddable Boolean @default(true) + creativeCommons Boolean @default(false) + includeWatermark Boolean @default(true) + /// Optional per-item cover. Falls back to Job.imagePath when null. + itemImagePath String? + /// none | default | text | logo + watermarkMode String @default("default") + watermarkText String? + watermarkLogoPath String? + /// curated key | custom | system + watermarkFontKey String? + /// uploaded .ttf/.otf when fontKey=custom + watermarkFontPath String? + /// top-left | top-right | bottom-left | bottom-right | center + watermarkPosition String @default("bottom-right") + watermarkOffsetX Int @default(20) + watermarkOffsetY Int @default(20) + /// Optional artist line for art-track layouts + artist String? + /// COVER_LEFT_TEXT_RIGHT | COVER_TOP_TEXT_BOTTOM | COVER_RIGHT_TEXT_LEFT | CENTERED_COMPACT | null=classic + layoutTemplate String? + /// 0–100 Gaussian / boxblur intensity + blurAmount Int @default(55) + /// 0–100 visibility of blurred fill vs black + blurOpacity Int @default(100) + /// Padding around cover + text in art-track layouts + textPadding Int @default(48) + /// Extra gap between title and artist lines (px) + titleArtistGap Int @default(10) + /// Fine-tune text block within template (-120..120) + textOffsetX Int @default(0) + textOffsetY Int @default(0) playlistId String? billingSource JobItemBilling @default(QUOTA) - status JobItemStatus @default(PENDING) + status JobItemStatus @default(PENDING) outputPath String? youtubeVideoId String? error String? diff --git a/scripts/fetch-watermark-fonts.mjs b/scripts/fetch-watermark-fonts.mjs new file mode 100644 index 0000000..bd68a02 --- /dev/null +++ b/scripts/fetch-watermark-fonts.mjs @@ -0,0 +1,71 @@ +/** + * Download curated watermark fonts into assets/fonts for FFmpeg drawtext. + * Run: node scripts/fetch-watermark-fonts.mjs + */ +import fs from "fs/promises"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const outDir = path.join(__dirname, "..", "assets", "fonts"); + +/** Direct TTF URLs from Google Fonts GitHub (OFL). */ +const FONTS = [ + { + file: "Inter-Regular.ttf", + url: "https://github.com/google/fonts/raw/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf", + }, + { + file: "Montserrat-Regular.ttf", + url: "https://github.com/google/fonts/raw/main/ofl/montserrat/Montserrat%5Bwght%5D.ttf", + }, + { + file: "Roboto-Regular.ttf", + url: "https://github.com/google/fonts/raw/main/ofl/roboto/Roboto%5Bwdth%2Cwght%5D.ttf", + }, + { + file: "Oswald-Regular.ttf", + url: "https://github.com/google/fonts/raw/main/ofl/oswald/Oswald%5Bwght%5D.ttf", + }, + { + file: "PlayfairDisplay-Regular.ttf", + url: "https://github.com/google/fonts/raw/main/ofl/playfairdisplay/PlayfairDisplay%5Bwght%5D.ttf", + }, +]; + +await fs.mkdir(outDir, { recursive: true }); + +for (const font of FONTS) { + const dest = path.join(outDir, font.file); + try { + await fs.access(dest); + console.log("skip (exists)", font.file); + continue; + } catch { + /* download */ + } + console.log("fetch", font.file); + const res = await fetch(font.url, { + headers: { "User-Agent": "songs2vid-font-fetch/1.0" }, + redirect: "follow", + }); + if (!res.ok) { + console.error("FAILED", font.file, res.status); + continue; + } + const buf = Buffer.from(await res.arrayBuffer()); + await fs.writeFile(dest, buf); + console.log("wrote", font.file, buf.length, "bytes"); +} + +await fs.writeFile( + path.join(outDir, "README.md"), + `# Watermark fonts + +Curated TTF assets for FFmpeg \`drawtext\` (OFL via Google Fonts). +Refresh with \`node scripts/fetch-watermark-fonts.mjs\`. +`, + "utf8", +); + +console.log("done β†’", outDir); diff --git a/worker/index.ts b/worker/index.ts index 8eb8adb..4028fbd 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -65,11 +65,51 @@ async function processJobItem(data: VideoJobData) { }); await encodeVideo({ - imagePath: item.job.imagePath, + imagePath: item.itemImagePath || item.job.imagePath, audioPath: item.audioPath, outputPath, resolution: item.resolution, includeWatermark: item.includeWatermark, + title: item.title, + artist: item.artist, + layout: item.layoutTemplate + ? { + template: item.layoutTemplate as + | "COVER_LEFT_TEXT_RIGHT" + | "COVER_TOP_TEXT_BOTTOM" + | "COVER_RIGHT_TEXT_LEFT" + | "CENTERED_COMPACT", + blurAmount: item.blurAmount ?? 55, + blurOpacity: item.blurOpacity ?? 100, + textPadding: item.textPadding ?? 48, + titleArtistGap: item.titleArtistGap ?? 10, + textOffsetX: item.textOffsetX ?? 0, + textOffsetY: item.textOffsetY ?? 0, + } + : null, + watermark: { + mode: (item.watermarkMode as "none" | "default" | "text" | "logo") || "default", + text: item.watermarkText, + logoPath: item.watermarkLogoPath, + fontKey: (item.watermarkFontKey as + | "system" + | "custom" + | "inter" + | "montserrat" + | "roboto" + | "oswald" + | "playfair") || "system", + fontPath: item.watermarkFontPath, + position: + (item.watermarkPosition as + | "top-left" + | "top-right" + | "bottom-left" + | "bottom-right" + | "center") || "bottom-right", + offsetX: item.watermarkOffsetX ?? 20, + offsetY: item.watermarkOffsetY ?? 20, + }, }); await prisma.jobItem.update({ @@ -123,4 +163,4 @@ worker.on("failed", (job, err) => { console.error(`Job item ${job?.data.jobItemId} failed:`, err.message); }); -console.log(`Songs2YT worker started (ffmpeg: ${getFfmpegPath()}), waiting for jobs...`); +console.log(`Songs2VID worker started (ffmpeg: ${getFfmpegPath()}), waiting for jobs...`);