Sync layouts, typography, and watermark studio for self-hosted OSS.
Unlock features without credits/Stripe, point docs to docs.songs2vid.com, and drop leftover billing admin surfaces. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+9
-4
@@ -5,7 +5,12 @@ NEXTAUTH_SECRET="replace-with-a-long-random-secret"
|
|||||||
GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com"
|
GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com"
|
||||||
GOOGLE_CLIENT_SECRET="your-google-client-secret"
|
GOOGLE_CLIENT_SECRET="your-google-client-secret"
|
||||||
UPLOAD_DIR="./uploads"
|
UPLOAD_DIR="./uploads"
|
||||||
S2YT_EDITION="selfhosted"
|
# Self-hosted OSS edition (unlocked features, no Stripe / credits)
|
||||||
NEXT_PUBLIC_GITEA_ISSUES_URL="https://git.atakanozban.com/Songs2YT/songs2yt/issues"
|
S2VID_EDITION="selfhosted"
|
||||||
NEXT_PUBLIC_GITEA_URL="https://git.atakanozban.com/Songs2YT/songs2yt"
|
# Optional legacy alias (also accepted by lib/edition.ts):
|
||||||
NEXT_PUBLIC_DOCKER_HUB_URL="https://hub.docker.com/r/atakanozban/songs2yt"
|
# 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"
|
||||||
|
|||||||
+1
-1
@@ -10,5 +10,5 @@ website/.docusaurus/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
basibozuk_cover.jpg
|
basibozuk_cover.jpg
|
||||||
bg-video/
|
bg-video/
|
||||||
scripts/
|
|
||||||
_restore/
|
_restore/
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
|||||||
+2
-1
@@ -21,7 +21,7 @@ FROM node:22-bookworm-slim AS runner
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV S2YT_EDITION=selfhosted
|
ENV S2VID_EDITION=selfhosted
|
||||||
ENV UPLOAD_DIR=/app/uploads
|
ENV UPLOAD_DIR=/app/uploads
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
openssl ca-certificates ffmpeg \
|
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/node_modules ./node_modules
|
||||||
COPY --from=builder /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/assets ./assets
|
||||||
COPY --from=builder /app/worker ./worker
|
COPY --from=builder /app/worker ./worker
|
||||||
COPY --from=builder /app/lib ./lib
|
COPY --from=builder /app/lib ./lib
|
||||||
COPY --from=builder /app/tsconfig.json ./tsconfig.json
|
COPY --from=builder /app/tsconfig.json ./tsconfig.json
|
||||||
|
|||||||
@@ -8,82 +8,79 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<b>Turn your cover art + audio files into YouTube videos automatically. No manual rendering BS.</b><br>
|
<b>Turn your cover art + audio files into YouTube videos automatically.</b><br>
|
||||||
<i>The ultimate life-saver for creators who waste hours opening Premiere/Vegas just to upload a single track.</i>
|
<i>Art-track layouts, blur backgrounds, typography, watermark studio, and per-track covers — unlocked for self-hosting.</i>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔥 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**:
|
This is the **open-source (self-hosted) edition**:
|
||||||
* 🚫 **Zero limits:** No video upload quotas, no API rate limit bullshit.
|
* **Zero practical limits:** no video quotas, credits, or Stripe paywalls
|
||||||
* 🔓 **Fully Unlocked:** Playlists and REST API are completely unlocked (`S2VID_EDITION=selfhosted`).
|
* **Fully unlocked:** playlists, REST API, art-track layouts, custom watermarks/fonts, per-track covers (`S2VID_EDITION=selfhosted`)
|
||||||
* ☁️ **Full Control:** Run it on your own server. Your data, your rules.
|
* **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)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ Requirements
|
## Requirements
|
||||||
|
|
||||||
* **Docker & Docker Compose** (highly recommended, saves your mental health)
|
* **Docker & Docker Compose** (recommended)
|
||||||
* *Or:* Node 20+, Postgres, Redis, FFmpeg
|
* *Or:* Node 20+, Postgres, Redis, FFmpeg
|
||||||
* Google Cloud OAuth client with **YouTube Data API v3** enabled
|
* Google Cloud OAuth client with **YouTube Data API v3** enabled
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Quick Start (Docker)
|
## Quick Start (Docker)
|
||||||
|
|
||||||
Skip the setup headache. Copy the env, spin it up, and go touch grass:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Copy env template
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
# Fill in GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, NEXTAUTH_SECRET, and NEXTAUTH_URL
|
# Fill in GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, NEXTAUTH_SECRET, and NEXTAUTH_URL
|
||||||
nano .env
|
|
||||||
|
|
||||||
# Fire up the engine
|
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
* Open `http://localhost:3000`
|
* 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
|
## Local Development
|
||||||
|
|
||||||
Want to hack on the codebase? Follow this:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Prepare env
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
# 2. Spin up Postgres & Redis
|
|
||||||
docker compose -f docker-compose.dev.yml up -d
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
# 3. Get dependencies
|
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 4. Push database schema
|
|
||||||
npm run db:push
|
npm run db:push
|
||||||
|
npm run dev # Terminal 1
|
||||||
# 5. Run it (open two terminals)
|
npm run worker # Terminal 2
|
||||||
npm run dev # Terminal 1 (Next.js)
|
|
||||||
npm run worker # Terminal 2 (BullMQ/FFmpeg Worker)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🏗️ Stack
|
Optional: fetch curated watermark fonts into `assets/fonts/` with `node scripts/fetch-watermark-fonts.mjs`.
|
||||||
|
|
||||||
* Next.js (Frontend & API)
|
## Features
|
||||||
* 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)
|
|
||||||
|
|
||||||
## 📄 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.
|
## 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
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+17
-4
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
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";
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
|
||||||
export const maxDuration = 120;
|
export const maxDuration = 120;
|
||||||
@@ -15,14 +16,26 @@ export async function POST(req: NextRequest) {
|
|||||||
const type = formData.get("type") as string | null;
|
const type = formData.get("type") as string | null;
|
||||||
const sessionKey = (formData.get("session") as string | null)?.trim() || undefined;
|
const sessionKey = (formData.get("session") as string | null)?.trim() || undefined;
|
||||||
|
|
||||||
if (!file || !type || (type !== "image" && type !== "audio")) {
|
if (
|
||||||
return NextResponse.json({ error: "Missing file or type" }, { status: 400 });
|
!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 {
|
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);
|
return NextResponse.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof PremiumRequiredError) {
|
||||||
|
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
|
||||||
|
}
|
||||||
const message = err instanceof Error ? err.message : "Upload failed";
|
const message = err instanceof Error ? err.message : "Upload failed";
|
||||||
return NextResponse.json({ error: message }, { status: 400 });
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-5
@@ -1,12 +1,13 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { API_DOCS_URL } from "@/lib/plans";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
name: "Songs2YT API",
|
name: "Songs2VID API",
|
||||||
version: "1.0",
|
version: "1.0",
|
||||||
authentication: "Authorization: Bearer <api_key>",
|
authentication: "Authorization: Bearer <api_key>",
|
||||||
requirements: ["Pro subscription", "YouTube account connected"],
|
requirements: ["Self-hosted edition", "YouTube account connected"],
|
||||||
rateLimit: "60 requests per minute per account (plus any approved bonus)",
|
rateLimit: "Generous self-hosted limits (see docs)",
|
||||||
guidance: {
|
guidance: {
|
||||||
recommended:
|
recommended:
|
||||||
"For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/jobs with the returned paths",
|
"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",
|
method: "GET",
|
||||||
path: "/api/v1/playlists",
|
path: "/api/v1/playlists",
|
||||||
description: "List YouTube playlists for the authenticated Pro account",
|
description: "List YouTube playlists for the authenticated account",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -56,6 +57,6 @@ export async function GET() {
|
|||||||
description: "Get job status and item details",
|
description: "Get job status and item details",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
docs: "/dashboard/api-docs",
|
docs: API_DOCS_URL,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
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;
|
export const maxDuration = 120;
|
||||||
|
|
||||||
@@ -12,17 +13,24 @@ export async function POST(req: NextRequest) {
|
|||||||
const file = formData.get("file") as File | null;
|
const file = formData.get("file") as File | null;
|
||||||
const type = formData.get("type") as string | 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(
|
return NextResponse.json(
|
||||||
{ error: "Provide multipart fields: file, type (image|audio)" },
|
{ error: "Provide multipart fields: file, type (image|audio|logo|font)" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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);
|
return NextResponse.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof PremiumRequiredError) {
|
||||||
|
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
|
||||||
|
}
|
||||||
const message = err instanceof Error ? err.message : "Upload failed";
|
const message = err instanceof Error ? err.message : "Upload failed";
|
||||||
return NextResponse.json({ error: message }, { status: 400 });
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<pre className="overflow-x-auto rounded-lg border border-gray-800 bg-black/40 p-4 text-xs leading-relaxed text-gray-300">
|
|
||||||
<code>{children}</code>
|
|
||||||
</pre>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<DashboardShell channelTitle={user.youtubeConnection?.channelTitle}>
|
|
||||||
<div className="mx-auto max-w-3xl space-y-8">
|
|
||||||
<div>
|
|
||||||
<Link
|
|
||||||
href="/dashboard/settings"
|
|
||||||
className="text-sm text-gray-400 transition-colors hover:text-white"
|
|
||||||
>
|
|
||||||
← Back to settings
|
|
||||||
</Link>
|
|
||||||
<h1 className="mt-4 text-2xl font-bold text-white">REST API</h1>
|
|
||||||
<p className="mt-2 text-sm text-gray-400">
|
|
||||||
Programmatic uploads and batch jobs for Pro subscribers. Generate your API key in{" "}
|
|
||||||
<Link href="/dashboard/settings" className="text-accent hover:underline">
|
|
||||||
Settings
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
{!isPro && (
|
|
||||||
<p className="mt-3 rounded border border-accent/30 bg-accent/10 px-4 py-3 text-sm text-accent">
|
|
||||||
API access requires Pro. <UpgradeProLink />
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<h2 className="text-lg font-semibold text-white">Authentication</h2>
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
Send your API key on every request. Keys start with <code className="text-gray-300">s2yt_live_</code>.
|
|
||||||
</p>
|
|
||||||
<CodeBlock>{`Authorization: Bearer s2yt_live_your_key_here`}</CodeBlock>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<h2 className="text-lg font-semibold text-white">Rate limits</h2>
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
Default is 60 requests per minute per account. Approved rate-limit extensions increase
|
|
||||||
that ceiling. Check live usage and request an extension in{" "}
|
|
||||||
<Link href="/dashboard/settings" className="text-accent hover:underline">
|
|
||||||
Settings → API access
|
|
||||||
</Link>
|
|
||||||
. Video quota limits from your Pro plan still apply to job creation.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<h2 className="text-lg font-semibold text-white">Choosing a flow</h2>
|
|
||||||
<div className="rounded border border-gray-800 bg-surface p-4 text-sm text-gray-400 space-y-3">
|
|
||||||
<p>
|
|
||||||
<span className="font-medium text-white">Recommended for most jobs (especially 5+ audio files):</span>{" "}
|
|
||||||
two-step: upload each file with <code className="text-gray-300">POST /api/v1/upload</code>, then
|
|
||||||
create the job with <code className="text-gray-300">POST /api/v1/jobs</code> (JSON).
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<span className="font-medium text-white">One-shot batch</span>{" "}
|
|
||||||
(<code className="text-gray-300">POST /api/v1/jobs/batch</code>) is for small packs only, typically
|
|
||||||
1 cover + up to a few audio files. Large multipart bodies often fail with{" "}
|
|
||||||
<code className="text-gray-300">failed to parse body as FormData</code>. Prefer two-step for albums
|
|
||||||
or long tracklists.
|
|
||||||
</p>
|
|
||||||
<ul className="list-disc space-y-1 pl-5">
|
|
||||||
<li>Do not set <code className="text-gray-300">Content-Type</code> manually for multipart; the client must include the boundary.</li>
|
|
||||||
<li>For Postman: Body → form-data, each audio row key must be exactly <code className="text-gray-300">audio</code> (type File).</li>
|
|
||||||
<li>If a file field shows a warning triangle, re-select the file from disk.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<h2 className="text-lg font-semibold text-white">Discovery</h2>
|
|
||||||
<CodeBlock>{`GET /api/v1`}</CodeBlock>
|
|
||||||
<p className="text-sm text-gray-400">Returns endpoint list and requirements (no auth).</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<h2 className="text-lg font-semibold text-white">1. Upload a file (two-step)</h2>
|
|
||||||
<CodeBlock>{`curl -X POST "$BASE_URL/api/v1/upload" \\
|
|
||||||
-H "Authorization: Bearer $API_KEY" \\
|
|
||||||
-F "file=@cover.jpg" \\
|
|
||||||
-F "type=image"`}</CodeBlock>
|
|
||||||
<CodeBlock>{`curl -X POST "$BASE_URL/api/v1/upload" \\
|
|
||||||
-H "Authorization: Bearer $API_KEY" \\
|
|
||||||
-F "file=@track1.mp3" \\
|
|
||||||
-F "type=audio"`}</CodeBlock>
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
Response includes <code className="text-gray-300">path</code>,{" "}
|
|
||||||
<code className="text-gray-300">filename</code>, and{" "}
|
|
||||||
<code className="text-gray-300">size</code>. Upload the image once, then each audio file. Keep the{" "}
|
|
||||||
<code className="text-gray-300">path</code> values for the next step.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<h2 className="text-lg font-semibold text-white">2. Create job from paths (recommended)</h2>
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
Use the exact <code className="text-gray-300">path</code> strings returned by upload. Add one{" "}
|
|
||||||
<code className="text-gray-300">items[]</code> entry per track.
|
|
||||||
</p>
|
|
||||||
<CodeBlock>{`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
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}'`}</CodeBlock>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<h2 className="text-lg font-semibold text-white">YouTube playlists (Pro)</h2>
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
List existing playlists, create a new one, or create one inline when starting a job.
|
|
||||||
Pass <code className="text-gray-300">playlistId</code> in item metadata / batch{" "}
|
|
||||||
<code className="text-gray-300">defaults</code>, or use{" "}
|
|
||||||
<code className="text-gray-300">createPlaylist</code> to make a playlist and attach all
|
|
||||||
videos to it. Privacy may be <code className="text-gray-300">public</code>,{" "}
|
|
||||||
<code className="text-gray-300">unlisted</code>, or{" "}
|
|
||||||
<code className="text-gray-300">private</code>. If playlist permission was just added,
|
|
||||||
sign out and sign in again for <code className="text-gray-300">youtube.force-ssl</code>.
|
|
||||||
</p>
|
|
||||||
<CodeBlock>{`# List playlists
|
|
||||||
curl "$BASE_URL/api/v1/playlists" \\
|
|
||||||
-H "Authorization: Bearer $API_KEY"`}</CodeBlock>
|
|
||||||
<CodeBlock>{`# 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"}'`}</CodeBlock>
|
|
||||||
<CodeBlock>{`# 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" }]
|
|
||||||
}`}</CodeBlock>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<h2 className="text-lg font-semibold text-white">One-shot batch (small packs only)</h2>
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
<CodeBlock>{`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"}]}'`}</CodeBlock>
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
Optional <code className="text-gray-300">metadata</code> JSON supports{" "}
|
|
||||||
<code className="text-gray-300">defaults</code> applied to every item and per-item overrides in{" "}
|
|
||||||
<code className="text-gray-300">items</code>. Item order should match the order of{" "}
|
|
||||||
<code className="text-gray-300">audio</code> files.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<h2 className="text-lg font-semibold text-white">Poll job status</h2>
|
|
||||||
<CodeBlock>{`curl "$BASE_URL/api/v1/jobs/JOB_ID" \\
|
|
||||||
-H "Authorization: Bearer $API_KEY"`}</CodeBlock>
|
|
||||||
<CodeBlock>{`curl "$BASE_URL/api/v1/jobs?limit=10" \\
|
|
||||||
-H "Authorization: Bearer $API_KEY"`}</CodeBlock>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</DashboardShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+16
-117
@@ -4,21 +4,12 @@ import { getServerSession } from "next-auth";
|
|||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { AccountPrivacyActions } from "@/components/AccountPrivacyActions";
|
import { AccountPrivacyActions } from "@/components/AccountPrivacyActions";
|
||||||
import { PlanBillingActions } from "@/components/PlanBillingActions";
|
|
||||||
import { DashboardShell } from "@/components/DashboardShell";
|
import { DashboardShell } from "@/components/DashboardShell";
|
||||||
import { UpgradeProLink } from "@/components/UpgradeProLink";
|
|
||||||
import { getUserApiKeyStatus } from "@/lib/api-keys";
|
import { getUserApiKeyStatus } from "@/lib/api-keys";
|
||||||
import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
|
import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
|
||||||
import { ApiKeySettings } from "@/components/ApiKeySettings";
|
import { ApiKeySettings } from "@/components/ApiKeySettings";
|
||||||
import { hasProFeatures, isSelfHostedEdition } from "@/lib/edition";
|
|
||||||
import { EXTENSION_KIND, getQuotaExtensionUsage } from "@/lib/quota-extensions";
|
|
||||||
import { getQuotaInfo } from "@/lib/quota";
|
import { getQuotaInfo } from "@/lib/quota";
|
||||||
|
|
||||||
const PLAN_LABELS = {
|
|
||||||
FREE: "Bedroom Producer",
|
|
||||||
PREMIUM: "Pro",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between gap-4">
|
<div className="flex justify-between gap-4">
|
||||||
@@ -39,16 +30,8 @@ export default async function SettingsPage() {
|
|||||||
if (!user) redirect("/");
|
if (!user) redirect("/");
|
||||||
|
|
||||||
const quota = await getQuotaInfo(user.id);
|
const quota = await getQuotaInfo(user.id);
|
||||||
const selfHosted = isSelfHostedEdition();
|
const apiKeyStatus = await getUserApiKeyStatus(user.id);
|
||||||
const proFeatures = hasProFeatures(user.plan);
|
const apiRateLimit = await getApiRateLimitStatus(user.id);
|
||||||
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 youtube = user.youtubeConnection;
|
const youtube = user.youtubeConnection;
|
||||||
const channelUrl = youtube ? `https://www.youtube.com/channel/${youtube.channelId}` : null;
|
const channelUrl = youtube ? `https://www.youtube.com/channel/${youtube.channelId}` : null;
|
||||||
|
|
||||||
@@ -100,115 +83,31 @@ export default async function SettingsPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
||||||
<h2 className="mb-4 text-lg font-semibold text-white">
|
<h2 className="mb-4 text-lg font-semibold text-white">Usage</h2>
|
||||||
{selfHosted ? "Usage" : "Plan & billing"}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="mb-6 rounded border border-gray-800 bg-surface p-4">
|
<div className="mb-6 rounded border border-gray-800 bg-surface p-4">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
<div>
|
||||||
<div>
|
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
Videos processed
|
||||||
Videos processed
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-2xl font-semibold text-white">
|
|
||||||
{selfHosted ? (
|
|
||||||
quota.used
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{quota.remaining}
|
|
||||||
<span className="text-base font-normal text-gray-400">
|
|
||||||
{" "}
|
|
||||||
of {quota.limit} videos
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{!selfHosted && (
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
{quota.used} used
|
|
||||||
{quota.bonusQuota > 0 ? ` · +${quota.bonusQuota} bonus` : ""}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!selfHosted && (
|
|
||||||
<>
|
|
||||||
<div className="mt-3 h-2 overflow-hidden rounded bg-gray-800">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded ${
|
|
||||||
quota.remaining <= 0
|
|
||||||
? "bg-red-500"
|
|
||||||
: quota.remaining / Math.max(1, quota.limit) <= 0.2
|
|
||||||
? "bg-amber-500"
|
|
||||||
: "bg-accent"
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
width: `${Math.min(100, Math.round((quota.used / Math.max(1, quota.limit)) * 100))}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-gray-500">
|
|
||||||
{user.plan === "PREMIUM"
|
|
||||||
? `Monthly quota resets on ${quota.resetsIn}`
|
|
||||||
: `Quota resets in ${quota.resetsIn}`}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{selfHosted && (
|
|
||||||
<p className="mt-2 text-xs text-gray-500">
|
|
||||||
Self-hosted edition: no video or API quotas.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
<p className="mt-1 text-2xl font-semibold text-white">{quota.used}</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-gray-500">
|
||||||
|
Self-hosted edition: no video credits or quotas.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl className="space-y-3 text-sm">
|
<dl className="space-y-3 text-sm">
|
||||||
<InfoRow label="Current plan" value={planLabel} />
|
<InfoRow label="Edition" value="Self-hosted" />
|
||||||
{!selfHosted && user.plan === "PREMIUM" && user.subscribedAt && (
|
|
||||||
<InfoRow
|
|
||||||
label="Subscribed since"
|
|
||||||
value={user.subscribedAt.toLocaleDateString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!selfHosted && user.plan === "PREMIUM" && (
|
|
||||||
<InfoRow
|
|
||||||
label="Payment method"
|
|
||||||
value={user.cardLast4 ? `•••• ${user.cardLast4}` : "No card on file"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{extensionUsage && (
|
|
||||||
<InfoRow
|
|
||||||
label="Extension requests (this year)"
|
|
||||||
value={`${extensionUsage.used} / ${extensionUsage.limit}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<InfoRow label="Max batch size" value={`${quota.maxBatchSize} videos`} />
|
<InfoRow label="Max batch size" value={`${quota.maxBatchSize} videos`} />
|
||||||
<InfoRow label="Max resolution" value={`${quota.maxResolutionHeight}p`} />
|
<InfoRow label="Max resolution" value={`${quota.maxResolutionHeight}p`} />
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{!selfHosted && user.plan === "FREE" && (
|
|
||||||
<p className="mt-4 text-sm text-gray-400">
|
|
||||||
<UpgradeProLink /> for more videos, 1080p, and lossless audio.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{!selfHosted && user.plan === "PREMIUM" && extensionUsage && (
|
|
||||||
<PlanBillingActions initialUsage={extensionUsage} />
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{proFeatures && apiKeyStatus && apiRateLimit && apiRateExtensionUsage && (
|
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
||||||
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
<h2 className="mb-4 text-lg font-semibold text-white">API access</h2>
|
||||||
<h2 className="mb-4 text-lg font-semibold text-white">API access</h2>
|
<ApiKeySettings initialStatus={apiKeyStatus} initialRateLimit={apiRateLimit} />
|
||||||
<ApiKeySettings
|
</section>
|
||||||
initialStatus={apiKeyStatus}
|
|
||||||
initialRateLimit={apiRateLimit}
|
|
||||||
initialExtensionUsage={apiRateExtensionUsage}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
||||||
<h2 className="mb-2 text-lg font-semibold text-white">Account deletion & data</h2>
|
<h2 className="mb-2 text-lg font-semibold text-white">Account deletion & data</h2>
|
||||||
|
|||||||
+24
-10
@@ -5,10 +5,10 @@ import { BenefitsSection } from "@/components/BenefitsSection";
|
|||||||
import { DownloadSection } from "@/components/DownloadSection";
|
import { DownloadSection } from "@/components/DownloadSection";
|
||||||
import { LandingNavbar } from "@/components/LandingNavbar";
|
import { LandingNavbar } from "@/components/LandingNavbar";
|
||||||
import { SignInButton } from "@/components/SignInButton";
|
import { SignInButton } from "@/components/SignInButton";
|
||||||
import { PricingSection } from "@/components/PricingSection";
|
|
||||||
import { StepsSection } from "@/components/StepsSection";
|
import { StepsSection } from "@/components/StepsSection";
|
||||||
import { Footer } from "@/components/Footer";
|
import { Footer } from "@/components/Footer";
|
||||||
import { SupportSection } from "@/components/SupportSection";
|
import { SupportSection } from "@/components/SupportSection";
|
||||||
|
import { DOCS_URL, WEBSITE_URL } from "@/lib/plans";
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
@@ -34,14 +34,13 @@ export default async function HomePage() {
|
|||||||
|
|
||||||
<LandingNavbar />
|
<LandingNavbar />
|
||||||
|
|
||||||
{/* Purpose + brand above the fold for Google OAuth verification */}
|
|
||||||
<div className="relative z-10 mx-auto flex min-h-dvh max-w-3xl flex-col justify-center px-6 pb-16 pt-28 text-center">
|
<div className="relative z-10 mx-auto flex min-h-dvh max-w-3xl flex-col justify-center px-6 pb-16 pt-28 text-center">
|
||||||
<h1 className="text-5xl font-bold tracking-tight text-white sm:text-6xl lg:text-7xl">
|
<h1 className="text-5xl font-bold tracking-tight text-white sm:text-6xl lg:text-7xl">
|
||||||
Songs2YT
|
Songs2VID
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-gray-200 sm:text-lg md:text-xl">
|
<p className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-gray-200 sm:text-lg md:text-xl">
|
||||||
Songs2YT is an automation tool that converts your audio and image files into
|
Self-hosted automation that turns your audio and cover art into YouTube videos —
|
||||||
high-quality videos and uploads them directly to YouTube.
|
including art-track layouts, typography, and watermark studio. No quotas or paywalls.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-10 flex flex-col items-center gap-3">
|
<div className="mt-10 flex flex-col items-center gap-3">
|
||||||
@@ -74,19 +73,34 @@ export default async function HomePage() {
|
|||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<p className="mt-4 text-sm text-gray-400">
|
||||||
|
<a
|
||||||
|
href={`${DOCS_URL}/docs/intro`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-accent hover:underline"
|
||||||
|
>
|
||||||
|
Documentation
|
||||||
|
</a>
|
||||||
|
{" · "}
|
||||||
|
<a
|
||||||
|
href={WEBSITE_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-accent hover:underline"
|
||||||
|
>
|
||||||
|
Hosted product
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<StepsSection />
|
<StepsSection />
|
||||||
<BenefitsSection />
|
<BenefitsSection />
|
||||||
|
|
||||||
<DownloadSection />
|
<DownloadSection />
|
||||||
|
|
||||||
<PricingSection />
|
|
||||||
|
|
||||||
<SupportSection />
|
<SupportSection />
|
||||||
<Footer />
|
<Footer />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,4 @@
|
|||||||
|
# Watermark fonts
|
||||||
|
|
||||||
|
Curated TTF assets for FFmpeg `drawtext` (OFL via Google Fonts).
|
||||||
|
Refresh with `node scripts/fetch-watermark-fonts.mjs`.
|
||||||
Binary file not shown.
+24
-130
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { API_DOCS_URL } from "@/lib/plans";
|
||||||
|
|
||||||
type ApiKeyStatus = {
|
type ApiKeyStatus = {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
@@ -16,39 +17,16 @@ type RateLimitStatus = {
|
|||||||
bonus?: number;
|
bonus?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
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 = {
|
type Props = {
|
||||||
initialStatus: ApiKeyStatus;
|
initialStatus: ApiKeyStatus;
|
||||||
initialRateLimit: RateLimitStatus;
|
initialRateLimit: RateLimitStatus;
|
||||||
initialExtensionUsage: ExtensionUsage;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ApiKeySettings({
|
export function ApiKeySettings({ initialStatus, initialRateLimit }: Props) {
|
||||||
initialStatus,
|
|
||||||
initialRateLimit,
|
|
||||||
initialExtensionUsage,
|
|
||||||
}: Props) {
|
|
||||||
const [status, setStatus] = useState(initialStatus);
|
const [status, setStatus] = useState(initialStatus);
|
||||||
const [rateLimit, setRateLimit] = useState(initialRateLimit);
|
const [rateLimit, setRateLimit] = useState(initialRateLimit);
|
||||||
const [extensionUsage, setExtensionUsage] = useState(initialExtensionUsage);
|
|
||||||
const [newKey, setNewKey] = useState<string | null>(null);
|
const [newKey, setNewKey] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [requesting, setRequesting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -81,146 +59,58 @@ export function ApiKeySettings({
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setNewKey(null);
|
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
|
setNewKey(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/account/api-key", { method: "POST" });
|
const res = await fetch("/api/account/api-key", { method: "POST" });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error || "Failed to generate API key");
|
if (!res.ok) throw new Error(data.error || "Failed to generate key");
|
||||||
|
|
||||||
setNewKey(data.apiKey);
|
setNewKey(data.apiKey);
|
||||||
setStatus({ configured: true, prefix: data.prefix });
|
setStatus({ configured: true, prefix: data.prefix });
|
||||||
} catch (err) {
|
setSuccess("API key generated. Copy it now — it will not be shown again.");
|
||||||
setError(err instanceof Error ? err.message : "Failed to generate API key");
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to generate key");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function revokeKey() {
|
async function revokeKey() {
|
||||||
if (!confirm("Revoke your API key? External integrations will stop working.")) return;
|
if (!confirm("Revoke the current API key?")) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
|
setNewKey(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/account/api-key", { method: "DELETE" });
|
const res = await fetch("/api/account/api-key", { method: "DELETE" });
|
||||||
const data = await res.json();
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(data.error || "Failed to revoke API key");
|
if (!res.ok) throw new Error(data.error || "Failed to revoke key");
|
||||||
|
|
||||||
setStatus({ configured: false, prefix: null });
|
setStatus({ configured: false, prefix: null });
|
||||||
setNewKey(null);
|
setSuccess("API key revoked.");
|
||||||
} catch (err) {
|
} catch (e) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to revoke API key");
|
setError(e instanceof Error ? e.message : "Failed to revoke key");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestRateLimitExtension() {
|
|
||||||
if (extensionUsage.remaining <= 0) return;
|
|
||||||
|
|
||||||
const reason = prompt(
|
|
||||||
"Optional: tell us why you need a higher API rate limit (leave blank to skip).",
|
|
||||||
);
|
|
||||||
if (reason === null) return;
|
|
||||||
|
|
||||||
setRequesting(true);
|
|
||||||
setError(null);
|
|
||||||
setSuccess(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/account/api-rate-limit-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");
|
|
||||||
|
|
||||||
setExtensionUsage({
|
|
||||||
used: data.used,
|
|
||||||
limit: data.limit,
|
|
||||||
remaining: data.remaining,
|
|
||||||
requests: data.requests ?? extensionUsage.requests,
|
|
||||||
});
|
|
||||||
setSuccess(
|
|
||||||
`Rate limit extension request submitted. ${data.used} of ${data.limit} used this year.`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to submit request");
|
|
||||||
} finally {
|
|
||||||
setRequesting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasPending = extensionUsage.requests.some((r) => r.status === "PENDING");
|
|
||||||
const usedPct = Math.min(100, Math.round((rateLimit.used / Math.max(1, rateLimit.limit)) * 100));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
Use the REST API to upload files and create batch video jobs programmatically. Pro plan
|
Use the REST API to upload files and create batch video jobs programmatically.
|
||||||
only.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="rounded border border-gray-800 bg-surface p-4 space-y-3">
|
<div className="rounded border border-gray-800 bg-surface p-4 space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<p className="text-sm font-medium text-white">API rate limit</p>
|
<p className="text-sm font-medium text-white">API rate limit</p>
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400">Resets in {rateLimit.resetsInSeconds}s</p>
|
||||||
Resets in {rateLimit.resetsInSeconds}s
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-300">
|
<p className="text-sm text-gray-300">
|
||||||
{rateLimit.used} / {rateLimit.limit} requests used this minute
|
{rateLimit.used} / {rateLimit.limit} requests used this minute
|
||||||
{rateLimit.bonus ? (
|
|
||||||
<span className="text-gray-500"> (includes +{rateLimit.bonus} bonus)</span>
|
|
||||||
) : null}
|
|
||||||
</p>
|
</p>
|
||||||
<div className="h-2 overflow-hidden rounded bg-gray-800">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded transition-all ${
|
|
||||||
usedPct >= 90 ? "bg-red-500" : usedPct >= 70 ? "bg-amber-500" : "bg-accent"
|
|
||||||
}`}
|
|
||||||
style={{ width: `${usedPct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
{rateLimit.remaining} requests remaining · window {rateLimit.windowSeconds}s
|
Self-hosted: API rate limits are effectively unlimited.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<p className="text-xs text-gray-400">
|
|
||||||
Extension requests this year: {extensionUsage.used} / {extensionUsage.limit}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={requestRateLimitExtension}
|
|
||||||
disabled={requesting || extensionUsage.remaining <= 0 || hasPending}
|
|
||||||
className="inline-flex items-center justify-center rounded border border-accent/40 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/10 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{requesting
|
|
||||||
? "Submitting…"
|
|
||||||
: hasPending
|
|
||||||
? "Pending request"
|
|
||||||
: extensionUsage.remaining <= 0
|
|
||||||
? "No extensions left"
|
|
||||||
: "Request rate limit extension"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{extensionUsage.requests.length > 0 && (
|
|
||||||
<ul className="space-y-1 border-t border-gray-800 pt-3 text-xs text-gray-500">
|
|
||||||
{extensionUsage.requests.slice(0, 5).map((req) => (
|
|
||||||
<li key={req.id}>
|
|
||||||
{new Date(req.requestedAt).toLocaleDateString()} · {req.status}
|
|
||||||
{req.adminNote ? ` · ${req.adminNote}` : ""}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status.configured && status.prefix && (
|
{status.configured && status.prefix && (
|
||||||
@@ -284,9 +174,13 @@ export function ApiKeySettings({
|
|||||||
</ul>
|
</ul>
|
||||||
<p className="mt-3">
|
<p className="mt-3">
|
||||||
Send <span className="text-gray-300">Authorization: Bearer YOUR_API_KEY</span> on every
|
Send <span className="text-gray-300">Authorization: Bearer YOUR_API_KEY</span> on every
|
||||||
request. For many audio files, upload each file then call{" "}
|
request.{" "}
|
||||||
<span className="text-gray-300">/api/v1/jobs</span> (avoid large one-shot batches).{" "}
|
<a
|
||||||
<a href="/dashboard/api-docs" className="text-accent hover:underline">
|
href={API_DOCS_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-accent hover:underline"
|
||||||
|
>
|
||||||
Full API docs
|
Full API docs
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ function GearsIcon({ className }: { className?: string }) {
|
|||||||
const BENEFITS = [
|
const BENEFITS = [
|
||||||
{
|
{
|
||||||
title: "No editing required",
|
title: "No editing required",
|
||||||
desc: "Skip complex video editors. Just upload an image and audio, and Songs2YT handles the rest.",
|
desc: "Skip complex video editors. Just upload an image and audio, and Songs2VID handles the rest.",
|
||||||
Icon: FilmStripIcon,
|
Icon: FilmStripIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -86,13 +86,13 @@ const BENEFITS = [
|
|||||||
Icon: YouTubeIcon,
|
Icon: YouTubeIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Free tier included",
|
title: "Self-hosted, unlocked",
|
||||||
desc: "Start creating with 14 videos every 12 hours at up to 720p. No credit card needed.",
|
desc: "No video quotas or paywalls. Art-track layouts, typography, watermarks, and the REST API are all available.",
|
||||||
Icon: CoinsIcon,
|
Icon: CoinsIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "YouTube playlists",
|
title: "YouTube playlists",
|
||||||
desc: "Independent Artist (Pro) can create YouTube playlists and add every upload from the dashboard or API.",
|
desc: "Create YouTube playlists and add every upload from the dashboard or API.",
|
||||||
Icon: PlaylistIcon,
|
Icon: PlaylistIcon,
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
+15
-8
@@ -2,10 +2,13 @@ import Link from "next/link";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
import {
|
import {
|
||||||
|
API_DOCS_URL,
|
||||||
DOCKER_HUB_URL,
|
DOCKER_HUB_URL,
|
||||||
|
DOCS_URL,
|
||||||
GITEA_ISSUES_URL,
|
GITEA_ISSUES_URL,
|
||||||
GITEA_URL,
|
GITEA_URL,
|
||||||
SUPPORT_EMAIL,
|
SUPPORT_EMAIL,
|
||||||
|
WEBSITE_URL,
|
||||||
} from "@/lib/plans";
|
} from "@/lib/plans";
|
||||||
|
|
||||||
function GiteaIcon({ className }: { className?: string }) {
|
function GiteaIcon({ className }: { className?: string }) {
|
||||||
@@ -88,6 +91,7 @@ function LegalLink({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_URL = "https://status.atakanozban.com/status/2";
|
const STATUS_URL = "https://status.atakanozban.com/status/2";
|
||||||
|
const DOCS_INTRO_URL = `${DOCS_URL}/docs/intro`;
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
@@ -129,12 +133,19 @@ export function Footer() {
|
|||||||
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||||
Product
|
Product
|
||||||
</span>
|
</span>
|
||||||
<FooterLink href="#pricing">Pricing</FooterLink>
|
|
||||||
<FooterLink href="#download">Download</FooterLink>
|
<FooterLink href="#download">Download</FooterLink>
|
||||||
<FooterLink href="#benefits">Benefits</FooterLink>
|
<FooterLink href="#benefits">Benefits</FooterLink>
|
||||||
|
<FooterLink href={DOCS_INTRO_URL} external>
|
||||||
|
Documentation
|
||||||
|
</FooterLink>
|
||||||
|
<FooterLink href={API_DOCS_URL} external>
|
||||||
|
API docs
|
||||||
|
</FooterLink>
|
||||||
|
<FooterLink href={WEBSITE_URL} external>
|
||||||
|
Hosted Songs2VID
|
||||||
|
</FooterLink>
|
||||||
<FooterLink href="/privacy">Privacy Policy</FooterLink>
|
<FooterLink href="/privacy">Privacy Policy</FooterLink>
|
||||||
<FooterLink href="/terms">Terms of Service</FooterLink>
|
<FooterLink href="/terms">Terms of Service</FooterLink>
|
||||||
<FooterLink href="/refund">Refund Policy</FooterLink>
|
|
||||||
<FooterLink href={STATUS_URL} external>
|
<FooterLink href={STATUS_URL} external>
|
||||||
Service Status
|
Service Status
|
||||||
</FooterLink>
|
</FooterLink>
|
||||||
@@ -160,13 +171,13 @@ export function Footer() {
|
|||||||
Contact
|
Contact
|
||||||
</span>
|
</span>
|
||||||
<FooterLink href={`mailto:${SUPPORT_EMAIL}`}>Support Email</FooterLink>
|
<FooterLink href={`mailto:${SUPPORT_EMAIL}`}>Support Email</FooterLink>
|
||||||
<span className="text-xs text-gray-600">Response within 24h for Pro users</span>
|
<span className="text-xs text-gray-600">Self-hosted community support</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 border-t border-gray-900 pt-8 text-xs text-gray-500 md:justify-start">
|
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 border-t border-gray-900 pt-8 text-xs text-gray-500 md:justify-start">
|
||||||
<span>© {year} Songs2YT. All rights reserved.</span>
|
<span>© {year} Songs2VID. All rights reserved.</span>
|
||||||
<span aria-hidden="true" className="hidden sm:inline">
|
<span aria-hidden="true" className="hidden sm:inline">
|
||||||
|
|
|
|
||||||
</span>
|
</span>
|
||||||
@@ -178,10 +189,6 @@ export function Footer() {
|
|||||||
<span aria-hidden="true" className="hidden sm:inline">
|
<span aria-hidden="true" className="hidden sm:inline">
|
||||||
|
|
|
|
||||||
</span>
|
</span>
|
||||||
<LegalLink href="/refund">Refund Policy</LegalLink>
|
|
||||||
<span aria-hidden="true" className="hidden sm:inline">
|
|
||||||
|
|
|
||||||
</span>
|
|
||||||
<LegalLink href={STATUS_URL} external>
|
<LegalLink href={STATUS_URL} external>
|
||||||
Service Status
|
Service Status
|
||||||
</LegalLink>
|
</LegalLink>
|
||||||
|
|||||||
@@ -20,12 +20,7 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
export function JobProgress({ jobId }: Props) {
|
export function JobProgress({ jobId }: Props) {
|
||||||
const [job, setJob] = useState<JobResponse | null>(null);
|
const [job, setJob] = useState<JobResponse | null>(null);
|
||||||
const [quota, setQuota] = useState<{
|
const [videosProcessed, setVideosProcessed] = useState<number | null>(null);
|
||||||
remaining: number;
|
|
||||||
limit: number;
|
|
||||||
plan: string;
|
|
||||||
resetsIn: string;
|
|
||||||
} | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -44,7 +39,7 @@ export function JobProgress({ jobId }: Props) {
|
|||||||
|
|
||||||
if (quotaRes.ok) {
|
if (quotaRes.ok) {
|
||||||
const quotaData = await quotaRes.json();
|
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) {
|
} catch (err) {
|
||||||
if (active) setError(err instanceof Error ? err.message : "Error loading job");
|
if (active) setError(err instanceof Error ? err.message : "Error loading job");
|
||||||
@@ -81,12 +76,9 @@ export function JobProgress({ jobId }: Props) {
|
|||||||
Overall: <span className="text-white">{job.status}</span>
|
Overall: <span className="text-white">{job.status}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{quota && (
|
{videosProcessed !== null && (
|
||||||
<div className="rounded bg-surface-light px-4 py-2 text-sm text-gray-300">
|
<div className="rounded bg-surface-light px-4 py-2 text-sm text-gray-300">
|
||||||
{quota.remaining} / {quota.limit} videos remaining ·{" "}
|
Self-hosted · {videosProcessed} videos processed
|
||||||
{quota.plan === "FREE"
|
|
||||||
? `resets in ${quota.resetsIn}`
|
|
||||||
: `monthly quota resets on ${quota.resetsIn}`}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
import { MobileMenuButton, MobileSidebar, sidebarLinkClass } from "@/components/MobileSidebar";
|
import { MobileMenuButton, MobileSidebar, sidebarLinkClass } from "@/components/MobileSidebar";
|
||||||
|
import { DOCS_URL, WEBSITE_URL } from "@/lib/plans";
|
||||||
|
|
||||||
const NAV_LINKS = [
|
const NAV_LINKS = [
|
||||||
{ href: "#benefits", label: "Benefits" },
|
{ href: "#benefits", label: "Benefits", kind: "anchor" as const },
|
||||||
{ href: "#download", label: "Download" },
|
{ href: "#download", label: "Download", kind: "anchor" as const },
|
||||||
{ href: "#pricing", label: "Pricing" },
|
{ href: `${DOCS_URL}/docs/intro`, label: "Docs", kind: "external" as const },
|
||||||
{ href: "#support", label: "Support" },
|
{ href: WEBSITE_URL, label: "Hosted", kind: "external" as const },
|
||||||
|
{ href: "#support", label: "Support", kind: "anchor" as const },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function NavAnchor({
|
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 (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() => onNavigate?.()}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function LandingNavbar() {
|
export function LandingNavbar() {
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
@@ -48,9 +74,13 @@ export function LandingNavbar() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<nav className="hidden items-center gap-10 sm:flex">
|
<nav className="hidden items-center gap-10 sm:flex">
|
||||||
{NAV_LINKS.map(({ href, label }) => (
|
{NAV_LINKS.map((link) =>
|
||||||
<NavAnchor key={href} href={href} label={label} />
|
link.kind === "external" ? (
|
||||||
))}
|
<ExternalLink key={link.href} href={link.href} label={link.label} />
|
||||||
|
) : (
|
||||||
|
<NavAnchor key={link.href} href={link.href} label={link.label} />
|
||||||
|
),
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<MobileMenuButton
|
<MobileMenuButton
|
||||||
@@ -62,15 +92,25 @@ export function LandingNavbar() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<MobileSidebar open={menuOpen} onClose={() => setMenuOpen(false)} title="Menu">
|
<MobileSidebar open={menuOpen} onClose={() => setMenuOpen(false)} title="Menu">
|
||||||
{NAV_LINKS.map(({ href, label }) => (
|
{NAV_LINKS.map((link) =>
|
||||||
<NavAnchor
|
link.kind === "external" ? (
|
||||||
key={href}
|
<ExternalLink
|
||||||
href={href}
|
key={link.href}
|
||||||
label={label}
|
href={link.href}
|
||||||
onNavigate={() => setMenuOpen(false)}
|
label={link.label}
|
||||||
className={sidebarLinkClass()}
|
onNavigate={() => setMenuOpen(false)}
|
||||||
/>
|
className={sidebarLinkClass()}
|
||||||
))}
|
/>
|
||||||
|
) : (
|
||||||
|
<NavAnchor
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
label={link.label}
|
||||||
|
onNavigate={() => setMenuOpen(false)}
|
||||||
|
className={sidebarLinkClass()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</MobileSidebar>
|
</MobileSidebar>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,792 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
|
type ChangeEvent,
|
||||||
|
} from "react";
|
||||||
|
import { getVideoAttributionText } from "@/lib/branding";
|
||||||
|
import {
|
||||||
|
CURATED_FONTS,
|
||||||
|
googleFontsStylesheetUrl,
|
||||||
|
type CuratedFontKey,
|
||||||
|
type WatermarkFontKey,
|
||||||
|
} from "@/lib/fonts";
|
||||||
|
import {
|
||||||
|
BLUR_AMOUNT_MAX,
|
||||||
|
BLUR_AMOUNT_MIN,
|
||||||
|
BLUR_OPACITY_DEFAULT,
|
||||||
|
BLUR_OPACITY_MAX,
|
||||||
|
BLUR_OPACITY_MIN,
|
||||||
|
DEFAULT_LAYOUT,
|
||||||
|
LAYOUT_TEMPLATE_LABELS,
|
||||||
|
LAYOUT_TEMPLATES,
|
||||||
|
TEXT_OFFSET_MAX,
|
||||||
|
TEXT_OFFSET_MIN,
|
||||||
|
TEXT_PADDING_MAX,
|
||||||
|
TEXT_PADDING_MIN,
|
||||||
|
TITLE_ARTIST_GAP_MAX,
|
||||||
|
TITLE_ARTIST_GAP_MIN,
|
||||||
|
type LayoutSettings,
|
||||||
|
type LayoutTemplate,
|
||||||
|
} from "@/lib/layout";
|
||||||
|
import {
|
||||||
|
DEFAULT_WATERMARK,
|
||||||
|
WATERMARK_OFFSET_MAX,
|
||||||
|
WATERMARK_OFFSET_MIN,
|
||||||
|
WATERMARK_POSITIONS,
|
||||||
|
WATERMARK_TEXT_MAX,
|
||||||
|
type WatermarkMode,
|
||||||
|
type WatermarkPosition,
|
||||||
|
type WatermarkSettings,
|
||||||
|
} from "@/lib/watermark";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
locked?: boolean;
|
||||||
|
previewImageUrl: string | null;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
layout: LayoutSettings;
|
||||||
|
onLayoutChange: (next: LayoutSettings) => void;
|
||||||
|
watermark: WatermarkSettings;
|
||||||
|
onWatermarkChange: (next: WatermarkSettings) => void;
|
||||||
|
onUploadLogo: (file: File) => Promise<string>;
|
||||||
|
onUploadFont: (file: File) => Promise<string>;
|
||||||
|
logoPreviewUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WM_POSITION_LABELS: Record<WatermarkPosition, string> = {
|
||||||
|
"top-left": "Top left",
|
||||||
|
"top-right": "Top right",
|
||||||
|
"bottom-left": "Bottom left",
|
||||||
|
"bottom-right": "Bottom right",
|
||||||
|
center: "Center",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CUSTOM_PREVIEW_FAMILY = "S2VIDCustomWm";
|
||||||
|
|
||||||
|
function watermarkOverlayStyle(
|
||||||
|
position: WatermarkPosition,
|
||||||
|
offsetX: number,
|
||||||
|
offsetY: number,
|
||||||
|
): CSSProperties {
|
||||||
|
const base: CSSProperties = {
|
||||||
|
position: "absolute",
|
||||||
|
maxWidth: "32%",
|
||||||
|
pointerEvents: "none",
|
||||||
|
zIndex: 5,
|
||||||
|
};
|
||||||
|
const ox = `${offsetX}px`;
|
||||||
|
const oy = `${offsetY}px`;
|
||||||
|
switch (position) {
|
||||||
|
case "top-left":
|
||||||
|
return { ...base, top: oy, left: ox };
|
||||||
|
case "top-right":
|
||||||
|
return { ...base, top: oy, right: ox };
|
||||||
|
case "bottom-left":
|
||||||
|
return { ...base, bottom: oy, left: ox };
|
||||||
|
case "center":
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY}px))`,
|
||||||
|
};
|
||||||
|
case "bottom-right":
|
||||||
|
default:
|
||||||
|
return { ...base, bottom: oy, right: ox };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewFontFamily(fontKey: WatermarkFontKey | undefined): string {
|
||||||
|
if (!fontKey || fontKey === "system") return "ui-sans-serif, system-ui, sans-serif";
|
||||||
|
if (fontKey === "custom") return `'${CUSTOM_PREVIEW_FAMILY}', sans-serif`;
|
||||||
|
const meta = CURATED_FONTS.find((f) => f.key === fontKey);
|
||||||
|
return meta ? `'${meta.cssFamily}', sans-serif` : "sans-serif";
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniThumb({
|
||||||
|
template,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
template: LayoutTemplate | null;
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled: boolean;
|
||||||
|
}) {
|
||||||
|
const isClassic = template === null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`overflow-hidden rounded border text-left transition-colors ${
|
||||||
|
active
|
||||||
|
? "border-accent bg-accent/10"
|
||||||
|
: "border-gray-700 bg-black/40 hover:border-gray-500"
|
||||||
|
} disabled:cursor-not-allowed disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
<div className="relative aspect-video w-full overflow-hidden bg-gradient-to-br from-gray-800 to-gray-950 p-1">
|
||||||
|
{isClassic ? (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="h-[70%] w-[45%] rounded-sm bg-gray-600" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative h-full w-full overflow-hidden bg-gray-700/50">
|
||||||
|
<div className="absolute inset-0 scale-110 bg-gray-600/40 blur-[3px]" />
|
||||||
|
{template === "COVER_LEFT_TEXT_RIGHT" && (
|
||||||
|
<>
|
||||||
|
<div className="absolute left-[6%] top-[18%] h-[64%] w-[38%] bg-gray-300" />
|
||||||
|
<div className="absolute right-[8%] top-[38%] h-1 w-[40%] rounded bg-white/80" />
|
||||||
|
<div className="absolute right-[8%] top-[52%] h-0.5 w-[28%] rounded bg-white/50" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{template === "COVER_RIGHT_TEXT_LEFT" && (
|
||||||
|
<>
|
||||||
|
<div className="absolute right-[6%] top-[18%] h-[64%] w-[38%] bg-gray-300" />
|
||||||
|
<div className="absolute left-[8%] top-[38%] h-1 w-[40%] rounded bg-white/80" />
|
||||||
|
<div className="absolute left-[8%] top-[52%] h-0.5 w-[28%] rounded bg-white/50" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{template === "COVER_TOP_TEXT_BOTTOM" && (
|
||||||
|
<>
|
||||||
|
<div className="absolute left-[8%] top-[6%] h-[52%] w-[84%] bg-gray-300" />
|
||||||
|
<div className="absolute left-[20%] bottom-[18%] h-1 w-[60%] rounded bg-white/80" />
|
||||||
|
<div className="absolute left-[28%] bottom-[8%] h-0.5 w-[44%] rounded bg-white/50" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{template === "CENTERED_COMPACT" && (
|
||||||
|
<>
|
||||||
|
<div className="absolute left-[32%] top-[14%] h-[42%] w-[36%] bg-gray-300" />
|
||||||
|
<div className="absolute left-[28%] bottom-[22%] h-1 w-[44%] rounded bg-white/80" />
|
||||||
|
<div className="absolute left-[34%] bottom-[12%] h-0.5 w-[32%] rounded bg-white/50" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="px-2 py-1.5 text-[10px] font-medium text-gray-300">
|
||||||
|
{isClassic ? "Classic letterbox" : LAYOUT_TEMPLATE_LABELS[template]}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewCoverStyle(
|
||||||
|
template: LayoutTemplate,
|
||||||
|
padPct: number,
|
||||||
|
): CSSProperties {
|
||||||
|
const p = `${padPct}%`;
|
||||||
|
switch (template) {
|
||||||
|
case "COVER_LEFT_TEXT_RIGHT":
|
||||||
|
return {
|
||||||
|
position: "absolute",
|
||||||
|
left: p,
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
width: "38%",
|
||||||
|
maxHeight: "72%",
|
||||||
|
objectFit: "contain",
|
||||||
|
};
|
||||||
|
case "COVER_RIGHT_TEXT_LEFT":
|
||||||
|
return {
|
||||||
|
position: "absolute",
|
||||||
|
right: p,
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
width: "38%",
|
||||||
|
maxHeight: "72%",
|
||||||
|
objectFit: "contain",
|
||||||
|
};
|
||||||
|
case "COVER_TOP_TEXT_BOTTOM":
|
||||||
|
return {
|
||||||
|
position: "absolute",
|
||||||
|
left: "50%",
|
||||||
|
top: p,
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
width: `calc(100% - ${padPct * 2}%)`,
|
||||||
|
maxHeight: "56%",
|
||||||
|
objectFit: "contain",
|
||||||
|
};
|
||||||
|
case "CENTERED_COMPACT":
|
||||||
|
return {
|
||||||
|
position: "absolute",
|
||||||
|
left: "50%",
|
||||||
|
top: "16%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
width: "38%",
|
||||||
|
maxHeight: "38%",
|
||||||
|
objectFit: "contain",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewTextStyle(
|
||||||
|
template: LayoutTemplate,
|
||||||
|
padPct: number,
|
||||||
|
textOffsetX: number,
|
||||||
|
textOffsetY: number,
|
||||||
|
titleArtistGap: number,
|
||||||
|
): CSSProperties {
|
||||||
|
const p = `${padPct}%`;
|
||||||
|
const shift = {
|
||||||
|
transform: undefined as string | undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseGap = { display: "flex", flexDirection: "column" as const, gap: `${titleArtistGap}px` };
|
||||||
|
|
||||||
|
switch (template) {
|
||||||
|
case "COVER_LEFT_TEXT_RIGHT":
|
||||||
|
return {
|
||||||
|
...baseGap,
|
||||||
|
position: "absolute",
|
||||||
|
left: `calc(48% + ${textOffsetX}px)`,
|
||||||
|
right: p,
|
||||||
|
top: "50%",
|
||||||
|
transform: `translateY(calc(-50% + ${textOffsetY}px))`,
|
||||||
|
textAlign: "left",
|
||||||
|
};
|
||||||
|
case "COVER_RIGHT_TEXT_LEFT":
|
||||||
|
return {
|
||||||
|
...baseGap,
|
||||||
|
position: "absolute",
|
||||||
|
left: p,
|
||||||
|
right: `calc(48% - ${textOffsetX}px)`,
|
||||||
|
top: "50%",
|
||||||
|
transform: `translateY(calc(-50% + ${textOffsetY}px))`,
|
||||||
|
textAlign: "left",
|
||||||
|
};
|
||||||
|
case "COVER_TOP_TEXT_BOTTOM":
|
||||||
|
return {
|
||||||
|
...baseGap,
|
||||||
|
position: "absolute",
|
||||||
|
left: p,
|
||||||
|
right: p,
|
||||||
|
bottom: `calc(10% - ${textOffsetY}px)`,
|
||||||
|
transform: textOffsetX ? `translateX(${textOffsetX}px)` : undefined,
|
||||||
|
textAlign: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
};
|
||||||
|
case "CENTERED_COMPACT":
|
||||||
|
return {
|
||||||
|
...baseGap,
|
||||||
|
position: "absolute",
|
||||||
|
left: p,
|
||||||
|
right: p,
|
||||||
|
top: `calc(58% + ${textOffsetY}px)`,
|
||||||
|
transform: textOffsetX ? `translateX(${textOffsetX}px)` : undefined,
|
||||||
|
textAlign: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
void shift;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LayoutStudio({
|
||||||
|
locked = false,
|
||||||
|
previewImageUrl,
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
layout,
|
||||||
|
onLayoutChange,
|
||||||
|
watermark,
|
||||||
|
onWatermarkChange,
|
||||||
|
onUploadLogo,
|
||||||
|
onUploadFont,
|
||||||
|
logoPreviewUrl,
|
||||||
|
}: Props) {
|
||||||
|
const [logoUploading, setLogoUploading] = useState(false);
|
||||||
|
const [logoError, setLogoError] = useState<string | null>(null);
|
||||||
|
const [fontUploading, setFontUploading] = useState(false);
|
||||||
|
const [fontError, setFontError] = useState<string | null>(null);
|
||||||
|
const [customFontObjectUrl, setCustomFontObjectUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Always coalesce HMR / older session state may omit newly added fields
|
||||||
|
const L: LayoutSettings = {
|
||||||
|
...DEFAULT_LAYOUT,
|
||||||
|
...layout,
|
||||||
|
blurAmount: layout.blurAmount ?? DEFAULT_LAYOUT.blurAmount,
|
||||||
|
blurOpacity: layout.blurOpacity ?? BLUR_OPACITY_DEFAULT,
|
||||||
|
textPadding: layout.textPadding ?? DEFAULT_LAYOUT.textPadding,
|
||||||
|
titleArtistGap: layout.titleArtistGap ?? DEFAULT_LAYOUT.titleArtistGap,
|
||||||
|
textOffsetX: layout.textOffsetX ?? DEFAULT_LAYOUT.textOffsetX,
|
||||||
|
textOffsetY: layout.textOffsetY ?? DEFAULT_LAYOUT.textOffsetY,
|
||||||
|
};
|
||||||
|
const W: WatermarkSettings = {
|
||||||
|
...DEFAULT_WATERMARK,
|
||||||
|
...watermark,
|
||||||
|
text: watermark.text ?? "",
|
||||||
|
offsetX: watermark.offsetX ?? DEFAULT_WATERMARK.offsetX,
|
||||||
|
offsetY: watermark.offsetY ?? DEFAULT_WATERMARK.offsetY,
|
||||||
|
fontKey: watermark.fontKey ?? "system",
|
||||||
|
};
|
||||||
|
|
||||||
|
const blurPx = useMemo(
|
||||||
|
() => Math.round((L.blurAmount / 100) * 28),
|
||||||
|
[L.blurAmount],
|
||||||
|
);
|
||||||
|
const padPct = useMemo(
|
||||||
|
() =>
|
||||||
|
3 +
|
||||||
|
((L.textPadding - TEXT_PADDING_MIN) / (TEXT_PADDING_MAX - TEXT_PADDING_MIN)) * 5,
|
||||||
|
[L.textPadding],
|
||||||
|
);
|
||||||
|
|
||||||
|
const wmOverlay = useMemo(
|
||||||
|
() => watermarkOverlayStyle(W.position, W.offsetX, W.offsetY),
|
||||||
|
[W.position, W.offsetX, W.offsetY],
|
||||||
|
);
|
||||||
|
|
||||||
|
const textFontStyle = useMemo(
|
||||||
|
() => ({ fontFamily: previewFontFamily(W.fontKey) }),
|
||||||
|
[W.fontKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = "s2vid-watermark-google-fonts";
|
||||||
|
if (document.getElementById(id)) return;
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.id = id;
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
link.href = googleFontsStylesheetUrl(CURATED_FONTS.map((f) => f.key));
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!customFontObjectUrl) return;
|
||||||
|
const styleId = "s2vid-watermark-custom-font";
|
||||||
|
let el = document.getElementById(styleId) as HTMLStyleElement | null;
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement("style");
|
||||||
|
el.id = styleId;
|
||||||
|
document.head.appendChild(el);
|
||||||
|
}
|
||||||
|
el.textContent = `
|
||||||
|
@font-face {
|
||||||
|
font-family: '${CUSTOM_PREVIEW_FAMILY}';
|
||||||
|
src: url('${customFontObjectUrl}');
|
||||||
|
font-display: swap;
|
||||||
|
}`;
|
||||||
|
}, [customFontObjectUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (customFontObjectUrl) URL.revokeObjectURL(customFontObjectUrl);
|
||||||
|
};
|
||||||
|
}, [customFontObjectUrl]);
|
||||||
|
|
||||||
|
function patchLayout(partial: Partial<LayoutSettings>) {
|
||||||
|
if (locked) return;
|
||||||
|
onLayoutChange({ ...DEFAULT_LAYOUT, ...layout, ...partial });
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchWm(partial: Partial<WatermarkSettings>) {
|
||||||
|
if (locked) return;
|
||||||
|
onWatermarkChange({ ...DEFAULT_WATERMARK, ...watermark, ...partial });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogo(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (!file || locked) return;
|
||||||
|
setLogoError(null);
|
||||||
|
setLogoUploading(true);
|
||||||
|
try {
|
||||||
|
const path = await onUploadLogo(file);
|
||||||
|
patchWm({ mode: "logo", logoPath: path });
|
||||||
|
} catch (err) {
|
||||||
|
setLogoError(err instanceof Error ? err.message : "Logo upload failed");
|
||||||
|
} finally {
|
||||||
|
setLogoUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFont(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (!file || locked) return;
|
||||||
|
setFontError(null);
|
||||||
|
setFontUploading(true);
|
||||||
|
try {
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
if (customFontObjectUrl) URL.revokeObjectURL(customFontObjectUrl);
|
||||||
|
setCustomFontObjectUrl(objectUrl);
|
||||||
|
const path = await onUploadFont(file);
|
||||||
|
patchWm({ mode: "text", fontKey: "custom", fontPath: path });
|
||||||
|
} catch (err) {
|
||||||
|
setFontError(err instanceof Error ? err.message : "Font upload failed");
|
||||||
|
} finally {
|
||||||
|
setFontUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFontKey(key: WatermarkFontKey) {
|
||||||
|
if (key === "custom") {
|
||||||
|
patchWm({ fontKey: "custom", fontPath: watermark.fontPath ?? null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
patchWm({ fontKey: key, fontPath: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const artTrack = Boolean(L.template);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative rounded-lg border border-gray-700 bg-surface p-6">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="text-lg font-medium text-white">Video layout</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Single live preview: art-track + watermark */}
|
||||||
|
<div
|
||||||
|
className="relative mx-auto aspect-video w-full max-w-xl overflow-hidden rounded border border-gray-800 bg-black"
|
||||||
|
aria-hidden={locked}
|
||||||
|
>
|
||||||
|
{previewImageUrl ? (
|
||||||
|
<>
|
||||||
|
{artTrack ? (
|
||||||
|
<>
|
||||||
|
<div className="absolute inset-0 bg-black" />
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={previewImageUrl}
|
||||||
|
alt=""
|
||||||
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
style={{
|
||||||
|
filter: L.blurAmount > 0 ? `blur(${blurPx}px)` : undefined,
|
||||||
|
transform: "scale(1.15)",
|
||||||
|
opacity: L.blurOpacity / 100,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={previewImageUrl}
|
||||||
|
alt=""
|
||||||
|
style={previewCoverStyle(L.template!, padPct)}
|
||||||
|
className="pointer-events-none"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={previewTextStyle(
|
||||||
|
L.template!,
|
||||||
|
padPct,
|
||||||
|
L.textOffsetX,
|
||||||
|
L.textOffsetY,
|
||||||
|
L.titleArtistGap,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="max-w-full truncate text-sm font-semibold text-white drop-shadow"
|
||||||
|
style={
|
||||||
|
W.mode === "text" ? textFontStyle : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{title.trim() || "Track title"}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="max-w-full truncate text-xs text-white/75 drop-shadow"
|
||||||
|
style={
|
||||||
|
W.mode === "text" ? textFontStyle : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{artist.trim() || "Artist"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={previewImageUrl}
|
||||||
|
alt=""
|
||||||
|
className="max-h-full max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{W.mode !== "none" && (
|
||||||
|
<div style={wmOverlay} className="rounded bg-black/40 px-2 py-1">
|
||||||
|
{W.mode === "logo" && logoPreviewUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={logoPreviewUrl}
|
||||||
|
alt=""
|
||||||
|
className="max-h-14 w-auto object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="text-[11px] font-medium text-white/90 drop-shadow"
|
||||||
|
style={W.mode === "text" ? textFontStyle : undefined}
|
||||||
|
>
|
||||||
|
{W.mode === "text" && W.text?.trim()
|
||||||
|
? W.text.trim().slice(0, WATERMARK_TEXT_MAX)
|
||||||
|
: getVideoAttributionText()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-gray-600">
|
||||||
|
Upload a cover image to preview
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Art-track templates */}
|
||||||
|
<p className="mb-2 mt-5 text-sm font-medium text-gray-300">Composition</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
|
<MiniThumb
|
||||||
|
template={null}
|
||||||
|
active={L.template === null}
|
||||||
|
disabled={locked}
|
||||||
|
onClick={() => patchLayout({ template: null })}
|
||||||
|
/>
|
||||||
|
{LAYOUT_TEMPLATES.map((t) => (
|
||||||
|
<MiniThumb
|
||||||
|
key={t}
|
||||||
|
template={t}
|
||||||
|
active={L.template === t}
|
||||||
|
disabled={locked}
|
||||||
|
onClick={() => patchLayout({ template: t })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="text-sm text-gray-400">
|
||||||
|
Background blur ({L.blurAmount}%)
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={BLUR_AMOUNT_MIN}
|
||||||
|
max={BLUR_AMOUNT_MAX}
|
||||||
|
disabled={locked || !artTrack}
|
||||||
|
value={L.blurAmount}
|
||||||
|
onChange={(e) => patchLayout({ blurAmount: Number(e.target.value) })}
|
||||||
|
className="mt-1 w-full disabled:opacity-40"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm text-gray-400">
|
||||||
|
Background opacity ({L.blurOpacity}%)
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={BLUR_OPACITY_MIN}
|
||||||
|
max={BLUR_OPACITY_MAX}
|
||||||
|
disabled={locked || !artTrack}
|
||||||
|
value={L.blurOpacity}
|
||||||
|
onChange={(e) => patchLayout({ blurOpacity: Number(e.target.value) })}
|
||||||
|
className="mt-1 w-full disabled:opacity-40"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm text-gray-400">
|
||||||
|
Cover / edge padding ({L.textPadding}px)
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={TEXT_PADDING_MIN}
|
||||||
|
max={TEXT_PADDING_MAX}
|
||||||
|
disabled={locked || !artTrack}
|
||||||
|
value={L.textPadding}
|
||||||
|
onChange={(e) => patchLayout({ textPadding: Number(e.target.value) })}
|
||||||
|
className="mt-1 w-full disabled:opacity-40"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm text-gray-400">
|
||||||
|
Title ↔ artist gap ({L.titleArtistGap}px)
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={TITLE_ARTIST_GAP_MIN}
|
||||||
|
max={TITLE_ARTIST_GAP_MAX}
|
||||||
|
disabled={locked || !artTrack}
|
||||||
|
value={L.titleArtistGap}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchLayout({ titleArtistGap: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
className="mt-1 w-full disabled:opacity-40"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm text-gray-400">
|
||||||
|
Text horizontal ({L.textOffsetX}px)
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={TEXT_OFFSET_MIN}
|
||||||
|
max={TEXT_OFFSET_MAX}
|
||||||
|
disabled={locked || !artTrack}
|
||||||
|
value={L.textOffsetX}
|
||||||
|
onChange={(e) => patchLayout({ textOffsetX: Number(e.target.value) })}
|
||||||
|
className="mt-1 w-full disabled:opacity-40"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm text-gray-400 sm:col-span-2">
|
||||||
|
Text vertical ({L.textOffsetY}px)
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={TEXT_OFFSET_MIN}
|
||||||
|
max={TEXT_OFFSET_MAX}
|
||||||
|
disabled={locked || !artTrack}
|
||||||
|
value={L.textOffsetY}
|
||||||
|
onChange={(e) => patchLayout({ textOffsetY: Number(e.target.value) })}
|
||||||
|
className="mt-1 w-full disabled:opacity-40"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Watermark section */}
|
||||||
|
<div className="mt-6 border-t border-gray-800 pt-5">
|
||||||
|
<p className="mb-3 text-sm font-medium text-gray-300">Watermark</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["none", "No watermark"],
|
||||||
|
["default", "Songs2VID badge"],
|
||||||
|
["text", "Custom text"],
|
||||||
|
["logo", "PNG logo"],
|
||||||
|
] as const
|
||||||
|
).map(([mode, label]) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
disabled={locked}
|
||||||
|
onClick={() => patchWm({ mode: mode as WatermarkMode })}
|
||||||
|
className={`rounded border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
|
W.mode === mode
|
||||||
|
? "border-accent bg-accent/15 text-accent"
|
||||||
|
: "border-gray-700 text-gray-400 hover:border-gray-500"
|
||||||
|
} disabled:cursor-not-allowed`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{W.mode === "text" && (
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
<label className="block text-sm text-gray-400">
|
||||||
|
Watermark text
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
maxLength={WATERMARK_TEXT_MAX}
|
||||||
|
disabled={locked}
|
||||||
|
value={W.text ?? ""}
|
||||||
|
onChange={(e) => patchWm({ text: e.target.value })}
|
||||||
|
className="input-field mt-1"
|
||||||
|
placeholder="Your brand name"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm text-gray-400">
|
||||||
|
Font
|
||||||
|
<select
|
||||||
|
disabled={locked}
|
||||||
|
value={W.fontKey ?? "system"}
|
||||||
|
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
|
||||||
|
className="input-field mt-1"
|
||||||
|
>
|
||||||
|
<option value="system">System default</option>
|
||||||
|
{CURATED_FONTS.map((f) => (
|
||||||
|
<option key={f.key} value={f.key}>
|
||||||
|
{f.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="custom">Custom upload (.ttf / .otf)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{W.fontKey === "custom" && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm text-gray-400">
|
||||||
|
Upload font (.ttf or .otf, max 10 MB)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".ttf,.otf,font/ttf,font/otf"
|
||||||
|
disabled={locked || fontUploading}
|
||||||
|
onChange={(e) => void handleFont(e)}
|
||||||
|
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white"
|
||||||
|
/>
|
||||||
|
{fontUploading && (
|
||||||
|
<p className="mt-1 text-xs text-yellow-400">Uploading font…</p>
|
||||||
|
)}
|
||||||
|
{fontError && <p className="mt-1 text-xs text-red-400">{fontError}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(W.fontKey === "custom" ||
|
||||||
|
(W.fontKey &&
|
||||||
|
W.fontKey !== "system" &&
|
||||||
|
CURATED_FONTS.some(
|
||||||
|
(f) => f.key === (W.fontKey as CuratedFontKey),
|
||||||
|
))) && (
|
||||||
|
<p className="text-xs text-gray-500" style={textFontStyle}>
|
||||||
|
Preview: The quick brown fox jumps over the lazy dog
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{W.mode === "logo" && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<label className="mb-1 block text-sm text-gray-400">PNG logo</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,.png"
|
||||||
|
disabled={locked || logoUploading}
|
||||||
|
onChange={(e) => void handleLogo(e)}
|
||||||
|
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white"
|
||||||
|
/>
|
||||||
|
{logoUploading && (
|
||||||
|
<p className="mt-1 text-xs text-yellow-400">Uploading logo…</p>
|
||||||
|
)}
|
||||||
|
{logoError && <p className="mt-1 text-xs text-red-400">{logoError}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="mb-2 text-sm text-gray-400">Watermark position</p>
|
||||||
|
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
|
||||||
|
{WATERMARK_POSITIONS.map((pos) => (
|
||||||
|
<button
|
||||||
|
key={pos}
|
||||||
|
type="button"
|
||||||
|
disabled={locked || W.mode === "none"}
|
||||||
|
onClick={() => patchWm({ position: pos })}
|
||||||
|
className={`rounded border px-2 py-2 text-[11px] font-medium ${
|
||||||
|
W.position === pos
|
||||||
|
? "border-accent bg-accent/15 text-accent"
|
||||||
|
: "border-gray-700 text-gray-400"
|
||||||
|
} disabled:opacity-40`}
|
||||||
|
>
|
||||||
|
{WM_POSITION_LABELS[pos]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="text-sm text-gray-400">
|
||||||
|
Watermark offset X ({W.offsetX}px)
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={WATERMARK_OFFSET_MIN}
|
||||||
|
max={WATERMARK_OFFSET_MAX}
|
||||||
|
disabled={locked || W.mode === "none"}
|
||||||
|
value={W.offsetX}
|
||||||
|
onChange={(e) => patchWm({ offsetX: Number(e.target.value) })}
|
||||||
|
className="mt-1 w-full"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm text-gray-400">
|
||||||
|
Watermark offset Y ({W.offsetY}px)
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={WATERMARK_OFFSET_MIN}
|
||||||
|
max={WATERMARK_OFFSET_MAX}
|
||||||
|
disabled={locked || W.mode === "none"}
|
||||||
|
value={W.offsetY}
|
||||||
|
onChange={(e) => patchWm({ offsetY: Number(e.target.value) })}
|
||||||
|
className="mt-1 w-full"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(null);
|
|
||||||
const [success, setSuccess] = useState<string | null>(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 (
|
|
||||||
<div className="mt-4 space-y-4">
|
|
||||||
<div className="rounded border border-gray-700 bg-surface px-4 py-3 text-sm">
|
|
||||||
<p className="text-gray-300">
|
|
||||||
Extension requests this year:{" "}
|
|
||||||
<span className="font-medium text-white">
|
|
||||||
{usage.used} / {usage.limit}
|
|
||||||
</span>
|
|
||||||
{usage.remaining > 0 ? (
|
|
||||||
<span className="text-gray-500"> · {usage.remaining} remaining</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-yellow-400"> · limit reached</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{success && (
|
|
||||||
<div className="rounded border border-green-500/50 bg-green-500/10 px-4 py-3 text-sm text-green-300">
|
|
||||||
{success}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCancelPlan}
|
|
||||||
disabled={cancelling}
|
|
||||||
className="inline-flex items-center justify-center rounded border border-yellow-600/50 px-4 py-2 text-sm font-medium text-yellow-300 transition-colors hover:bg-yellow-500/10 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{cancelling ? "Cancelling…" : "Cancel your plan"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleQuotaRequest}
|
|
||||||
disabled={requesting || usage.remaining <= 0 || hasPending}
|
|
||||||
className="inline-flex items-center justify-center rounded border border-accent/40 px-4 py-2 text-sm font-medium text-accent transition-colors hover:bg-accent/10 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{requesting
|
|
||||||
? "Submitting…"
|
|
||||||
: hasPending
|
|
||||||
? "Request pending…"
|
|
||||||
: "Request quota reset & extension"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{usage.requests.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
|
||||||
Request history
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{usage.requests.slice(0, 5).map((req) => (
|
|
||||||
<li
|
|
||||||
key={req.id}
|
|
||||||
className="rounded border border-gray-800 bg-surface px-3 py-2 text-xs text-gray-400"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-gray-300">
|
|
||||||
{new Date(req.requestedAt).toLocaleDateString(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
req.status === "APPROVED"
|
|
||||||
? "text-green-400"
|
|
||||||
: req.status === "REJECTED"
|
|
||||||
? "text-red-400"
|
|
||||||
: "text-yellow-400"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{req.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{req.message && <p className="mt-1 text-gray-500">{req.message}</p>}
|
|
||||||
<p className="mt-1 font-mono text-[10px] text-gray-600">ID: {req.id}</p>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
Pro users may request up to 5 manual quota resets or extensions per calendar year. See our{" "}
|
|
||||||
<a href="/terms" className="text-gray-400 underline hover:text-white">
|
|
||||||
Terms of Service
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { UpgradeProLink } from "./UpgradeProLink";
|
|
||||||
|
|
||||||
type Playlist = {
|
type Playlist = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -80,14 +79,7 @@ export function PlaylistSelect({ value, onChange, enabled }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!enabled) {
|
if (!enabled) { return null; }
|
||||||
return (
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
Add uploaded videos to a YouTube playlist with{" "}
|
|
||||||
<UpgradeProLink className="text-accent hover:underline" />.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -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 <span className="text-sm text-gray-200">{value}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CloudIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
className={className}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M7.5 18.5h9.75a4.25 4.25 0 0 0 .55-8.46A5.75 5.75 0 0 0 6.9 8.8 4.5 4.5 0 0 0 7.5 18.5Z"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ServerIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
className={className}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<rect x="4" y="5" width="16" height="6" rx="2" />
|
|
||||||
<rect x="4" y="13" width="16" height="6" rx="2" />
|
|
||||||
<path d="M7.5 8h.01M7.5 16h.01" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InfrastructureChips({
|
|
||||||
chips,
|
|
||||||
}: {
|
|
||||||
chips: InfrastructureChip[];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
||||||
{chips.includes("cloud") && (
|
|
||||||
<span className="inline-flex items-center gap-2 rounded-lg border border-gray-700/60 bg-surface-dark/40 px-3 py-2 text-sm text-gray-200">
|
|
||||||
<CloudIcon className="h-4 w-4 text-gray-300" />
|
|
||||||
Cloud
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{chips.includes("self-hosted") && (
|
|
||||||
<span className="inline-flex items-center gap-2 rounded-lg border border-gray-700/60 bg-surface-dark/40 px-3 py-2 text-sm text-gray-200">
|
|
||||||
<ServerIcon className="h-4 w-4 text-gray-300" />
|
|
||||||
Self-hosted
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PricingCard({ plan }: { plan: PlanConfig }) {
|
|
||||||
const {
|
|
||||||
name,
|
|
||||||
badge,
|
|
||||||
price,
|
|
||||||
priceNote,
|
|
||||||
features,
|
|
||||||
watermarkNote,
|
|
||||||
infrastructureChips,
|
|
||||||
cta,
|
|
||||||
highlighted,
|
|
||||||
hover,
|
|
||||||
accent,
|
|
||||||
badgeClass,
|
|
||||||
ctaClass,
|
|
||||||
} = plan;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`group relative flex h-full flex-col rounded-2xl border bg-surface p-6 transition-all duration-300 sm:p-7 ${
|
|
||||||
highlighted
|
|
||||||
? "border-accent/40 shadow-[0_0_32px_rgba(74,158,255,0.12)]"
|
|
||||||
: "border-gray-700/50"
|
|
||||||
} ${hover}`}
|
|
||||||
>
|
|
||||||
{highlighted && (
|
|
||||||
<span className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-accent px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-white">
|
|
||||||
Most popular
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="mb-3 flex items-start justify-between gap-3">
|
|
||||||
<h3 className="text-lg font-semibold text-white">{name}</h3>
|
|
||||||
<span
|
|
||||||
className={`shrink-0 rounded px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${badgeClass}`}
|
|
||||||
>
|
|
||||||
{badge}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-end gap-1">
|
|
||||||
<span className={`text-4xl font-bold ${accent}`}>{price}</span>
|
|
||||||
<span className="mb-1 text-sm text-gray-500">{priceNote}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul className="mb-6 flex-1 space-y-4 border-t border-gray-700/50 pt-6">
|
|
||||||
{FEATURE_LABELS.map((label) => (
|
|
||||||
<li key={label}>
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wider text-gray-500">{label}</p>
|
|
||||||
{label === "Infrastructure" ? (
|
|
||||||
<InfrastructureChips chips={infrastructureChips} />
|
|
||||||
) : (
|
|
||||||
<FeatureValue value={features[label]} />
|
|
||||||
)}
|
|
||||||
{label === "Watermark" && watermarkNote && (
|
|
||||||
<p className="mt-1.5 rounded-lg border border-gray-700/60 bg-surface-dark/80 px-3 py-2 text-xs leading-relaxed text-gray-400">
|
|
||||||
{watermarkNote}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div className="mt-auto">
|
|
||||||
{cta.type === "signin" && <SignInButton fullWidth />}
|
|
||||||
{cta.type === "link" && (
|
|
||||||
<Link
|
|
||||||
href={cta.href}
|
|
||||||
className={`inline-flex w-full items-center justify-center rounded border px-4 py-3 text-sm font-medium transition-all duration-300 ${ctaClass ?? "border-gray-600 text-white hover:border-gray-400"}`}
|
|
||||||
>
|
|
||||||
{cta.label}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{cta.type === "disabled" && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled
|
|
||||||
className="w-full cursor-not-allowed rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm font-medium text-gray-500"
|
|
||||||
>
|
|
||||||
{cta.label}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{cta.type === "mailto" && (
|
|
||||||
<a
|
|
||||||
href={`mailto:${cta.email}?subject=${encodeURIComponent(cta.subject)}`}
|
|
||||||
className={`inline-flex w-full items-center justify-center rounded border px-4 py-3 text-sm font-medium transition-all duration-300 ${ctaClass ?? ""}`}
|
|
||||||
>
|
|
||||||
{cta.label}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PricingSection() {
|
|
||||||
const sectionRef = useRef<HTMLElement>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
ref={sectionRef}
|
|
||||||
id="pricing"
|
|
||||||
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 py-24"
|
|
||||||
>
|
|
||||||
<SectionScrollTitle sectionRef={sectionRef} title="Pricing" />
|
|
||||||
|
|
||||||
<div className="relative z-10 mx-auto max-w-7xl">
|
|
||||||
<ScrollReveal>
|
|
||||||
<h2 className="mb-4 text-center text-3xl font-bold text-white">Pricing</h2>
|
|
||||||
<p className="mx-auto mb-10 max-w-2xl text-center text-gray-400">
|
|
||||||
Free for getting started, Pro for creators, or Enterprise for teams.
|
|
||||||
</p>
|
|
||||||
</ScrollReveal>
|
|
||||||
|
|
||||||
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
|
||||||
{PLANS.map((plan, index) => (
|
|
||||||
<ScrollReveal key={plan.name} delay={index * 100}>
|
|
||||||
<PricingCard plan={plan} />
|
|
||||||
</ScrollReveal>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollReveal delay={360}>
|
|
||||||
<p className="mx-auto mt-10 max-w-2xl text-center text-xs text-gray-500">
|
|
||||||
*Pro is a monthly subscription with included quota (extensions via support). Self-hosted
|
|
||||||
open-source deployments have no quotas.
|
|
||||||
</p>
|
|
||||||
<p className="mx-auto mt-4 max-w-2xl text-center text-xs text-gray-500">
|
|
||||||
Technical support is strictly reserved for Managed Cloud and paid Professional Setup
|
|
||||||
agreements; independent self-hosted deployments are community-supported.
|
|
||||||
</p>
|
|
||||||
</ScrollReveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<Link href={UPGRADE_URL} className={className}>
|
|
||||||
{children ?? "Upgrade to Pro"}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function QuotaErrorMessage({ message }: { message: string }) {
|
|
||||||
if (message.endsWith(PRO_UPGRADE_SUFFIX)) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{message.slice(0, -PRO_UPGRADE_SUFFIX.length)}{" "}
|
|
||||||
<UpgradeProLink className="font-medium text-red-200 underline hover:text-white">
|
|
||||||
Upload up to 50 videos with Pro!
|
|
||||||
</UpgradeProLink>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{message}</>;
|
|
||||||
}
|
|
||||||
+213
-60
@@ -4,14 +4,16 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Plan, Privacy } from "@prisma/client";
|
import { Plan, Privacy } from "@prisma/client";
|
||||||
import { filenameWithoutExtension } from "@/lib/constants";
|
import { filenameWithoutExtension } from "@/lib/constants";
|
||||||
|
import { getVideoAttributionText } from "@/lib/branding";
|
||||||
import { audioTagsToMetadata } from "@/lib/audio-tags";
|
import { audioTagsToMetadata } from "@/lib/audio-tags";
|
||||||
import type { ItemMetadata } from "@/lib/types";
|
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 { CategorySelect } from "./CategorySelect";
|
||||||
|
import { LayoutStudio } from "./LayoutStudio";
|
||||||
import { PlaylistSelect } from "./PlaylistSelect";
|
import { PlaylistSelect } from "./PlaylistSelect";
|
||||||
import { PrivacyToggle } from "./PrivacyToggle";
|
import { PrivacyToggle } from "./PrivacyToggle";
|
||||||
import { ResolutionSelect } from "./ResolutionSelect";
|
import { ResolutionSelect } from "./ResolutionSelect";
|
||||||
import { QuotaErrorMessage, UpgradeProLink } from "./UpgradeProLink";
|
|
||||||
|
|
||||||
const UPLOAD_CONCURRENCY = 4;
|
const UPLOAD_CONCURRENCY = 4;
|
||||||
|
|
||||||
type AudioItem = {
|
type AudioItem = {
|
||||||
@@ -19,6 +21,9 @@ type AudioItem = {
|
|||||||
file: File;
|
file: File;
|
||||||
path: string | null;
|
path: string | null;
|
||||||
uploading: boolean;
|
uploading: boolean;
|
||||||
|
itemImagePath: string | null;
|
||||||
|
itemImageName: string | null;
|
||||||
|
itemImagePreview: string | null;
|
||||||
metadata: ItemMetadata;
|
metadata: ItemMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,6 +41,10 @@ function defaultMetadata(title = ""): ItemMetadata {
|
|||||||
creativeCommons: false,
|
creativeCommons: false,
|
||||||
includeWatermark: true,
|
includeWatermark: true,
|
||||||
playlistId: null,
|
playlistId: null,
|
||||||
|
imagePath: null,
|
||||||
|
artist: null,
|
||||||
|
watermark: { ...DEFAULT_WATERMARK },
|
||||||
|
layout: { ...DEFAULT_LAYOUT },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,21 +53,19 @@ export function UploadForm() {
|
|||||||
const uploadSessionRef = useRef<string>(crypto.randomUUID());
|
const uploadSessionRef = useRef<string>(crypto.randomUUID());
|
||||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||||
const [imagePath, setImagePath] = useState<string | null>(null);
|
const [imagePath, setImagePath] = useState<string | null>(null);
|
||||||
|
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
|
||||||
const [imageUploading, setImageUploading] = useState(false);
|
const [imageUploading, setImageUploading] = useState(false);
|
||||||
const [audioItems, setAudioItems] = useState<AudioItem[]>([]);
|
const [audioItems, setAudioItems] = useState<AudioItem[]>([]);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [playlistId, setPlaylistId] = useState("");
|
const [playlistId, setPlaylistId] = useState("");
|
||||||
|
const [jobWatermark, setJobWatermark] = useState<WatermarkSettings>({ ...DEFAULT_WATERMARK });
|
||||||
|
const [jobLayout, setJobLayout] = useState<LayoutSettings>({ ...DEFAULT_LAYOUT });
|
||||||
|
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
|
||||||
const [quota, setQuota] = useState<{
|
const [quota, setQuota] = useState<{
|
||||||
remaining: number;
|
|
||||||
limit: number;
|
|
||||||
plan: Plan;
|
plan: Plan;
|
||||||
maxBatchSize: number;
|
maxBatchSize: number;
|
||||||
resetsIn: string;
|
|
||||||
videoCredits: number;
|
|
||||||
totalAvailable: number;
|
|
||||||
used: number;
|
used: number;
|
||||||
selfHosted?: boolean;
|
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const loadQuota = useCallback(async () => {
|
const loadQuota = useCallback(async () => {
|
||||||
@@ -66,15 +73,9 @@ export function UploadForm() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setQuota({
|
setQuota({
|
||||||
remaining: data.remaining,
|
|
||||||
limit: data.limit,
|
|
||||||
plan: data.plan,
|
plan: data.plan,
|
||||||
maxBatchSize: data.maxBatchSize,
|
maxBatchSize: data.maxBatchSize,
|
||||||
resetsIn: data.resetsIn,
|
|
||||||
videoCredits: data.videoCredits ?? 0,
|
|
||||||
totalAvailable: data.totalAvailable ?? data.remaining,
|
|
||||||
used: data.used ?? 0,
|
used: data.used ?? 0,
|
||||||
selfHosted: Boolean(data.selfHosted),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -83,20 +84,30 @@ export function UploadForm() {
|
|||||||
loadQuota();
|
loadQuota();
|
||||||
}, [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(
|
async function uploadFile(
|
||||||
file: File,
|
file: File,
|
||||||
type: "image" | "audio",
|
type: "image" | "audio" | "logo" | "font",
|
||||||
): Promise<{ path: string; audioTags?: Parameters<typeof audioTagsToMetadata>[0] | null }> {
|
): Promise<{ path: string; audioTags?: Parameters<typeof audioTagsToMetadata>[0] | null }> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
formData.append("type", type);
|
formData.append("type", type);
|
||||||
formData.append("session", uploadSessionRef.current);
|
formData.append("session", uploadSessionRef.current);
|
||||||
const res = await fetch("/api/upload", { method: "POST", body: formData });
|
const res = await fetch("/api/upload", { method: "POST", body: formData });
|
||||||
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
|
||||||
throw new Error(data.error || "Upload failed");
|
throw new Error(data.error || "Upload failed");
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
|
||||||
return { path: data.path, audioTags: data.audioTags };
|
return { path: data.path, audioTags: data.audioTags };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,9 +123,14 @@ export function UploadForm() {
|
|||||||
file,
|
file,
|
||||||
path: null,
|
path: null,
|
||||||
uploading: true,
|
uploading: true,
|
||||||
|
itemImagePath: null,
|
||||||
|
itemImageName: null,
|
||||||
|
itemImagePreview: null,
|
||||||
metadata: {
|
metadata: {
|
||||||
...defaultMetadata(autoTitle),
|
...defaultMetadata(autoTitle),
|
||||||
playlistId: playlistId || null,
|
playlistId: playlistId || null,
|
||||||
|
watermark: { ...jobWatermark },
|
||||||
|
layout: { ...jobLayout },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -165,6 +181,8 @@ export function UploadForm() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setImageFile(file);
|
setImageFile(file);
|
||||||
setImageUploading(true);
|
setImageUploading(true);
|
||||||
|
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
|
||||||
|
setImagePreviewUrl(URL.createObjectURL(file));
|
||||||
try {
|
try {
|
||||||
const path = await uploadFile(file, "image");
|
const path = await uploadFile(file, "image");
|
||||||
setImagePath(path.path);
|
setImagePath(path.path);
|
||||||
@@ -172,11 +190,106 @@ export function UploadForm() {
|
|||||||
setError(err instanceof Error ? err.message : "Image upload failed");
|
setError(err instanceof Error ? err.message : "Image upload failed");
|
||||||
setImageFile(null);
|
setImageFile(null);
|
||||||
setImagePath(null);
|
setImagePath(null);
|
||||||
|
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
|
||||||
|
setImagePreviewUrl(null);
|
||||||
} finally {
|
} finally {
|
||||||
setImageUploading(false);
|
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<HTMLInputElement>) {
|
async function handleAudioChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const files = Array.from(e.target.files || []);
|
const files = Array.from(e.target.files || []);
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
@@ -244,13 +357,31 @@ export function UploadForm() {
|
|||||||
items: readyAudios.map((item) => ({
|
items: readyAudios.map((item) => ({
|
||||||
audioPath: item.path!,
|
audioPath: item.path!,
|
||||||
audioFilename: item.file.name,
|
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();
|
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}`);
|
router.push(`/jobs/${data.jobId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -263,30 +394,13 @@ export function UploadForm() {
|
|||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{quota && (
|
{quota && (
|
||||||
<div className="rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm text-gray-300">
|
<div className="rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm text-gray-300">
|
||||||
{quota.selfHosted ? (
|
Self-hosted · {quota.used} videos processed · up to {quota.maxBatchSize} files per batch
|
||||||
<>
|
|
||||||
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
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||||
<QuotaErrorMessage message={error} />
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -308,17 +422,16 @@ export function UploadForm() {
|
|||||||
{imageUploading ? "Uploading…" : imageFile?.name || "No image selected"}
|
{imageUploading ? "Uploading…" : imageFile?.name || "No image selected"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-gray-500">
|
||||||
|
Shared cover for all tracks. Optionally override per audio below.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm text-gray-400">Audio files</label>
|
<label className="mb-2 block text-sm text-gray-400">Audio files</label>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept={
|
accept="audio/mpeg,audio/wav,audio/flac,.mp3,.wav,.flac"
|
||||||
quota?.selfHosted || quota?.plan === "PREMIUM"
|
|
||||||
? "audio/mpeg,audio/wav,audio/flac,.mp3,.wav,.flac"
|
|
||||||
: "audio/mpeg,.mp3"
|
|
||||||
}
|
|
||||||
multiple
|
multiple
|
||||||
onChange={handleAudioChange}
|
onChange={handleAudioChange}
|
||||||
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"
|
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"
|
||||||
@@ -346,7 +459,7 @@ export function UploadForm() {
|
|||||||
<PlaylistSelect
|
<PlaylistSelect
|
||||||
value={playlistId}
|
value={playlistId}
|
||||||
onChange={handlePlaylistChange}
|
onChange={handlePlaylistChange}
|
||||||
enabled={Boolean(quota?.selfHosted || quota?.plan === "PREMIUM")}
|
enabled={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -384,6 +497,19 @@ export function UploadForm() {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Artist (art-track layouts)">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.metadata.artist ?? ""}
|
||||||
|
onChange={(e) => updateItemMetadata(item.id, { artist: e.target.value })}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="Optional shown in layout templates"
|
||||||
|
maxLength={80}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<Field label="Category">
|
<Field label="Category">
|
||||||
<CategorySelect
|
<CategorySelect
|
||||||
value={item.metadata.categoryId}
|
value={item.metadata.categoryId}
|
||||||
@@ -426,6 +552,31 @@ export function UploadForm() {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<div className="relative rounded border border-gray-800 bg-surface-light/40 p-3">
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Cover for this track (optional)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
disabled={false}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{item.itemImageName && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
{item.itemImagePath ? `Using ${item.itemImageName}` : `Uploading ${item.itemImageName}…`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Notify subscribers about this upload"
|
label="Notify subscribers about this upload"
|
||||||
@@ -447,28 +598,30 @@ export function UploadForm() {
|
|||||||
checked={item.metadata.creativeCommons}
|
checked={item.metadata.creativeCommons}
|
||||||
onChange={(v) => updateItemMetadata(item.id, { creativeCommons: v })}
|
onChange={(v) => updateItemMetadata(item.id, { creativeCommons: v })}
|
||||||
/>
|
/>
|
||||||
<Checkbox
|
|
||||||
label="Show 'Uploaded through Songs2YT.com' watermark (support open-source)"
|
|
||||||
checked={item.metadata.includeWatermark}
|
|
||||||
onChange={(v) => updateItemMetadata(item.id, { includeWatermark: v })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{quota && !quota.selfHosted && quota.plan === "FREE" && (
|
<LayoutStudio
|
||||||
<>
|
locked={false}
|
||||||
<p className="text-sm text-gray-400">
|
previewImageUrl={
|
||||||
Watermark is optional on the free plan - turn it off anytime for a clean video.
|
audioItems.find((a) => a.itemImagePreview)?.itemImagePreview || imagePreviewUrl
|
||||||
</p>
|
}
|
||||||
<p className="text-sm text-gray-400">
|
title={audioItems[0]?.metadata.title || "Track title"}
|
||||||
<UpgradeProLink className="text-accent hover:underline" /> for 50 videos/month, 1080p,
|
artist={audioItems[0]?.metadata.artist || ""}
|
||||||
lossless audio, and full watermark control.
|
layout={jobLayout}
|
||||||
</p>
|
onLayoutChange={applyLayoutToAll}
|
||||||
</>
|
watermark={jobWatermark}
|
||||||
)}
|
onWatermarkChange={applyWatermarkToAll}
|
||||||
|
onUploadLogo={handleLogoUpload}
|
||||||
|
onUploadFont={handleFontUpload}
|
||||||
|
logoPreviewUrl={logoPreviewUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
"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 {
|
||||||
|
WATERMARK_OFFSET_MAX,
|
||||||
|
WATERMARK_OFFSET_MIN,
|
||||||
|
WATERMARK_POSITIONS,
|
||||||
|
WATERMARK_TEXT_MAX,
|
||||||
|
type WatermarkMode,
|
||||||
|
type WatermarkPosition,
|
||||||
|
type WatermarkSettings,
|
||||||
|
} from "@/lib/watermark";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
enabled: boolean;
|
||||||
|
locked?: boolean;
|
||||||
|
previewImageUrl: string | null;
|
||||||
|
value: WatermarkSettings;
|
||||||
|
onChange: (next: WatermarkSettings) => void;
|
||||||
|
onUploadLogo: (file: File) => Promise<string>;
|
||||||
|
onUploadFont: (file: File) => Promise<string>;
|
||||||
|
logoPreviewUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const POSITION_LABELS: Record<WatermarkPosition, string> = {
|
||||||
|
"top-left": "Top left",
|
||||||
|
"top-right": "Top right",
|
||||||
|
"bottom-left": "Bottom left",
|
||||||
|
"bottom-right": "Bottom right",
|
||||||
|
center: "Center",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CUSTOM_PREVIEW_FAMILY = "S2VIDCustomWm";
|
||||||
|
|
||||||
|
function previewStyle(
|
||||||
|
position: WatermarkPosition,
|
||||||
|
offsetX: number,
|
||||||
|
offsetY: number,
|
||||||
|
): CSSProperties {
|
||||||
|
const base: CSSProperties = {
|
||||||
|
position: "absolute",
|
||||||
|
maxWidth: "32%",
|
||||||
|
pointerEvents: "none",
|
||||||
|
};
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WatermarkPreview({
|
||||||
|
enabled,
|
||||||
|
locked = false,
|
||||||
|
previewImageUrl,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onUploadLogo,
|
||||||
|
onUploadFont,
|
||||||
|
logoPreviewUrl,
|
||||||
|
}: Props) {
|
||||||
|
const [logoUploading, setLogoUploading] = useState(false);
|
||||||
|
const [logoError, setLogoError] = useState<string | null>(null);
|
||||||
|
const [fontUploading, setFontUploading] = useState(false);
|
||||||
|
const [fontError, setFontError] = useState<string | null>(null);
|
||||||
|
const [customFontObjectUrl, setCustomFontObjectUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const overlay = useMemo(
|
||||||
|
() => previewStyle(value.position, value.offsetX, value.offsetY),
|
||||||
|
[value.position, value.offsetX, value.offsetY],
|
||||||
|
);
|
||||||
|
|
||||||
|
const textFontStyle = useMemo(
|
||||||
|
() => ({ fontFamily: previewFontFamily(value.fontKey) }),
|
||||||
|
[value.fontKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load curated Google Fonts for live canvas preview
|
||||||
|
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);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// @font-face for uploaded custom font (browser preview only)
|
||||||
|
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;
|
||||||
|
}`;
|
||||||
|
return () => {
|
||||||
|
/* keep style until next custom font replaces it */
|
||||||
|
};
|
||||||
|
}, [customFontObjectUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (customFontObjectUrl) URL.revokeObjectURL(customFontObjectUrl);
|
||||||
|
};
|
||||||
|
}, [customFontObjectUrl]);
|
||||||
|
|
||||||
|
function patch(partial: Partial<WatermarkSettings>) {
|
||||||
|
if (locked) return;
|
||||||
|
onChange({ ...value, ...partial });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogo(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (!file || locked) return;
|
||||||
|
setLogoError(null);
|
||||||
|
setLogoUploading(true);
|
||||||
|
try {
|
||||||
|
const path = await onUploadLogo(file);
|
||||||
|
patch({ mode: "logo", logoPath: path });
|
||||||
|
} catch (err) {
|
||||||
|
setLogoError(err instanceof Error ? err.message : "Logo upload failed");
|
||||||
|
} finally {
|
||||||
|
setLogoUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFont(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (!file || locked) return;
|
||||||
|
setFontError(null);
|
||||||
|
setFontUploading(true);
|
||||||
|
try {
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
if (customFontObjectUrl) URL.revokeObjectURL(customFontObjectUrl);
|
||||||
|
setCustomFontObjectUrl(objectUrl);
|
||||||
|
const path = await onUploadFont(file);
|
||||||
|
patch({ 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") {
|
||||||
|
patch({ fontKey: "custom", fontPath: value.fontPath ?? null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
patch({ fontKey: key, fontPath: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative rounded-lg border border-gray-700 bg-surface p-6"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="text-lg font-medium text-white">Watermark layout</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative mx-auto aspect-video w-full max-w-xl overflow-hidden rounded border border-gray-800 bg-black"
|
||||||
|
aria-hidden={locked}
|
||||||
|
>
|
||||||
|
{previewImageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={previewImageUrl} alt="" className="h-full w-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-gray-600">
|
||||||
|
Upload a cover image to preview
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{enabled && value.mode !== "none" && (
|
||||||
|
<div style={overlay} className="rounded bg-black/40 px-2 py-1">
|
||||||
|
{value.mode === "logo" && logoPreviewUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={logoPreviewUrl} alt="" className="max-h-16 w-auto object-contain" />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="text-[11px] font-medium text-white/90 drop-shadow"
|
||||||
|
style={value.mode === "text" ? textFontStyle : undefined}
|
||||||
|
>
|
||||||
|
{value.mode === "text" && value.text?.trim()
|
||||||
|
? value.text.trim().slice(0, WATERMARK_TEXT_MAX)
|
||||||
|
: getVideoAttributionText()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-4" aria-disabled={locked}>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["none", "No watermark"],
|
||||||
|
["default", "Songs2VID badge"],
|
||||||
|
["text", "Custom text"],
|
||||||
|
["logo", "PNG logo"],
|
||||||
|
] as const
|
||||||
|
).map(([mode, label]) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
disabled={locked}
|
||||||
|
onClick={() => patch({ mode: mode as WatermarkMode })}
|
||||||
|
className={`rounded border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
|
value.mode === mode
|
||||||
|
? "border-accent bg-accent/15 text-accent"
|
||||||
|
: "border-gray-700 text-gray-400 hover:border-gray-500"
|
||||||
|
} disabled:cursor-not-allowed`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{value.mode === "text" && (
|
||||||
|
<>
|
||||||
|
<label className="block text-sm text-gray-400">
|
||||||
|
Watermark text
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
maxLength={WATERMARK_TEXT_MAX}
|
||||||
|
disabled={locked}
|
||||||
|
value={value.text ?? ""}
|
||||||
|
onChange={(e) => patch({ text: e.target.value })}
|
||||||
|
className="input-field mt-1"
|
||||||
|
placeholder="Your brand name"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block text-sm text-gray-400">
|
||||||
|
Font
|
||||||
|
<select
|
||||||
|
disabled={locked}
|
||||||
|
value={value.fontKey ?? "system"}
|
||||||
|
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
|
||||||
|
className="input-field mt-1"
|
||||||
|
>
|
||||||
|
<option value="system">System default</option>
|
||||||
|
{CURATED_FONTS.map((f) => (
|
||||||
|
<option key={f.key} value={f.key} style={{ fontFamily: f.cssFamily }}>
|
||||||
|
{f.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="custom">Custom upload (.ttf / .otf)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{(value.fontKey === "custom" ||
|
||||||
|
(value.fontKey &&
|
||||||
|
value.fontKey !== "system" &&
|
||||||
|
CURATED_FONTS.some((f) => f.key === (value.fontKey as CuratedFontKey)))) && (
|
||||||
|
<p className="text-xs text-gray-500" style={textFontStyle}>
|
||||||
|
Preview: The quick brown fox jumps over the lazy dog
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{value.fontKey === "custom" && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm text-gray-400">
|
||||||
|
Upload font (.ttf or .otf, max 10 MB)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".ttf,.otf,font/ttf,font/otf"
|
||||||
|
disabled={locked || fontUploading}
|
||||||
|
onChange={(e) => void handleFont(e)}
|
||||||
|
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white"
|
||||||
|
/>
|
||||||
|
{fontUploading && (
|
||||||
|
<p className="mt-1 text-xs text-yellow-400">Uploading font…</p>
|
||||||
|
)}
|
||||||
|
{fontError && <p className="mt-1 text-xs text-red-400">{fontError}</p>}
|
||||||
|
{value.fontPath && !fontError && (
|
||||||
|
<p className="mt-1 text-xs text-green-400">Custom font ready for render</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{value.mode === "logo" && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm text-gray-400">PNG logo</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,.png"
|
||||||
|
disabled={locked || logoUploading}
|
||||||
|
onChange={(e) => void handleLogo(e)}
|
||||||
|
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white"
|
||||||
|
/>
|
||||||
|
{logoUploading && <p className="mt-1 text-xs text-yellow-400">Uploading logo…</p>}
|
||||||
|
{logoError && <p className="mt-1 text-xs text-red-400">{logoError}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm text-gray-400">Position</p>
|
||||||
|
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
|
||||||
|
{WATERMARK_POSITIONS.map((pos) => (
|
||||||
|
<button
|
||||||
|
key={pos}
|
||||||
|
type="button"
|
||||||
|
disabled={locked || value.mode === "none"}
|
||||||
|
onClick={() => patch({ position: pos })}
|
||||||
|
className={`rounded border px-2 py-2 text-[11px] font-medium ${
|
||||||
|
value.position === pos
|
||||||
|
? "border-accent bg-accent/15 text-accent"
|
||||||
|
: "border-gray-700 text-gray-400"
|
||||||
|
} disabled:opacity-40`}
|
||||||
|
>
|
||||||
|
{POSITION_LABELS[pos]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="text-sm text-gray-400">
|
||||||
|
Offset X ({value.offsetX}px)
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={WATERMARK_OFFSET_MIN}
|
||||||
|
max={WATERMARK_OFFSET_MAX}
|
||||||
|
disabled={locked || value.mode === "none"}
|
||||||
|
value={value.offsetX}
|
||||||
|
onChange={(e) => patch({ offsetX: Number(e.target.value) })}
|
||||||
|
className="mt-1 w-full"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm text-gray-400">
|
||||||
|
Offset Y ({value.offsetY}px)
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={WATERMARK_OFFSET_MIN}
|
||||||
|
max={WATERMARK_OFFSET_MAX}
|
||||||
|
disabled={locked || value.mode === "none"}
|
||||||
|
value={value.offsetY}
|
||||||
|
onChange={(e) => patch({ offsetY: Number(e.target.value) })}
|
||||||
|
className="mt-1 w-full"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+2
-2
@@ -34,7 +34,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
S2YT_EDITION: selfhosted
|
S2VID_EDITION: selfhosted
|
||||||
DATABASE_URL: postgresql://s2yt:s2yt@postgres:5432/s2yt
|
DATABASE_URL: postgresql://s2yt:s2yt@postgres:5432/s2yt
|
||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://redis:6379
|
||||||
UPLOAD_DIR: /app/uploads
|
UPLOAD_DIR: /app/uploads
|
||||||
@@ -55,7 +55,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
S2YT_EDITION: selfhosted
|
S2VID_EDITION: selfhosted
|
||||||
DATABASE_URL: postgresql://s2yt:s2yt@postgres:5432/s2yt
|
DATABASE_URL: postgresql://s2yt:s2yt@postgres:5432/s2yt
|
||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://redis:6379
|
||||||
UPLOAD_DIR: /app/uploads
|
UPLOAD_DIR: /app/uploads
|
||||||
|
|||||||
+2
-2
@@ -33,7 +33,7 @@ export async function requirePaidApiUser(req: NextRequest) {
|
|||||||
if (!hasProFeatures(user.plan)) {
|
if (!hasProFeatures(user.plan)) {
|
||||||
return {
|
return {
|
||||||
error: NextResponse.json(
|
error: NextResponse.json(
|
||||||
{ error: "API access requires an active Pro subscription" },
|
{ error: "API access is unavailable for this account" },
|
||||||
{ status: 403 },
|
{ status: 403 },
|
||||||
),
|
),
|
||||||
user: null,
|
user: null,
|
||||||
@@ -87,7 +87,7 @@ export async function requirePaidUserFromSessionOrApi(req: NextRequest) {
|
|||||||
if (!hasProFeatures(sessionUser.plan)) {
|
if (!hasProFeatures(sessionUser.plan)) {
|
||||||
return {
|
return {
|
||||||
error: NextResponse.json(
|
error: NextResponse.json(
|
||||||
{ error: "API access requires an active Pro subscription" },
|
{ error: "API access is unavailable for this account" },
|
||||||
{ status: 403 },
|
{ status: 403 },
|
||||||
),
|
),
|
||||||
user: null,
|
user: null,
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
export const BRAND_NAME = "Songs2YT";
|
export const BRAND_NAME = "Songs2VID";
|
||||||
export const BRAND_DOMAIN = `${BRAND_NAME}.com`;
|
export const BRAND_DOMAIN = `${BRAND_NAME}.com`;
|
||||||
export const VIDEO_ATTRIBUTION_PREFIX = "Uploaded through";
|
export const VIDEO_ATTRIBUTION_PREFIX = "Uploaded through";
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -1,9 +1,17 @@
|
|||||||
import { Plan } from "@prisma/client";
|
import { Plan } from "@prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-hosted / OSS edition. Prefer S2VID_EDITION; S2YT_EDITION kept for older compose files.
|
||||||
|
*/
|
||||||
export function isSelfHostedEdition(): boolean {
|
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 {
|
/** All Pro features are unlocked in the OSS / self-hosted edition. */
|
||||||
return isSelfHostedEdition() || plan === "PREMIUM";
|
export function hasProFeatures(_plan?: Plan): boolean {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string | null | undefined>,
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function premiumRequiredResponse(message: string) {
|
||||||
|
return {
|
||||||
|
error: message,
|
||||||
|
code: PREMIUM_REQUIRED_CODE,
|
||||||
|
};
|
||||||
|
}
|
||||||
+200
-35
@@ -4,7 +4,23 @@ import path from "path";
|
|||||||
import ffmpegStatic from "ffmpeg-static";
|
import ffmpegStatic from "ffmpeg-static";
|
||||||
import { getVideoAttributionText } from "../branding";
|
import { getVideoAttributionText } from "../branding";
|
||||||
import { getResolution } from "../constants";
|
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 { getWatermarkPath } from "../storage";
|
||||||
|
import {
|
||||||
|
buildDrawtextFilter,
|
||||||
|
normalizeWatermarkSettings,
|
||||||
|
overlayXy,
|
||||||
|
sanitizeDrawtext,
|
||||||
|
type WatermarkSettings,
|
||||||
|
} from "../watermark";
|
||||||
|
|
||||||
function getFfmpegPath(): string {
|
function getFfmpegPath(): string {
|
||||||
if (process.env.FFMPEG_PATH) return process.env.FFMPEG_PATH;
|
if (process.env.FFMPEG_PATH) return process.env.FFMPEG_PATH;
|
||||||
@@ -14,59 +30,207 @@ function getFfmpegPath(): string {
|
|||||||
|
|
||||||
export { getFfmpegPath };
|
export { getFfmpegPath };
|
||||||
|
|
||||||
|
async function fileExists(p: string): Promise<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<string | null> {
|
||||||
|
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<typeof buildArtTrackFilterComplex>[0],
|
||||||
|
outLabel: string,
|
||||||
|
): string {
|
||||||
|
return buildArtTrackFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function encodeVideo(options: {
|
export async function encodeVideo(options: {
|
||||||
imagePath: string;
|
imagePath: string;
|
||||||
audioPath: string;
|
audioPath: string;
|
||||||
outputPath: string;
|
outputPath: string;
|
||||||
resolution: string;
|
resolution: string;
|
||||||
includeWatermark: boolean;
|
includeWatermark: boolean;
|
||||||
|
watermark?: Partial<WatermarkSettings> | null;
|
||||||
|
layout?: LayoutSettings | null;
|
||||||
|
title?: string;
|
||||||
|
artist?: string | null;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const res = getResolution(options.resolution);
|
const res = getResolution(options.resolution);
|
||||||
if (!res) throw new Error(`Invalid resolution: ${options.resolution}`);
|
if (!res) throw new Error(`Invalid resolution: ${options.resolution}`);
|
||||||
|
|
||||||
await fs.mkdir(path.dirname(options.outputPath), { recursive: true });
|
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 settings = normalizeWatermarkSettings(options.watermark, options.includeWatermark);
|
||||||
|
const layout = options.layout?.template ? options.layout : null;
|
||||||
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 watermarkWidth = Math.max(1, Math.round(res.width * 0.32));
|
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];
|
const args = ["-y", "-loop", "1", "-r", "1", "-i", options.imagePath, "-i", options.audioPath];
|
||||||
|
|
||||||
if (useWatermark) {
|
let nextInput = 2;
|
||||||
args.push("-i", watermarkPath);
|
let logoInputIndex: number | null = null;
|
||||||
args.push(
|
let defaultWmInputIndex: number | null = null;
|
||||||
"-filter_complex",
|
|
||||||
`[0:v]${scaleFilter}[scaled];[2:v]scale=${watermarkWidth}:-1[wm];[scaled][wm]overlay=W-w-20:H-h-20[vout]`,
|
const needsLogo = settings.mode === "logo" && Boolean(settings.logoPath);
|
||||||
"-map",
|
const defaultPath = getWatermarkPath();
|
||||||
"[vout]",
|
const useDefaultPng =
|
||||||
"-map",
|
settings.mode === "default" && (await fileExists(defaultPath));
|
||||||
"1:a",
|
|
||||||
);
|
if (needsLogo && settings.logoPath) {
|
||||||
} else if (options.includeWatermark && !watermarkExists) {
|
if (!(await fileExists(settings.logoPath))) {
|
||||||
args.push(
|
throw new Error("Watermark logo file not found");
|
||||||
"-filter_complex",
|
}
|
||||||
`[0:v]${scaleFilter},drawtext=text='${attributionText}':fontsize=22:fontcolor=white@0.85:x=w-text_w-20:y=h-th-20[vout]`,
|
await assertPngFile(settings.logoPath);
|
||||||
"-map",
|
args.push("-i", settings.logoPath);
|
||||||
"[vout]",
|
logoInputIndex = nextInput++;
|
||||||
"-map",
|
} else if (useDefaultPng) {
|
||||||
"1:a",
|
args.push("-i", defaultPath);
|
||||||
);
|
defaultWmInputIndex = nextInput++;
|
||||||
} else {
|
|
||||||
args.push("-vf", scaleFilter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
args.push(
|
||||||
"-c:v",
|
"-c:v",
|
||||||
"libx264",
|
"libx264",
|
||||||
@@ -89,6 +253,7 @@ function runFfmpeg(args: string[]): Promise<void> {
|
|||||||
let stderr = "";
|
let stderr = "";
|
||||||
proc.stderr.on("data", (chunk) => {
|
proc.stderr.on("data", (chunk) => {
|
||||||
stderr += chunk.toString();
|
stderr += chunk.toString();
|
||||||
|
if (stderr.length > 64_000) stderr = stderr.slice(-32_000);
|
||||||
});
|
});
|
||||||
proc.on("close", (code) => {
|
proc.on("close", (code) => {
|
||||||
if (code === 0) resolve();
|
if (code === 0) resolve();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string>(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`;
|
||||||
|
}
|
||||||
+330
-34
@@ -5,7 +5,22 @@ import { readAudioTags } from "../audio-tags";
|
|||||||
import { isAllowedResolution } from "../constants";
|
import { isAllowedResolution } from "../constants";
|
||||||
import { prisma } from "../db";
|
import { prisma } from "../db";
|
||||||
import { hasProFeatures } from "../edition";
|
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 { moveFile, writeUploadedFile } from "../fs-utils";
|
||||||
|
import {
|
||||||
|
ARTIST_MAX,
|
||||||
|
INVALID_LAYOUT_TEMPLATE_MESSAGE,
|
||||||
|
normalizeLayoutSettings,
|
||||||
|
type LayoutSettings,
|
||||||
|
} from "../layout";
|
||||||
import { enqueueVideoJob } from "../queue/client";
|
import { enqueueVideoJob } from "../queue/client";
|
||||||
import {
|
import {
|
||||||
getPlanLimits,
|
getPlanLimits,
|
||||||
@@ -19,7 +34,54 @@ import {
|
|||||||
getUserStagingDir,
|
getUserStagingDir,
|
||||||
sanitizeUploadSessionKey,
|
sanitizeUploadSessionKey,
|
||||||
} from "../upload-paths";
|
} 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"]) {
|
export function validateItemMetadata(metadata: CreateJobPayload["items"][0]["metadata"]) {
|
||||||
if (!metadata.title?.trim()) return "Each video must have a title";
|
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)) {
|
if (!["PUBLIC", "PRIVATE", "UNLISTED"].includes(metadata.privacy)) {
|
||||||
return "Invalid privacy setting";
|
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;
|
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";
|
return "Image and at least one audio file required";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const itemImagePaths: Array<string | null | undefined> = [];
|
||||||
|
|
||||||
for (const item of body.items) {
|
for (const item of body.items) {
|
||||||
const metaError = validateItemMetadata(item.metadata);
|
const metaError = validateItemMetadata(item.metadata);
|
||||||
if (metaError) return metaError;
|
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`;
|
return `Resolution ${item.metadata.resolution} is not available on your plan`;
|
||||||
}
|
}
|
||||||
if (item.metadata.playlistId && !hasProFeatures(user.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);
|
const limits = getPlanLimits(user.plan);
|
||||||
@@ -66,11 +168,60 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
|
|||||||
if (stat.size > limits.maxFileSizeBytes) {
|
if (stat.size > limits.maxFileSizeBytes) {
|
||||||
return `Audio file ${item.audioFilename} exceeds size limit`;
|
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) {
|
} catch (err) {
|
||||||
|
if (err instanceof PremiumRequiredError) return err.message;
|
||||||
if (err instanceof Error && err.message === "Invalid upload path") {
|
if (err instanceof Error && err.message === "Invalid upload path") {
|
||||||
return "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";
|
return "One or more uploaded files not found";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,13 +234,32 @@ export async function createVideoJob(
|
|||||||
) {
|
) {
|
||||||
const validationError = await validateJobPayload(user, body);
|
const validationError = await validateJobPayload(user, body);
|
||||||
if (validationError) {
|
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 imagePath = assertPathInUserUploads(user.id, body.imagePath);
|
||||||
const items = body.items.map((item) => ({
|
const items = body.items.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
audioPath: assertPathInUserUploads(user.id, item.audioPath),
|
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);
|
const reservation = await reserveQuota(user.id, items.length);
|
||||||
@@ -100,23 +270,54 @@ export async function createVideoJob(
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
imagePath,
|
imagePath,
|
||||||
items: {
|
items: {
|
||||||
create: items.map((item, index) => ({
|
create: items.map((item) => {
|
||||||
audioPath: item.audioPath,
|
const wm = normalizeWatermarkSettings(
|
||||||
audioFilename: item.audioFilename,
|
item.metadata.watermark
|
||||||
title: item.metadata.title.trim(),
|
? {
|
||||||
description: item.metadata.description || "",
|
...item.metadata.watermark,
|
||||||
tags: item.metadata.tags || "",
|
logoPath: item.watermarkLogoPath,
|
||||||
privacy: item.metadata.privacy as Privacy,
|
fontPath: item.watermarkFontPath,
|
||||||
categoryId: item.metadata.categoryId || "10",
|
}
|
||||||
resolution: item.metadata.resolution,
|
: null,
|
||||||
notifySubscribers: item.metadata.notifySubscribers,
|
item.metadata.includeWatermark,
|
||||||
madeForKids: item.metadata.madeForKids,
|
);
|
||||||
embeddable: item.metadata.embeddable,
|
const layout = resolveLayoutFromMetadata(item.metadata);
|
||||||
creativeCommons: item.metadata.creativeCommons,
|
return {
|
||||||
includeWatermark: item.metadata.includeWatermark,
|
audioPath: item.audioPath,
|
||||||
playlistId: item.metadata.playlistId?.trim() || null,
|
audioFilename: item.audioFilename,
|
||||||
billingSource: index < reservation.fromQuota ? "QUOTA" : "CREDIT",
|
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 },
|
include: { items: true },
|
||||||
@@ -130,14 +331,61 @@ export async function createVideoJob(
|
|||||||
await moveFile(imagePath, newImagePath);
|
await moveFile(imagePath, newImagePath);
|
||||||
await prisma.job.update({ where: { id: job.id }, data: { imagePath: newImagePath } });
|
await prisma.job.update({ where: { id: job.id }, data: { imagePath: newImagePath } });
|
||||||
|
|
||||||
|
const moved = new Map<string, string>();
|
||||||
|
moved.set(imagePath, newImagePath);
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
job.items.map(async (item) => {
|
job.items.map(async (item) => {
|
||||||
const audioExt = path.extname(item.audioPath);
|
const audioExt = path.extname(item.audioPath);
|
||||||
const newAudioPath = path.join(jobDir, `${item.id}${audioExt}`);
|
const newAudioPath = path.join(jobDir, `${item.id}${audioExt}`);
|
||||||
await moveFile(item.audioPath, newAudioPath);
|
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({
|
await prisma.jobItem.update({
|
||||||
where: { id: item.id },
|
where: { id: item.id },
|
||||||
data: { audioPath: newAudioPath },
|
data: {
|
||||||
|
audioPath: newAudioPath,
|
||||||
|
itemImagePath: newItemImage,
|
||||||
|
watermarkLogoPath: newLogo,
|
||||||
|
watermarkFontPath: newFont,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await enqueueVideoJob({
|
await enqueueVideoJob({
|
||||||
@@ -155,19 +403,42 @@ export async function createVideoJob(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UploadFileType = "image" | "audio" | "logo" | "font";
|
||||||
|
|
||||||
export async function saveUploadedFile(
|
export async function saveUploadedFile(
|
||||||
userId: string,
|
userId: string,
|
||||||
file: File,
|
file: File,
|
||||||
type: "image" | "audio",
|
type: UploadFileType,
|
||||||
plan: Plan,
|
plan: Plan,
|
||||||
options?: { sessionKey?: string },
|
options?: { sessionKey?: string },
|
||||||
) {
|
) {
|
||||||
const limits = getPlanLimits(plan);
|
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`);
|
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 allowedImageTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||||
const allowedAudioTypes = [
|
const allowedAudioTypes = [
|
||||||
"audio/mpeg",
|
"audio/mpeg",
|
||||||
@@ -180,18 +451,25 @@ export async function saveUploadedFile(
|
|||||||
"audio/mp4",
|
"audio/mp4",
|
||||||
"audio/x-m4a",
|
"audio/x-m4a",
|
||||||
];
|
];
|
||||||
const allowedTypes = type === "image" ? allowedImageTypes : allowedAudioTypes;
|
|
||||||
|
|
||||||
if (
|
if (type === "image") {
|
||||||
!allowedTypes.includes(file.type) &&
|
if (
|
||||||
!file.name.match(/\.(mp3|wav|ogg|flac|aac|m4a|jpg|jpeg|png|webp|gif)$/i)
|
!allowedImageTypes.includes(file.type) &&
|
||||||
) {
|
!file.name.match(/\.(jpg|jpeg|png|webp|gif)$/i)
|
||||||
throw new Error("Invalid file type");
|
) {
|
||||||
}
|
throw new Error("Invalid image file type");
|
||||||
|
}
|
||||||
if (type === "audio" && !isAudioExtensionAllowed(file.name, plan)) {
|
} else if (type === "audio") {
|
||||||
const allowed = limits.allowedAudioExtensions.join(", ");
|
if (
|
||||||
throw new Error(`Your plan supports ${allowed} audio files only`);
|
!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);
|
const sessionKey = sanitizeUploadSessionKey(options?.sessionKey);
|
||||||
@@ -204,6 +482,24 @@ export async function saveUploadedFile(
|
|||||||
assertPathInUserUploads(userId, filePath);
|
assertPathInUserUploads(userId, filePath);
|
||||||
await writeUploadedFile(file, 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;
|
let audioTags = null;
|
||||||
if (type === "audio" && limits.id3TagSupport && file.name.toLowerCase().endsWith(".mp3")) {
|
if (type === "audio" && limits.id3TagSupport && file.name.toLowerCase().endsWith(".mp3")) {
|
||||||
audioTags = await readAudioTags(filePath);
|
audioTags = await readAudioTags(filePath);
|
||||||
|
|||||||
+417
@@ -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<LayoutSettings> | 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<LayoutTemplate, string> = {
|
||||||
|
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",
|
||||||
|
};
|
||||||
+35
-34
@@ -8,54 +8,55 @@ export type PlanLimits = {
|
|||||||
maxResolutionHeight: number;
|
maxResolutionHeight: number;
|
||||||
maxFileSizeBytes: number;
|
maxFileSizeBytes: number;
|
||||||
watermarkOptional: boolean;
|
watermarkOptional: boolean;
|
||||||
|
customWatermark: boolean;
|
||||||
|
perItemImages: boolean;
|
||||||
|
artTrackLayouts: boolean;
|
||||||
allowedAudioExtensions: readonly string[];
|
allowedAudioExtensions: readonly string[];
|
||||||
id3TagSupport: boolean;
|
id3TagSupport: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
/** Unlimited self-hosted limits — used for every plan in this OSS build. */
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SELFHOSTED_LIMITS: PlanLimits = {
|
export const SELFHOSTED_LIMITS: PlanLimits = {
|
||||||
monthlyQuota: 1_000_000,
|
monthlyQuota: 1_000_000,
|
||||||
maxBatchSize: 100,
|
maxBatchSize: 1_000,
|
||||||
maxResolutionHeight: Math.max(...RESOLUTIONS.map((r) => r.height)),
|
maxResolutionHeight: Math.max(...RESOLUTIONS.map((r) => r.height)),
|
||||||
maxFileSizeBytes: 500 * 1024 * 1024,
|
maxFileSizeBytes: 500 * 1024 * 1024,
|
||||||
watermarkOptional: true,
|
watermarkOptional: true,
|
||||||
|
customWatermark: true,
|
||||||
|
perItemImages: true,
|
||||||
|
artTrackLayouts: true,
|
||||||
allowedAudioExtensions: [".mp3", ".wav", ".flac"],
|
allowedAudioExtensions: [".mp3", ".wav", ".flac"],
|
||||||
id3TagSupport: true,
|
id3TagSupport: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SALES_EMAIL = "songs2yt@atakanozban.com";
|
/** Kept for type compatibility; OSS always returns SELFHOSTED_LIMITS. */
|
||||||
export const SUPPORT_EMAIL = "songs2yt@atakanozban.com";
|
export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
||||||
export const UPGRADE_URL = "/#pricing";
|
FREE: { ...SELFHOSTED_LIMITS },
|
||||||
export const GITEA_ISSUES_URL =
|
PREMIUM: { ...SELFHOSTED_LIMITS },
|
||||||
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";
|
|
||||||
|
|
||||||
export function getPlanLimits(plan: Plan): PlanLimits {
|
export const SALES_EMAIL = "songs2vid@atakanozban.com";
|
||||||
if (isSelfHostedEdition()) return SELFHOSTED_LIMITS;
|
export const SUPPORT_EMAIL = "songs2vid@atakanozban.com";
|
||||||
return PLAN_LIMITS[plan];
|
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) {
|
export function getResolutionsForPlan(plan: Plan) {
|
||||||
|
|||||||
@@ -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<typeof getPlanLimits>[0], bonusQuota: number) {
|
|
||||||
return getPlanLimits(plan).monthlyQuota + bonusQuota;
|
|
||||||
}
|
|
||||||
+56
-243
@@ -1,129 +1,48 @@
|
|||||||
import { Plan } from "@prisma/client";
|
import { Plan } from "@prisma/client";
|
||||||
import { prisma } from "./db";
|
import { prisma } from "./db";
|
||||||
import { isSelfHostedEdition } from "./edition";
|
|
||||||
import { getEffectiveQuotaLimit } from "./quota-extensions";
|
|
||||||
import { getNextMonthlyQuotaReset, getPlanLimits } from "./plans";
|
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 {
|
export function formatQuotaResetCountdown(_resetsAt: Date | string): string {
|
||||||
return new Date(from.getTime() + TWELVE_HOURS_MS);
|
return "never";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNextQuotaResetForPlan(plan: Plan, from: Date = new Date()): Date {
|
export function formatQuotaResetDisplay(_plan: Plan, _resetsAt: Date | string): string {
|
||||||
return plan === "FREE" ? getNextFreeQuotaReset(from) : getNextMonthlyQuotaReset(from);
|
return "never (self-hosted)";
|
||||||
}
|
|
||||||
|
|
||||||
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 async function ensureQuotaReset(userId: string) {
|
export async function ensureQuotaReset(userId: string) {
|
||||||
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
return 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getQuotaInfo(userId: string) {
|
export async function getQuotaInfo(userId: string) {
|
||||||
const user = await ensureQuotaReset(userId);
|
const user = await ensureQuotaReset(userId);
|
||||||
const limits = getPlanLimits(user.plan);
|
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 {
|
return {
|
||||||
used: user.videosUsed,
|
used: user.videosUsed,
|
||||||
limit,
|
limit: limits.monthlyQuota,
|
||||||
baseLimit: limits.monthlyQuota,
|
baseLimit: limits.monthlyQuota,
|
||||||
bonusQuota: isSelfHostedEdition() ? 0 : user.bonusQuota,
|
bonusQuota: 0,
|
||||||
remaining,
|
remaining: limits.monthlyQuota,
|
||||||
videoCredits,
|
videoCredits: 0,
|
||||||
creditBalanceMax: CREDIT_BALANCE_MAX,
|
extraCredits: 0,
|
||||||
totalAvailable: remaining + videoCredits,
|
creditBalanceMax: 0,
|
||||||
|
totalAvailable: limits.monthlyQuota,
|
||||||
resetsAt: user.quotaResetAt.toISOString(),
|
resetsAt: user.quotaResetAt.toISOString(),
|
||||||
resetsIn: formatQuotaResetDisplay(user.plan, user.quotaResetAt),
|
resetsIn: formatQuotaResetDisplay(user.plan, user.quotaResetAt),
|
||||||
plan: user.plan,
|
plan: user.plan,
|
||||||
maxBatchSize: limits.maxBatchSize,
|
maxBatchSize: limits.maxBatchSize,
|
||||||
watermarkOptional: limits.watermarkOptional,
|
watermarkOptional: limits.watermarkOptional,
|
||||||
|
customWatermark: limits.customWatermark,
|
||||||
|
perItemImages: limits.perItemImages,
|
||||||
|
artTrackLayouts: limits.artTrackLayouts,
|
||||||
maxResolutionHeight: limits.maxResolutionHeight,
|
maxResolutionHeight: limits.maxResolutionHeight,
|
||||||
selfHosted: isSelfHostedEdition(),
|
selfHosted: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,150 +51,55 @@ export type ReservationSplit = {
|
|||||||
fromCredits: number;
|
fromCredits: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** Always succeed (except empty). Credits are never used in OSS. */
|
||||||
* Free plan: use included quota first, then pay-as-you-go credits.
|
|
||||||
* Pro plan: subscription quota only (PAYG is a separate product).
|
|
||||||
*/
|
|
||||||
export function planReservation(
|
export function planReservation(
|
||||||
plan: Plan,
|
_plan: Plan,
|
||||||
remainingQuota: number,
|
_remainingQuota: number,
|
||||||
videoCredits: number,
|
_videoCredits: number,
|
||||||
count: number,
|
count: number,
|
||||||
): ReservationSplit | null {
|
): ReservationSplit | null {
|
||||||
if (count <= 0) return { fromQuota: 0, fromCredits: 0 };
|
if (count <= 0) return { fromQuota: 0, fromCredits: 0 };
|
||||||
|
return { fromQuota: count, 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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkQuota(userId: string, requestedCount: number) {
|
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);
|
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 },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Track usage for display; never deduct credits or block. */
|
||||||
* Atomically reserve plan quota first, then prepaid credits.
|
|
||||||
* Returns how many of each were taken (for per-item billingSource).
|
|
||||||
*/
|
|
||||||
export async function reserveQuota(userId: string, count: number): Promise<ReservationSplit> {
|
export async function reserveQuota(userId: string, count: number): Promise<ReservationSplit> {
|
||||||
if (count <= 0) return { fromQuota: 0, fromCredits: 0 };
|
if (count <= 0) return { fromQuota: 0, fromCredits: 0 };
|
||||||
|
|
||||||
if (isSelfHostedEdition()) {
|
const limits = getPlanLimits();
|
||||||
const limits = getPlanLimits("FREE");
|
if (count > limits.maxBatchSize) {
|
||||||
if (count > limits.maxBatchSize) {
|
throw new Error(`Batch limit exceeded. Max ${limits.maxBatchSize} files per batch.`);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureQuotaReset(userId);
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
return prisma.$transaction(async (tx) => {
|
data: { videosUsed: { increment: count } },
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
|
return { fromQuota: count, fromCredits: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function releaseQuota(userId: string, count: number) {
|
export async function releaseQuota(userId: string, count: number) {
|
||||||
if (count <= 0) return;
|
if (count <= 0) return;
|
||||||
await ensureQuotaReset(userId);
|
|
||||||
await prisma.$executeRaw`
|
await prisma.$executeRaw`
|
||||||
UPDATE "User"
|
UPDATE "User"
|
||||||
SET "videosUsed" = GREATEST(0, "videosUsed" - ${count})
|
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) {
|
/** No-op in OSS (no prepaid credits). */
|
||||||
if (count <= 0) return;
|
export async function releaseCredits(_userId: string, _count: number) {
|
||||||
await prisma.$executeRaw`
|
return;
|
||||||
UPDATE "User"
|
|
||||||
SET "videoCredits" = LEAST(${CREDIT_BALANCE_MAX}, "videoCredits" + ${count})
|
|
||||||
WHERE id = ${userId}
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Release one reserved unit based on how the job item was billed. */
|
|
||||||
export async function releaseReservation(
|
export async function releaseReservation(
|
||||||
userId: string,
|
userId: string,
|
||||||
billingSource: "QUOTA" | "CREDIT",
|
_billingSource: "QUOTA" | "CREDIT",
|
||||||
count = 1,
|
count = 1,
|
||||||
) {
|
) {
|
||||||
if (billingSource === "CREDIT") {
|
await releaseQuota(userId, count);
|
||||||
await releaseCredits(userId, count);
|
|
||||||
} else {
|
|
||||||
await releaseQuota(userId, count);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function releaseReservationSplit(userId: string, split: ReservationSplit) {
|
export async function releaseReservationSplit(userId: string, split: ReservationSplit) {
|
||||||
if (split.fromQuota > 0) await releaseQuota(userId, split.fromQuota);
|
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) {
|
export async function incrementQuota(userId: string, count: number) {
|
||||||
await reserveQuota(userId, count);
|
await reserveQuota(userId, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInitialQuotaResetAt(): Date {
|
export function getInitialQuotaResetAt(): Date {
|
||||||
return getNextFreeQuotaReset();
|
return getNextMonthlyQuotaReset();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Privacy } from "@prisma/client";
|
import { Privacy } from "@prisma/client";
|
||||||
|
import type { LayoutSettings } from "./layout";
|
||||||
|
import type { WatermarkSettings } from "./watermark";
|
||||||
|
|
||||||
export type ItemMetadata = {
|
export type ItemMetadata = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -11,9 +13,48 @@ export type ItemMetadata = {
|
|||||||
madeForKids: boolean;
|
madeForKids: boolean;
|
||||||
embeddable: boolean;
|
embeddable: boolean;
|
||||||
creativeCommons: boolean;
|
creativeCommons: boolean;
|
||||||
|
/** Legacy Free toggle default Songs2VID branding when watermark.mode is omitted. */
|
||||||
includeWatermark: boolean;
|
includeWatermark: boolean;
|
||||||
/** YouTube playlist ID (Pro only). Video is added after upload. */
|
/** YouTube playlist ID (Pro only). Video is added after upload. */
|
||||||
playlistId?: string | null;
|
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<WatermarkSettings> | 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<LayoutSettings> & {
|
||||||
|
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 = {
|
export type UploadedAudio = {
|
||||||
@@ -29,6 +70,7 @@ export type CreatePlaylistRequest = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type CreateJobPayload = {
|
export type CreateJobPayload = {
|
||||||
|
/** Shared cover for Free / default when items omit imagePath. */
|
||||||
imagePath: string;
|
imagePath: string;
|
||||||
items: Array<{
|
items: Array<{
|
||||||
audioPath: string;
|
audioPath: string;
|
||||||
|
|||||||
@@ -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<WatermarkSettings> | 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;
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import type { NextConfig } from "next";
|
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 = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
images: {
|
images: {
|
||||||
@@ -15,6 +20,15 @@ const nextConfig: NextConfig = {
|
|||||||
bodySizeLimit: "32mb",
|
bodySizeLimit: "32mb",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
async redirects() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/dashboard/api-docs",
|
||||||
|
destination: apiDocsUrl,
|
||||||
|
permanent: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Generated
+3
-22
@@ -19,8 +19,7 @@
|
|||||||
"next": "^15.1.3",
|
"next": "^15.1.3",
|
||||||
"next-auth": "^4.24.11",
|
"next-auth": "^4.24.11",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0"
|
||||||
"stripe": "^22.3.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
@@ -1887,9 +1886,8 @@
|
|||||||
"version": "22.20.1",
|
"version": "22.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
||||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -7690,23 +7688,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/strtok3": {
|
||||||
"version": "10.3.5",
|
"version": "10.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
|
||||||
@@ -8274,7 +8255,7 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unrs-resolver": {
|
"node_modules/unrs-resolver": {
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"name": "songs2yt",
|
"name": "songs2yt",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
+43
-34
@@ -57,36 +57,16 @@ model User {
|
|||||||
subscribedAt DateTime?
|
subscribedAt DateTime?
|
||||||
bonusQuota Int @default(0)
|
bonusQuota Int @default(0)
|
||||||
apiRateLimitBonus 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)
|
videoCredits Int @default(0)
|
||||||
apiKeyHash String? @unique
|
apiKeyHash String? @unique
|
||||||
apiKeyPrefix String?
|
apiKeyPrefix String?
|
||||||
youtubeConnection YouTubeConnection?
|
youtubeConnection YouTubeConnection?
|
||||||
jobs Job[]
|
jobs Job[]
|
||||||
quotaExtensionRequests QuotaExtensionRequest[]
|
quotaExtensionRequests QuotaExtensionRequest[]
|
||||||
creditPurchases CreditPurchase[]
|
|
||||||
createdAt DateTime @default(now())
|
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 {
|
enum JobItemBilling {
|
||||||
QUOTA
|
QUOTA
|
||||||
CREDIT
|
CREDIT
|
||||||
@@ -127,25 +107,54 @@ model Job {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model JobItem {
|
model JobItem {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
jobId String
|
jobId String
|
||||||
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
|
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
|
||||||
audioPath String
|
audioPath String
|
||||||
audioFilename String
|
audioFilename String
|
||||||
title String
|
title String
|
||||||
description String @default("")
|
description String @default("")
|
||||||
tags String @default("")
|
tags String @default("")
|
||||||
privacy Privacy @default(PUBLIC)
|
privacy Privacy @default(PUBLIC)
|
||||||
categoryId String @default("10")
|
categoryId String @default("10")
|
||||||
resolution String @default("1280x720")
|
resolution String @default("1280x720")
|
||||||
notifySubscribers Boolean @default(true)
|
notifySubscribers Boolean @default(true)
|
||||||
madeForKids Boolean @default(false)
|
madeForKids Boolean @default(false)
|
||||||
embeddable Boolean @default(true)
|
embeddable Boolean @default(true)
|
||||||
creativeCommons Boolean @default(false)
|
creativeCommons Boolean @default(false)
|
||||||
includeWatermark Boolean @default(true)
|
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?
|
playlistId String?
|
||||||
billingSource JobItemBilling @default(QUOTA)
|
billingSource JobItemBilling @default(QUOTA)
|
||||||
status JobItemStatus @default(PENDING)
|
status JobItemStatus @default(PENDING)
|
||||||
outputPath String?
|
outputPath String?
|
||||||
youtubeVideoId String?
|
youtubeVideoId String?
|
||||||
error String?
|
error String?
|
||||||
|
|||||||
@@ -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);
|
||||||
+42
-2
@@ -65,11 +65,51 @@ async function processJobItem(data: VideoJobData) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await encodeVideo({
|
await encodeVideo({
|
||||||
imagePath: item.job.imagePath,
|
imagePath: item.itemImagePath || item.job.imagePath,
|
||||||
audioPath: item.audioPath,
|
audioPath: item.audioPath,
|
||||||
outputPath,
|
outputPath,
|
||||||
resolution: item.resolution,
|
resolution: item.resolution,
|
||||||
includeWatermark: item.includeWatermark,
|
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({
|
await prisma.jobItem.update({
|
||||||
@@ -123,4 +163,4 @@ worker.on("failed", (job, err) => {
|
|||||||
console.error(`Job item ${job?.data.jobItemId} failed:`, err.message);
|
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...`);
|
||||||
|
|||||||
Reference in New Issue
Block a user