Compare commits

..
11 Commits
Author SHA1 Message Date
Atakan Doğan Özban b40127c2e8 Exclude integrations/n8n from Next/Docker builds so the app image typechecks cleanly. 2026-08-08 16:47:16 +02:00
Atakan Doğan Özban f9b2a997a2 Add n8n community node, /api/v1/render alias, and job webhooks for OSS automation.
Payment-free self-hosted builds keep full API access with optional webhookUrl callbacks and the published n8n-nodes-songs2vid package source under integrations/n8n.
2026-08-08 16:32:28 +02:00
Atakan Doğan Özban 848607f9e0 Add lower-corner layouts, custom blur backgrounds, and classic blur fill.
Ship composition families, optional background images for lower-corner templates, and an optional blurred cover fill for classic letterbox.
2026-08-07 00:51:37 +02:00
atakan 03634d5100 Update README.md 2026-08-03 06:01:10 +00:00
atakan 6bc98606eb Update README.md 2026-08-03 05:57:28 +00:00
atakan b68e9fd8e5 Update README.md 2026-08-03 05:55:34 +00:00
atakan 888f5fb66a Update README.md 2026-08-03 05:55:08 +00:00
Atakan Doğan Özban 54bbf8b574 Close OSS self-host gaps: in-repo API docs, legal notes, and packaging.
Add MIT license and docs/api, strip SaaS status/admin remnants from robots and footer, align env/compose/README with payment-free product truth.
2026-08-03 07:36:08 +02:00
Atakan Doğan Özban 1fcae33ca3 Document payment-free OSS self-host quick start and Docker Hub overview. 2026-08-03 07:03:17 +02:00
Atakan Doğan Özban 6476bb42a4 Strip Songs2VID to a payment-free self-hosted OSS core.
Remove Stripe/billing/pricing/marketing, simplify schema and entitlements for unlimited self-host use, and keep auth, encode, and YouTube upload.
2026-08-03 06:52:00 +02:00
Songs2VID Support 506d56fa92 Initial OSS scaffold from Songs2VID (pre-strip) 2026-08-03 06:15:05 +02:00
135 changed files with 10908 additions and 3879 deletions
+1
View File
@@ -10,3 +10,4 @@ uploads
deploy-*.tgz
scripts
bg-video
integrations
+16 -9
View File
@@ -1,16 +1,23 @@
DATABASE_URL="postgresql://s2yt:s2yt@localhost:5432/s2yt"
REDIS_URL="redis://localhost:6379"
# Songs2VID OSS is always self-hosted and payment-free.
# Do not set Stripe, billing, credits, pricing, or S2VID_EDITION variables — they are unused.
# --- Required ---
DATABASE_URL="postgresql://songs2vid:songs2vid@localhost:5433/songs2vid"
REDIS_URL="redis://localhost:6380"
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="replace-with-a-long-random-secret"
GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
# --- Optional ---
# TOKEN_ENCRYPTION_KEY="replace-with-another-long-random-secret" # falls back to NEXTAUTH_SECRET
UPLOAD_DIR="./uploads"
# Self-hosted OSS edition (unlocked features, no Stripe / credits)
S2VID_EDITION="selfhosted"
# Optional legacy alias (also accepted by lib/edition.ts):
# S2YT_EDITION="selfhosted"
# Public docs (default: https://docs.songs2vid.com)
# NEXT_PUBLIC_DOCS_URL="https://docs.songs2vid.com"
# FFMPEG_PATH="C:/path/to/ffmpeg.exe" # override bundled / image ffmpeg
# S2VID_PORT=3000 # host port for docker-compose.yml (default 3000)
# S2VID_IMAGE_TAG=latest # Docker Hub tag when using published image
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_GITEA_URL="https://git.atakanozban.com/Songs2VID"
NEXT_PUBLIC_DOCKER_HUB_URL="https://hub.docker.com/r/atakanozban/songs2vid"
+2 -6
View File
@@ -3,12 +3,8 @@ node_modules/
.next/
.env
.env.local
*.tsbuildinfo
tsconfig.tsbuildinfo
website/node_modules/
website/build/
website/.docusaurus/
*.tgz
.DS_Store
basibozuk_cover.jpg
bg-video/
_restore/
tsconfig.tsbuildinfo
+50
View File
@@ -0,0 +1,50 @@
# Songs2VID
Payment-free, self-hosted software that turns cover art and audio into YouTube videos (FFmpeg + BullMQ). Full entitlements in every deployment — no plans, credits, Stripe, or paywall.
**Image:** `atakanozban/songs2vid:latest`
**License:** MIT
**Docs:** https://docs.songs2vid.com
**Source:** https://git.atakanozban.com/Songs2VID/songs2vid
**Hosted SaaS (separate):** https://songs2vid.com
## Quick start
1. Clone the repo (Compose + `.env.example`) from Gitea, or reuse the sample `docker-compose.yml`.
2. Copy `.env.example``.env` and set:
- `NEXTAUTH_SECRET`
- `GOOGLE_CLIENT_ID`
- `GOOGLE_CLIENT_SECRET`
3. Enable **YouTube Data API v3** in Google Cloud and add OAuth redirect:
- `http://localhost:3000/api/auth/callback/google` (or your public HTTPS origin)
4. Run:
```bash
docker pull atakanozban/songs2vid:latest
docker compose up -d
```
Open http://localhost:3000 → sign in with Google → dashboard.
No Stripe, billing, or edition environment variables are required. Watermarks are optional (no free-tier badge paywall).
## What you get
- Batch encode: cover art + audio → YouTube upload
- Classic and art-track layouts, blur, typography, watermarks
- Playlists, privacy, tags, resolutions
- REST API (Dashboard → Settings → API key)
- Always-on self-hosted entitlements
## Stack
Next.js 15 · PostgreSQL/Prisma · Redis/BullMQ · NextAuth (Google + YouTube) · FFmpeg
## Links
- Full documentation: https://docs.songs2vid.com
- Getting started: https://docs.songs2vid.com/docs/getting-started
- Environment variables: https://docs.songs2vid.com/docs/environment
- REST API: https://docs.songs2vid.com/docs/api/overview
- In-repo API notes: https://git.atakanozban.com/Songs2VID/songs2vid/src/branch/main/docs/api
+26 -20
View File
@@ -1,4 +1,5 @@
# syntax=docker/dockerfile:1
# Songs2VID OSS self-hosted image.
FROM node:22-bookworm-slim AS deps
WORKDIR /app
@@ -6,7 +7,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-cert
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
COPY prisma ./prisma
# Use npm install (not ci): lockfiles generated on Windows can omit Linux-only optional packages (@emnapi/*).
RUN npm install
FROM node:22-bookworm-slim AS builder
@@ -22,31 +22,37 @@ FROM node:22-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV S2VID_EDITION=selfhosted
ENV UPLOAD_DIR=/app/uploads
ENV FFMPEG_PATH=ffmpeg
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
ENV HOME=/home/nextjs
ENV npm_config_cache=/tmp/npm-cache
RUN apt-get update && apt-get install -y --no-install-recommends \
openssl ca-certificates ffmpeg \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system --gid 1001 nodejs \
&& useradd --system --uid 1001 --gid nodejs nextjs
&& useradd --system --uid 1001 --gid nodejs --create-home --home-dir /home/nextjs nextjs \
&& mkdir -p /app/uploads \
&& chown nextjs:nodejs /app /app/uploads
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/package-lock.json ./package-lock.json
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/assets ./assets
COPY --from=builder /app/worker ./worker
COPY --from=builder /app/lib ./lib
COPY --from=builder /app/tsconfig.json ./tsconfig.json
RUN mkdir -p /app/uploads \
&& chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --chown=nextjs:nodejs package.json package-lock.json ./
COPY --chown=nextjs:nodejs prisma ./prisma
RUN npm install --omit=dev \
&& npx prisma generate \
&& rm -rf /tmp/npm-cache
COPY --chown=nextjs:nodejs --from=builder /app/.next/standalone/server.js ./server.js
COPY --chown=nextjs:nodejs --from=builder /app/.next/standalone/.next ./.next
COPY --chown=nextjs:nodejs --from=builder /app/.next/static ./.next/static
COPY --chown=nextjs:nodejs --from=builder /app/public ./public
COPY --chown=nextjs:nodejs --from=builder /app/assets ./assets
COPY --chown=nextjs:nodejs --from=builder /app/worker ./worker
COPY --chown=nextjs:nodejs --from=builder /app/lib ./lib
COPY --chown=nextjs:nodejs --from=builder /app/tsconfig.json ./tsconfig.json
EXPOSE 3000
CMD ["node", "server.js"]
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 Songs2YT
Copyright (c) 2026 Atakan Doğan Özban
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+73 -61
View File
@@ -1,86 +1,98 @@
# 🎵 Songs2VID
# 🎬 Songs2VID
<p align="center">
<img src="https://img.shields.io/badge/Status-100%25%20Operational-brightgreen?style=for-the-badge" alt="Status">
<img src="https://img.shields.io/badge/Docker-Supported-blue?style=for-the-badge&logo=docker" alt="Docker">
<img src="https://img.shields.io/badge/Edition-Self--hosted-orange?style=for-the-badge" alt="Self-hosted">
<img src="https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge" alt="License">
</p>
Payment-free, self-hosted software that turns cover art and audio into YouTube videos
(FFmpeg encoding + BullMQ jobs). Every deployment has full entitlements — there are no
plans, credits, purchases, subscriptions, paywalls, or Stripe.
<p align="center">
<b>Turn your cover art + audio files into YouTube videos automatically.</b><br>
<i>Art-track layouts, blur backgrounds, typography, watermark studio, and per-track covers — unlocked for self-hosting.</i>
</p>
Sign in with Google, open the dashboard, and create videos. Settings cover account,
YouTube connection, and API keys only.
---
![Songs2VID Dashboard](https://www.atakanozban.com/Content/uploads/projects-media/f59f2b3c290042ceb8d12fe9395b0dda.png)
## What's the deal?
## 🚀 Quick start (Docker)
**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.
Published image: [`atakanozban/songs2vid:latest`](https://hub.docker.com/r/atakanozban/songs2vid)
This is the **open-source (self-hosted) edition**:
* **Zero practical limits:** no video quotas, credits, or Stripe paywalls
* **Fully unlocked:** playlists, REST API, art-track layouts, custom watermarks/fonts, per-track covers (`S2VID_EDITION=selfhosted`)
* **Your infra:** run it on your own server
> Hosted cloud product: [songs2vid.com](https://songs2vid.com) · Docs: [docs.songs2vid.com](https://docs.songs2vid.com) · API: [docs.songs2vid.com/docs/api/overview](https://docs.songs2vid.com/docs/api/overview)
![Songs2VID](https://www.atakanozban.com/Content/uploads/projects-media/f59f2b3c290042ceb8d12fe9395b0dda.png)
---
## Requirements
* **Docker & Docker Compose** (recommended)
* *Or:* Node 20+, Postgres, Redis, FFmpeg
* Google Cloud OAuth client with **YouTube Data API v3** enabled
---
## Quick Start (Docker)
1. Clone this repo (or copy `docker-compose.yml` + `.env.example`).
2. Copy `.env.example` to `.env` and set at least:
- `NEXTAUTH_SECRET`
- `GOOGLE_CLIENT_ID`
- `GOOGLE_CLIENT_SECRET`
3. In [Google Cloud Console](https://console.cloud.google.com/), enable **YouTube Data API v3**
and add an OAuth redirect URI:
- Local: `http://localhost:3000/api/auth/callback/google`
- Production: `https://YOUR_DOMAIN/api/auth/callback/google`
4. Start the stack (web + worker + Postgres + Redis):
```bash
cp .env.example .env
# Fill in GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, NEXTAUTH_SECRET, and NEXTAUTH_URL
docker compose up -d --build
docker compose up -d
```
* Open `http://localhost:3000`
* OAuth redirect URL: `{NEXTAUTH_URL}/api/auth/callback/google`
* `S2VID_EDITION=selfhosted` is set by compose (no Stripe keys required)
Compose uses the published image by default (`atakanozban/songs2vid:latest`) and can also
build from this repo (`docker compose up -d --build`).
## Local Development
Open http://localhost:3000 → sign in → dashboard.
No Stripe keys, billing env vars, or edition flags are required. Watermarks are optional;
OSS does not force a free-tier SaaS badge paywall.
## ✨ Features
- 📦 Batch: one cover (or per-track covers) + many audio files → YouTube uploads
- 🖼️ Classic letterbox and art-track layouts (blur backgrounds, typography, fine-tuning)
- 💧 Custom watermarks (badge, text, or logo) and curated or uploaded fonts
- ▶️ YouTube playlists, privacy, tags, resolution, categories
- 🔌 REST API for automation (keys under **Dashboard → Settings → API key**)
- 🤖 n8n community node [`n8n-nodes-songs2vid`](https://www.npmjs.com/package/n8n-nodes-songs2vid) + optional `webhookUrl` callbacks
- 🔓 Always-on self-hosted entitlements (no quota paywall)
## 📚 API documentation
- In-repo: [docs/api/overview.md](./docs/api/overview.md), [docs/api/endpoints.md](./docs/api/endpoints.md), [docs/n8n.md](./docs/n8n.md)
- Live site: [docs.songs2vid.com/docs/api/overview](https://docs.songs2vid.com/docs/api/overview)
- Machine-readable discovery: `GET /api/v1` (no auth; no billing routes)
- Node package source: [`integrations/n8n`](./integrations/n8n/)
## 🛠️ Local development
```bash
cp .env.example .env
docker compose -f docker-compose.dev.yml up -d
cp .env.example .env # then edit secrets / Google OAuth
npm install
npm run db:push
npm run dev # Terminal 1
npm run worker # Terminal 2
npm run db:migrate
npm run dev:all
```
Optional: fetch curated watermark fonts into `assets/fonts/` with `node scripts/fetch-watermark-fonts.mjs`.
- App: http://localhost:3000
- `dev:all` runs Next.js and the BullMQ worker
## Features
## ⚙️ Environment
* 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`)
See [`.env.example`](./.env.example) for the required and optional variables. Matching guide:
[docs.songs2vid.com/docs/environment](https://docs.songs2vid.com/docs/environment).
## Stack
## ⚖️ Legal (self-hosted)
* Next.js · Postgres · Redis / BullMQ · NextAuth (Google) · FFmpeg · YouTube Data API
In-app **Privacy** and **Terms** pages describe self-hosted responsibility: the operator of
each instance controls data and access; this software does not process payments. They are not
the paid cloud terms for [songs2vid.com](https://songs2vid.com).
## Links
## 🧱 Stack
* Docs: https://docs.songs2vid.com
* Hosted: https://songs2vid.com
* Gitea: https://git.atakanozban.com/Songs2VID/songs2vid
* Docker Hub: https://hub.docker.com/r/atakanozban/songs2vid
- Next.js 15, TypeScript, Tailwind
- PostgreSQL and Prisma
- Redis and BullMQ
- NextAuth with Google OAuth (YouTube scopes)
- FFmpeg
- YouTube Data API v3
## License
## 🔗 Links
MIT
- Docs: [docs.songs2vid.com](https://docs.songs2vid.com)
- Docker Hub: [atakanozban/songs2vid](https://hub.docker.com/r/atakanozban/songs2vid)
- Source: [git.atakanozban.com/Songs2VID](https://git.atakanozban.com/Songs2VID)
- Hosted SaaS (separate product): [songs2vid.com](https://songs2vid.com)
## 📄 License
[MIT](./LICENSE)
-10
View File
@@ -1,6 +1,5 @@
import { NextResponse } from "next/server";
import { createUserApiKey, getUserApiKeyStatus, revokeUserApiKey } from "@/lib/api-keys";
import { hasProFeatures } from "@/lib/edition";
import { getSessionUser } from "@/lib/session";
async function requireApiKeyAccess() {
@@ -8,15 +7,6 @@ async function requireApiKeyAccess() {
if (!user) {
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null };
}
if (!hasProFeatures(user.plan)) {
return {
error: NextResponse.json(
{ error: "API key access is not available" },
{ status: 403 },
),
user: null,
};
}
return { error: null, user };
}
-8
View File
@@ -1,6 +1,5 @@
import { NextResponse } from "next/server";
import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
import { hasProFeatures } from "@/lib/edition";
import { getSessionUser } from "@/lib/session";
export async function GET() {
@@ -8,13 +7,6 @@ export async function GET() {
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!hasProFeatures(user.plan)) {
return NextResponse.json(
{ error: "API rate limits are available on the Pro plan only" },
{ status: 403 },
);
}
const status = await getApiRateLimitStatus(user.id);
return NextResponse.json(status);
}
+17
View File
@@ -0,0 +1,17 @@
import fs from "fs/promises";
import { NextResponse } from "next/server";
import { getWatermarkPath } from "@/lib/storage";
export async function GET() {
try {
const buf = await fs.readFile(getWatermarkPath());
return new NextResponse(buf, {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=86400, immutable",
},
});
} catch {
return NextResponse.json({ error: "Watermark asset not found" }, { status: 404 });
}
}
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import { CURATED_FONTS, isCuratedFontKey, SYSTEM_FONT } from "@/lib/fonts";
import { resolveCuratedFontPath, resolveSystemFontPath } from "@/lib/fonts-server";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ key: string }> },
) {
const { key } = await params;
const fontPath =
key === SYSTEM_FONT.key
? resolveSystemFontPath()
: isCuratedFontKey(key)
? resolveCuratedFontPath(key)
: null;
if (!fontPath) {
return NextResponse.json({ error: "Unknown font" }, { status: 404 });
}
try {
const buf = await fs.readFile(fontPath);
const fileName =
key === SYSTEM_FONT.key
? SYSTEM_FONT.file
: CURATED_FONTS.find((f) => f.key === key)!.file;
return new NextResponse(buf, {
headers: {
"Content-Type": "font/ttf",
"Content-Disposition": `inline; filename="${fileName}"`,
"Cache-Control": "public, max-age=86400, immutable",
},
});
} catch {
return NextResponse.json({ error: "Font file not found" }, { status: 404 });
}
}
+1 -2
View File
@@ -17,8 +17,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ jobId: job.id });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create job";
const status = message.includes("Quota exceeded") ? 403 : 400;
return NextResponse.json({ error: message }, { status });
return NextResponse.json({ error: message }, { status: 400 });
}
}
+1 -5
View File
@@ -1,5 +1,4 @@
import { NextRequest, NextResponse } from "next/server";
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job";
import { getSessionUser } from "@/lib/session";
@@ -28,14 +27,11 @@ export async function POST(req: NextRequest) {
}
try {
const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan, {
const result = await saveUploadedFile(user.id, file, type as UploadFileType, {
sessionKey,
});
return NextResponse.json(result);
} catch (err) {
if (err instanceof PremiumRequiredError) {
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
}
const message = err instanceof Error ? err.message : "Upload failed";
return NextResponse.json({ error: message }, { status: 400 });
}
+2 -2
View File
@@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth";
import { requireApiUser } from "@/lib/api-auth";
import { prisma } from "@/lib/db";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { error, user } = await requirePaidApiUser(_req);
const { error, user } = await requireApiUser(_req);
if (error || !user) return error!;
const { id } = await params;
+9 -9
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { Privacy } from "@prisma/client";
import { requirePaidApiUser } from "@/lib/api-auth";
import { requireApiUser } from "@/lib/api-auth";
import { createVideoJob, saveUploadedFile } from "@/lib/jobs/create-job";
import {
applyCreatePlaylistToItems,
@@ -22,6 +22,7 @@ type BatchItemInput = Partial<ItemMetadata> & {
function defaultMetadata(title: string): ItemMetadata {
return {
title,
songTitle: null,
description: "",
tags: "",
privacy: "PUBLIC",
@@ -33,12 +34,13 @@ function defaultMetadata(title: string): ItemMetadata {
creativeCommons: false,
includeWatermark: false,
playlistId: null,
artist: null,
};
}
export async function POST(req: NextRequest) {
try {
const { error, user } = await requirePaidApiUser(req);
const { error, user } = await requireApiUser(req);
if (error || !user) return error!;
const formData = await req.formData();
@@ -53,10 +55,10 @@ export async function POST(req: NextRequest) {
);
}
const limits = getPlanLimits(user.plan);
const limits = getPlanLimits();
if (audioFiles.length > limits.maxBatchSize) {
return NextResponse.json(
{ error: `Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.` },
{ error: `Batch limit exceeded. Max ${limits.maxBatchSize} files per batch.` },
{ status: 400 },
);
}
@@ -95,10 +97,10 @@ export async function POST(req: NextRequest) {
}
const sessionKey = Date.now().toString();
const imageUpload = await saveUploadedFile(user.id, image, "image", user.plan, { sessionKey });
const imageUpload = await saveUploadedFile(user.id, image, "image", { sessionKey });
const uploads = await mapWithConcurrency(audioFiles, 4, (audio) =>
saveUploadedFile(user.id, audio, "audio", user.plan, { sessionKey }),
saveUploadedFile(user.id, audio, "audio", { sessionKey }),
);
const builtItems: CreateJobPayload["items"] = uploads.map((upload, i) => {
@@ -140,9 +142,7 @@ export async function POST(req: NextRequest) {
} catch (err) {
console.error("Batch upload failed:", err);
const message = err instanceof Error ? err.message : "Batch upload failed";
const status = message.includes("Quota exceeded") || message.includes("Batch limit exceeded")
? 403
: 500;
const status = message.includes("Batch limit exceeded") ? 400 : 500;
return NextResponse.json({ error: message }, { status });
}
}
+7 -5
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth";
import { requireApiUser } from "@/lib/api-auth";
import { createVideoJob } from "@/lib/jobs/create-job";
import {
applyCreatePlaylistToItems,
@@ -9,7 +9,7 @@ import { prisma } from "@/lib/db";
import type { CreateJobPayload } from "@/lib/types";
export async function POST(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req);
const { error, user } = await requireApiUser(req);
if (error || !user) return error!;
const body = (await req.json()) as CreateJobPayload & { createPlaylist?: unknown };
@@ -35,25 +35,27 @@ export async function POST(req: NextRequest) {
const job = await createVideoJob(user, {
imagePath: body.imagePath,
items,
webhookUrl: body.webhookUrl ?? null,
});
return NextResponse.json({
jobId: job.id,
itemCount: job.items.length,
status: job.status,
statusUrl: `/api/v1/jobs/${job.id}`,
webhookUrl: body.webhookUrl ?? null,
playlist: playlist
? { id: playlist.id, title: playlist.title, privacy: playlist.privacy }
: null,
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create job";
const status = message.includes("Quota exceeded") ? 403 : 400;
return NextResponse.json({ error: message }, { status });
return NextResponse.json({ error: message }, { status: 400 });
}
}
export async function GET(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req);
const { error, user } = await requireApiUser(req);
if (error || !user) return error!;
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100);
+3 -3
View File
@@ -1,10 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth";
import { requireApiUser } from "@/lib/api-auth";
import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist";
import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload";
export async function GET(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req);
const { error, user } = await requireApiUser(req);
if (error || !user) return error!;
try {
@@ -18,7 +18,7 @@ export async function GET(req: NextRequest) {
}
export async function POST(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req);
const { error, user } = await requireApiUser(req);
if (error || !user) return error!;
try {
+5
View File
@@ -0,0 +1,5 @@
/**
* Alias for n8n / automation: POST|GET /api/v1/render → same as /api/v1/jobs
* OSS: no plan gate — Bearer API key + YouTube connected.
*/
export { POST, GET } from "../jobs/route";
+42 -9
View File
@@ -1,4 +1,9 @@
import { NextResponse } from "next/server";
import {
COMPOSITION_FAMILIES,
LAYOUT_TEMPLATE_LABELS,
LAYOUT_TEMPLATES,
} from "@/lib/layout";
import { API_DOCS_URL } from "@/lib/plans";
export async function GET() {
@@ -6,26 +11,54 @@ export async function GET() {
name: "Songs2VID API",
version: "1.0",
authentication: "Authorization: Bearer <api_key>",
requirements: ["Self-hosted edition", "YouTube account connected"],
rateLimit: "Generous self-hosted limits (see docs)",
requirements: ["YouTube account connected"],
rateLimit: "100,000 requests per minute per account",
billing: false,
notes:
"OSS self-hosted: full entitlements, no Stripe/credits/paywall. In-repo docs: docs/api/",
webhooks:
"Optional webhookUrl on job create — HTTPS POST JSON on item/job terminal states for n8n",
guidance: {
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/render (or /api/v1/jobs) with paths + optional webhookUrl",
batch:
"POST /api/v1/jobs/batch is for small packs only. Large multipart bodies may fail with 'failed to parse body as FormData'",
polling: "GET /api/v1/jobs/:id until status is COMPLETED, FAILED, or PARTIAL",
},
layoutTemplates: LAYOUT_TEMPLATES.map((id) => ({
id,
label: LAYOUT_TEMPLATE_LABELS[id],
})),
compositionFamilies: COMPOSITION_FAMILIES.map((family) => ({
id: family.id,
label: family.label,
defaultTemplate: family.defaultTemplate,
variants: family.variants.map((id) => ({
id,
label: LAYOUT_TEMPLATE_LABELS[id],
})),
})),
endpoints: [
{
method: "POST",
path: "/api/v1/upload",
description: "Upload a single image or audio file (use for two-step flow)",
body: "multipart/form-data: file, type (image|audio)",
description:
"Upload image, audio, PNG logo, or .ttf/.otf font (font ≤10MB)",
body: "multipart/form-data: file, type (image|audio|logo|font)",
},
{
method: "POST",
path: "/api/v1/render",
description:
"Alias of /api/v1/jobs — create a render job (n8n-friendly). Optional webhookUrl for callbacks.",
body: "application/json: { imagePath, webhookUrl?, items[{ audioPath, audioFilename, metadata }] }",
},
{
method: "POST",
path: "/api/v1/jobs",
description: "Create a video job from uploaded file paths (recommended for large batches)",
body: "application/json: { imagePath, items[] }",
description:
"Create a video job from uploaded file paths with layouts, watermark, and per-item covers",
body: "application/json: { imagePath, webhookUrl?, items[{ audioPath, audioFilename, metadata }] }",
},
{
method: "POST",
@@ -48,13 +81,13 @@ export async function GET() {
{
method: "GET",
path: "/api/v1/jobs",
description: "List recent jobs",
description: "List recent jobs (also available as GET /api/v1/render)",
query: "limit (default 20, max 100)",
},
{
method: "GET",
path: "/api/v1/jobs/:id",
description: "Get job status and item details",
description: "Get job status and item details (poll for n8n)",
},
],
docs: API_DOCS_URL,
+3 -7
View File
@@ -1,12 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth";
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
import { requireApiUser } from "@/lib/api-auth";
import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job";
export const maxDuration = 120;
export async function POST(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req);
const { error, user } = await requireApiUser(req);
if (error || !user) return error!;
const formData = await req.formData();
@@ -25,12 +24,9 @@ export async function POST(req: NextRequest) {
}
try {
const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan);
const result = await saveUploadedFile(user.id, file, type as UploadFileType);
return NextResponse.json(result);
} catch (err) {
if (err instanceof PremiumRequiredError) {
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
}
const message = err instanceof Error ? err.message : "Upload failed";
return NextResponse.json({ error: message }, { status: 400 });
}
-10
View File
@@ -1,5 +1,4 @@
import { NextRequest, NextResponse } from "next/server";
import { hasProFeatures } from "@/lib/edition";
import { getSessionUser } from "@/lib/session";
import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist";
import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload";
@@ -9,15 +8,6 @@ async function requirePremiumYouTubeUser() {
if (!user) {
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null };
}
if (!hasProFeatures(user.plan)) {
return {
error: NextResponse.json(
{ error: "Playlist features are available on the Pro plan only" },
{ status: 403 },
),
user: null,
};
}
if (!user.youtubeConnection) {
return {
error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }),
+34 -63
View File
@@ -8,7 +8,6 @@ import { DashboardShell } from "@/components/DashboardShell";
import { getUserApiKeyStatus } from "@/lib/api-keys";
import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
import { ApiKeySettings } from "@/components/ApiKeySettings";
import { getQuotaInfo } from "@/lib/quota";
function InfoRow({ label, value }: { label: string; value: string }) {
return (
@@ -29,9 +28,10 @@ export default async function SettingsPage() {
});
if (!user) redirect("/");
const quota = await getQuotaInfo(user.id);
const apiKeyStatus = await getUserApiKeyStatus(user.id);
const apiRateLimit = await getApiRateLimitStatus(user.id);
const [apiKeyStatus, apiRateLimit] = await Promise.all([
getUserApiKeyStatus(user.id),
getApiRateLimitStatus(user.id),
]);
const youtube = user.youtubeConnection;
const channelUrl = youtube ? `https://www.youtube.com/channel/${youtube.channelId}` : null;
@@ -39,80 +39,51 @@ export default async function SettingsPage() {
<DashboardShell channelTitle={youtube?.channelTitle}>
<div className="mx-auto max-w-4xl px-6 py-8">
<h1 className="mb-6 text-2xl font-bold text-white">Settings</h1>
<div className="space-y-6">
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
<h2 className="mb-4 text-lg font-semibold text-white">Account information</h2>
<h2 className="mb-4 text-lg font-semibold text-white">Account</h2>
<dl className="space-y-3 text-sm">
<InfoRow label="Name" value={user.name || "Not set"} />
<InfoRow label="Email" value={user.email} />
</dl>
<div className="mt-6 border-t border-gray-800 pt-6">
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-500">
YouTube
</h3>
{youtube ? (
<dl className="space-y-3 text-sm">
<InfoRow label="Channel name" value={youtube.channelTitle} />
<InfoRow label="Channel ID" value={youtube.channelId} />
</dl>
) : (
<p className="text-sm text-red-400">YouTube account not connected.</p>
)}
{channelUrl && (
<a
href={channelUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-4 inline-flex rounded bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-hover"
>
Go to your channel
</a>
)}
<p className="mt-4 text-sm text-gray-400">
To refresh your YouTube permissions,{" "}
<Link href="/" className="text-accent hover:underline">
sign out and sign in again
</Link>
.
</p>
</div>
</section>
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
<h2 className="mb-4 text-lg font-semibold text-white">Usage</h2>
<div className="mb-6 rounded border border-gray-800 bg-surface p-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Videos processed
</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>
<dl className="space-y-3 text-sm">
<InfoRow label="Edition" value="Self-hosted" />
<InfoRow label="Max batch size" value={`${quota.maxBatchSize} videos`} />
<InfoRow label="Max resolution" value={`${quota.maxResolutionHeight}p`} />
<InfoRow label="Videos created" value={String(user.createdVideoCount)} />
</dl>
</section>
<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">YouTube</h2>
{youtube ? (
<dl className="space-y-3 text-sm">
<InfoRow label="Channel name" value={youtube.channelTitle} />
<InfoRow label="Channel ID" value={youtube.channelId} />
</dl>
) : (
<p className="text-sm text-red-400">YouTube account not connected.</p>
)}
{channelUrl && (
<a
href={channelUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-4 inline-flex rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
>
Go to your channel
</a>
)}
<p className="mt-4 text-sm text-gray-400">
To refresh YouTube permissions,{" "}
<Link href="/" className="text-accent hover:underline">sign out and sign in again</Link>.
</p>
</section>
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
<h2 className="mb-4 text-lg font-semibold text-white">API key</h2>
<ApiKeySettings initialStatus={apiKeyStatus} initialRateLimit={apiRateLimit} />
</section>
<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 &amp; data</h2>
<p className="mb-4 text-sm text-gray-400">
Request a copy of your data or permanently delete your account and associated uploads.
Request a copy of your data or permanently delete your account and uploads.
</p>
<AccountPrivacyActions email={user.email} />
</section>
+71
View File
@@ -116,6 +116,68 @@
.mobile-sidebar-link-danger:hover::before {
@apply bg-red-400;
}
.badge-marquee {
@apply relative w-full overflow-hidden;
mask-image: linear-gradient(
to right,
transparent,
black 8%,
black 92%,
transparent
);
}
.badge-marquee-track {
@apply flex w-max items-center gap-10;
animation: badge-marquee-scroll var(--badge-marquee-duration, 28s) linear infinite;
}
.badge-marquee:hover .badge-marquee-track {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
.badge-marquee-track {
animation: none;
flex-wrap: wrap;
justify-content: center;
width: 100%;
}
.badge-marquee-item[aria-hidden="true"] {
display: none;
}
}
.badge-marquee-item {
@apply flex shrink-0 items-center justify-center;
}
.badge-marquee-slot {
@apply flex h-14 max-h-14 items-center justify-center;
}
.badge-marquee-slot a,
.badge-marquee-link {
@apply m-0 inline-flex items-center p-0 leading-none no-underline;
}
.badge-marquee-slot img,
.badge-marquee-slot iframe,
.badge-marquee-slot svg,
.badge-marquee-slot object {
display: block !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
max-height: 3.5rem !important;
width: auto !important;
height: auto !important;
max-width: min(280px, 70vw) !important;
object-fit: contain;
vertical-align: middle;
}
}
@keyframes mockup-float {
@@ -246,3 +308,12 @@
transform: scale(0.6) rotate(90deg);
}
}
@keyframes badge-marquee-scroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 374 KiB

After

Width:  |  Height:  |  Size: 14 KiB

+13 -5
View File
@@ -5,10 +5,18 @@ import { Providers } from "./providers";
const inter = Inter({ subsets: ["latin"] });
const SITE_NAME = "Songs2VID";
const DEFAULT_TITLE = "Songs2VID";
const DEFAULT_DESCRIPTION =
"Payment-free self-hosted audio-to-video creation and YouTube uploading.";
export const metadata: Metadata = {
title: "Songs2YT",
description:
"Songs2YT is an automation tool that converts your audio and image files into high-quality videos and uploads them directly to YouTube.",
title: DEFAULT_TITLE,
description: DEFAULT_DESCRIPTION,
applicationName: SITE_NAME,
icons: {
icon: "/favicon.png",
},
};
export default function RootLayout({
@@ -17,8 +25,8 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
<html lang="en" suppressHydrationWarning>
<body className={inter.className} suppressHydrationWarning>
<Providers>{children}</Providers>
</body>
</html>
+15 -94
View File
@@ -1,106 +1,27 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { BenefitsSection } from "@/components/BenefitsSection";
import { DownloadSection } from "@/components/DownloadSection";
import { LandingNavbar } from "@/components/LandingNavbar";
import { Logo } from "@/components/Logo";
import { SignInButton } from "@/components/SignInButton";
import { StepsSection } from "@/components/StepsSection";
import { Footer } from "@/components/Footer";
import { SupportSection } from "@/components/SupportSection";
import { DOCS_URL, WEBSITE_URL } from "@/lib/plans";
export default async function HomePage() {
const session = await getServerSession(authOptions);
if (session) redirect("/dashboard");
return (
<main className="min-h-screen">
<section className="relative min-h-dvh overflow-hidden">
<video
autoPlay
muted
loop
playsInline
className="absolute inset-0 h-full w-full object-cover"
aria-hidden="true"
>
<source src="/bg-video.mp4" type="video/mp4" />
</video>
<div className="absolute inset-0 bg-black/55" aria-hidden="true" />
<div
className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/40 to-black/80"
aria-hidden="true"
/>
<LandingNavbar />
<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">
Songs2VID
</h1>
<p className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-gray-200 sm:text-lg md:text-xl">
Self-hosted automation that turns your audio and cover art into YouTube videos
including art-track layouts, typography, and watermark studio. No quotas or paywalls.
</p>
<div className="mt-10 flex flex-col items-center gap-3">
{session ? (
<Link
href="/dashboard"
className="inline-flex items-center gap-2 rounded bg-accent px-8 py-3.5 font-medium text-white transition-all duration-300 hover:scale-[1.02] hover:bg-accent-hover hover:shadow-lg hover:shadow-accent/20"
>
Go to Dashboard
</Link>
) : (
<>
<SignInButton large />
<p className="max-w-md text-xs leading-relaxed text-gray-400">
By clicking Continue with Google, you accept our{" "}
<Link
href="/terms"
className="text-gray-300 underline-offset-2 transition-colors hover:text-white hover:underline"
>
Terms of Service
</Link>{" "}
and{" "}
<Link
href="/privacy"
className="text-gray-300 underline-offset-2 transition-colors hover:text-white hover:underline"
>
Privacy Policy
</Link>
.
</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>
<main className="flex min-h-dvh items-center justify-center bg-surface px-6">
<div className="w-full max-w-sm rounded-xl border border-gray-700 bg-surface-light p-8 text-center shadow-xl">
<div className="mb-6 flex justify-center">
<Logo size="lg" />
</div>
</section>
<StepsSection />
<BenefitsSection />
<DownloadSection />
<SupportSection />
<Footer />
<SignInButton large />
<p className="mt-5 text-xs leading-relaxed text-gray-500">
By continuing, you accept the{" "}
<Link href="/terms" className="hover:text-gray-300">Terms</Link> and{" "}
<Link href="/privacy" className="hover:text-gray-300">Privacy Policy</Link>.
</p>
</div>
</main>
);
}
}
+52 -165
View File
@@ -1,189 +1,76 @@
import type { Metadata } from "next";
import { LegalPageLayout } from "@/components/LegalPageLayout";
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
export const metadata: Metadata = {
title: "Privacy Policy | Songs2YT",
description: "How Songs2YT collects, uses, and protects your personal data.",
};
import { DOCS_URL, GITEA_URL } from "@/lib/plans";
export default function PrivacyPage() {
return (
<LegalPageLayout
title="Privacy Policy"
description="How we handle your personal data when you use Songs2YT."
>
<h2>1. Overview</h2>
<LegalPageLayout title="Privacy Policy">
<p>
This Privacy Policy explains how {LEGAL_OPERATOR.name} (&quot;we&quot;, &quot;us&quot;)
processes personal data when you use our website, hosted cloud service, and related features
that convert audio and images into videos for upload to YouTube.
</p>
<p>
We process personal data in accordance with applicable data protection laws, including the
General Data Protection Regulation (GDPR) where it applies.
This policy describes how a <strong>self-hosted Songs2VID</strong> instance typically
handles data. Songs2VID OSS does not include payment processing, plans, credits, or
Stripe. The person or organization that operates this deployment (the &quot;operator&quot;)
controls the server, database, uploads, logs, and Google OAuth configuration, and is
responsible for privacy compliance for their users.
</p>
<h2>2. Data controller</h2>
<h2>1. Who is responsible</h2>
<p>
{LEGAL_OPERATOR.legalName}
<br />
{LEGAL_OPERATOR.address}
<br />
{LEGAL_OPERATOR.city}
<br />
Email: <a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>
For this software package as published by {LEGAL_OPERATOR.legalName}, contact:{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>. For data on{" "}
<em>this running instance</em>, contact the operator of the deployment you signed into
not necessarily the hosted SaaS at songs2vid.com, which is a separate product with its own
policies.
</p>
<h2>3. What data we collect</h2>
<h3>3.1 Account and authentication data</h3>
<p>When you sign in with Google, we receive and store:</p>
<h2>2. Data this software processes</h2>
<ul>
<li>Your name, email address, and profile image (from Google)</li>
<li>OAuth tokens required to authenticate your session</li>
<li>YouTube connection data, including channel ID and title</li>
<li>Encrypted YouTube API access and refresh tokens needed to upload videos on your behalf</li>
<li>Account profile from Google sign-in (email, name, avatar)</li>
<li>YouTube OAuth tokens (encrypted at rest when configured) and channel metadata</li>
<li>Uploaded cover art, audio, optional logos/fonts, and derived video files</li>
<li>Job metadata (titles, tags, privacy, layouts, watermarks)</li>
<li>API key hashes and rate-limit counters</li>
</ul>
<h3>3.2 Uploaded content</h3>
<p>When you use the service, we temporarily process:</p>
<ul>
<li>Image and audio files you upload</li>
<li>Generated video files</li>
<li>Per-video metadata you provide (title, description, tags, privacy settings, etc.)</li>
</ul>
<h3>3.3 Usage and technical data</h3>
<ul>
<li>Plan type, quota usage, and job processing status</li>
<li>IP address, browser type, device information, and request logs</li>
<li>Error reports and operational diagnostics</li>
</ul>
<h3>3.4 Payment data</h3>
<h2>3. Google and YouTube</h2>
<p>
If you purchase a paid plan, payment processing is handled by our payment provider. We do
not store full payment card details on our servers. We may receive billing status,
subscription identifiers, and transaction references.
Google and YouTube process account and upload data under their own terms and policies.
Operators must configure OAuth correctly and respect{" "}
<a
href="https://developers.google.com/terms/api-services-user-data-policy"
target="_blank"
rel="noopener noreferrer"
>
Google API Services User Data Policy
</a>{" "}
and{" "}
<a
href="https://developers.google.com/youtube/terms/developer-policies"
target="_blank"
rel="noopener noreferrer"
>
YouTube API Services Policies
</a>
.
</p>
<h2>4. Why we process your data</h2>
<ul>
<li>
<strong>Contract performance:</strong> to provide video encoding, metadata handling, and
YouTube upload features you request
</li>
<li>
<strong>Legitimate interests:</strong> to secure our service, prevent abuse, improve
reliability, and enforce our terms
</li>
<li>
<strong>Legal obligations:</strong> where required by tax, accounting, or regulatory law
</li>
<li>
<strong>Consent:</strong> where you have given explicit consent, such as optional
marketing communications if offered
</li>
</ul>
<h2>5. Third-party services</h2>
<p>We use trusted third parties to operate Songs2YT, including:</p>
<ul>
<li>
<strong>Google / YouTube:</strong> authentication and video uploads via Google OAuth and
the YouTube Data API
</li>
<li>
<strong>Hosting and infrastructure providers:</strong> servers, databases, queues, and
storage
</li>
<li>
<strong>Payment processors:</strong> for Pro and Enterprise billing when available
</li>
</ul>
<h2>4. Your choices</h2>
<p>
These providers process data only as necessary to deliver their services and under
appropriate contractual safeguards where required.
Use <strong>Dashboard Settings</strong> to export or delete your account data on this
instance, or ask the operator. You can also revoke Google access in your{" "}
<a
href="https://security.google.com/settings/security/permissions"
target="_blank"
rel="noopener noreferrer"
>
Google Account permissions
</a>
.
</p>
<h2>Google User Data Sharing and Disclosure</h2>
<h2>5. Documentation</h2>
<p>
We do not sell, share, transfer, or disclose any Google user data to any third parties.
</p>
<p>
All user data retrieved via Google OAuth APIs is used solely and strictly for the core
functionality of the application (uploading user-generated media) and is never distributed,
transferred, or disclosed to external services, partners, or third parties.
</p>
<h2>6. Data retention</h2>
<ul>
<li>Uploaded source files and generated outputs are retained only as long as needed to complete your jobs</li>
<li>Account data is kept while your account remains active</li>
<li>Billing records may be retained as required by law</li>
<li>Logs are retained for a limited period for security and troubleshooting</li>
</ul>
<p>You may request deletion of your account data subject to legal retention obligations.</p>
<h2>7. Self-hosted deployments</h2>
<p>
If you deploy Songs2YT on your own infrastructure, you are the data controller for data
processed on your instance. This Privacy Policy applies to the hosted cloud service
operated by us, not to independent self-hosted installations unless we provide managed
hosting for you under contract.
</p>
<h2>8. Your rights</h2>
<p>Depending on your location, you may have the right to:</p>
<ul>
<li>Access the personal data we hold about you</li>
<li>Request correction or deletion</li>
<li>Restrict or object to certain processing</li>
<li>Data portability</li>
<li>Withdraw consent where processing is consent-based</li>
<li>Lodge a complaint with a supervisory authority</li>
</ul>
<p>
To exercise these rights, contact{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>.
</p>
<h2>9. Cookies and local storage</h2>
<p>
We use essential cookies and similar technologies for authentication, session management,
and security. We do not use non-essential tracking cookies unless disclosed separately and
enabled with your consent where required.
</p>
<h2>10. Security</h2>
<p>
We implement appropriate technical and organizational measures to protect your data,
including encryption in transit, access controls, and isolated processing environments.
No method of transmission or storage is 100% secure.
</p>
<h2>11. International transfers</h2>
<p>
If data is transferred outside your country, we ensure appropriate safeguards such as
standard contractual clauses or equivalent mechanisms where required by law.
</p>
<h2>12. Children</h2>
<p>
Songs2YT is not directed at children under 16. We do not knowingly collect personal data
from children. If you believe a child has provided us data, please contact us.
</p>
<h2>13. Changes to this policy</h2>
<p>
We may update this Privacy Policy from time to time. Material changes will be posted on
this page with an updated effective date.
</p>
<h2>14. Contact</h2>
<p>
Questions about this Privacy Policy:{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>
Product docs: <a href={DOCS_URL}>{DOCS_URL}</a>. Source:{" "}
<a href={GITEA_URL}>{GITEA_URL}</a>.
</p>
</LegalPageLayout>
);
-106
View File
@@ -1,106 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { LegalPageLayout } from "@/components/LegalPageLayout";
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
export const metadata: Metadata = {
title: "Refund Policy | Songs2YT",
description: "Refund and cancellation policy for Songs2YT paid plans.",
};
export default function RefundPage() {
return (
<LegalPageLayout
title="Refund Policy"
description="Our policy on refunds, cancellations, and billing for paid plans."
>
<h2>1. Overview</h2>
<p>
This Refund Policy explains how refunds and cancellations work for paid Songs2YT plans. The
free Bedroom Producer plan does not involve payments and is not subject to refunds.
</p>
<h2>2. Free plan</h2>
<p>
The free plan is provided at no charge. No payment information is required and no refunds
apply.
</p>
<h2>3. Pro subscriptions</h2>
<h3>3.1 Billing cycle</h3>
<p>
Pro plans are billed on a recurring monthly basis unless otherwise stated at checkout.
Your subscription renews automatically until cancelled.
</p>
<h3>3.2 14-day refund window</h3>
<p>
If you are a new Pro subscriber, you may request a full refund within <strong>14 days</strong> of
your initial purchase, provided you have not substantially consumed paid entitlements
(for example, a large portion of your monthly video quota or premium-only features).
</p>
<h3>3.3 After the refund window</h3>
<p>
After 14 days, subscription fees are generally non-refundable for the current billing
period. You may cancel at any time to prevent future renewals. Access typically continues
until the end of the paid period.
</p>
<h3>3.4 Cancellation</h3>
<p>
You can cancel your subscription through your account billing settings or by contacting{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>. Cancellation stops
future charges; it does not automatically delete your account or uploaded content history
unless you request account deletion separately.
</p>
<h2>4. Enterprise and custom agreements</h2>
<p>
Enterprise, managed cloud, and paid self-hosted setup fees are governed by the individual
quote or contract signed with us. Refund terms for those services are specified in your
agreement. Contact{" "}
<a href={`mailto:${LEGAL_OPERATOR.salesEmail}`}>{LEGAL_OPERATOR.salesEmail}</a> for
contract-related billing questions.
</p>
<h2>5. Non-refundable situations</h2>
<p>Refunds are generally not provided when:</p>
<ul>
<li>The refund request is made outside the applicable refund window</li>
<li>The account was terminated for violation of our <Link href="/terms">Terms of Service</Link></li>
<li>The issue is caused by third-party services outside our control (e.g. YouTube API outages, Google account restrictions)</li>
<li>You simply changed your mind after substantial use of paid quota or features</li>
<li>One-time setup or license fees after delivery of agreed setup work, unless required by law or contract</li>
</ul>
<h2>6. Chargebacks</h2>
<p>
If you believe a charge is incorrect, please contact us before initiating a chargeback so
we can resolve the issue promptly. Unjustified chargebacks may result in account suspension.
</p>
<h2>7. How to request a refund</h2>
<p>Email us at <a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a> with:</p>
<ul>
<li>Your account email address</li>
<li>Date of purchase and invoice or transaction reference if available</li>
<li>Reason for the refund request</li>
</ul>
<p>We aim to respond within 5 business days. Approved refunds are issued to the original payment method where possible.</p>
<h2>8. Consumer rights</h2>
<p>
Nothing in this policy limits mandatory statutory rights you may have as a consumer under
applicable law, including withdrawal rights where required by EU or local consumer
protection regulations.
</p>
<h2>9. Changes</h2>
<p>
We may update this Refund Policy from time to time. The version published on this page
applies to purchases made after the effective date shown at the top.
</p>
</LegalPageLayout>
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { MetadataRoute } from "next";
function siteUrl() {
const raw = process.env.NEXTAUTH_URL?.trim() || "http://localhost:3000";
return raw.replace(/\/$/, "");
}
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/api/", "/dashboard", "/jobs"],
},
sitemap: `${siteUrl()}/sitemap.xml`,
};
}
+30
View File
@@ -0,0 +1,30 @@
import type { MetadataRoute } from "next";
function siteUrl() {
const raw = process.env.NEXTAUTH_URL?.trim() || "http://localhost:3000";
return raw.replace(/\/$/, "");
}
export default function sitemap(): MetadataRoute.Sitemap {
const base = siteUrl();
return [
{
url: base,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 1.0,
},
{
url: `${base}/privacy`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.3,
},
{
url: `${base}/terms`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.3,
},
];
}
+27 -173
View File
@@ -1,197 +1,51 @@
import type { Metadata } from "next";
import Link from "next/link";
import { LegalPageLayout } from "@/components/LegalPageLayout";
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
export const metadata: Metadata = {
title: "Terms of Service | Songs2YT",
description: "Terms and conditions for using the Songs2YT hosted service.",
};
import { DOCS_URL, GITEA_URL } from "@/lib/plans";
export default function TermsPage() {
return (
<LegalPageLayout
title="Terms of Service"
description="Please read these terms carefully before using Songs2YT."
>
<h2>1. Agreement</h2>
<LegalPageLayout title="Terms of Use">
<p>
These Terms of Service (&quot;Terms&quot;) govern your access to and use of the Songs2YT
website and hosted cloud service (the &quot;Service&quot;) operated by{" "}
{LEGAL_OPERATOR.legalName} (&quot;we&quot;, &quot;us&quot;). By creating an account or using
the Service, you agree to these Terms.
</p>
<p>
If you do not agree, do not use the Service. If you self-host the open-source software on
your own infrastructure without using our hosted Service, these Terms apply only to the
extent you use our website, support channels, or paid services we provide.
Songs2VID OSS is open-source, <strong>payment-free, self-hosted software</strong>. These
terms describe use of the software and of instances that run it. They are{" "}
<strong>not</strong> the paid cloud Terms of Service for songs2vid.com (a separate hosted
product with its own billing and policies).
</p>
<h2>2. The Service</h2>
<h2>1. Software license and warranty</h2>
<p>
Songs2YT converts user-provided images and audio files into videos and can upload them to
YouTube using your connected Google/YouTube account. Features, limits, and availability
depend on your plan.
</p>
<ul>
<li>
<strong>Bedroom Producer (Free):</strong> limited quota, 720p, MP3, optional watermark
</li>
<li>
<strong>Independent Artist (Pro):</strong> expanded limits and features as described on
our pricing page
</li>
<li>
<strong>Enterprise:</strong> custom terms as agreed in writing
</li>
</ul>
<p>
We may modify features, limits, or pricing with reasonable notice where required. The
open-source software is provided separately under its applicable open-source license.
The source is provided under the license in the repository <code>LICENSE</code> file. The
software is provided <strong>without warranty</strong> of any kind. The operator of each
instance is responsible for availability, security, backups, configuration, and who may
sign in.
</p>
<h2>3. Eligibility and accounts</h2>
<ul>
<li>You must be at least 16 years old or the age required in your jurisdiction</li>
<li>You must have a valid Google account and authorized access to the YouTube channel you connect</li>
<li>You are responsible for maintaining the security of your account and OAuth connection</li>
<li>You must provide accurate information and promptly update it if it changes</li>
</ul>
<h2>4. Your content and responsibilities</h2>
<p>You retain ownership of content you upload. You grant us a limited license to host, process, encode, transmit, and upload your content solely to provide the Service.</p>
<p>You represent and warrant that:</p>
<ul>
<li>You own or have all necessary rights to the content you upload</li>
<li>Your content and use of the Service comply with applicable law and YouTube policies</li>
<li>Your content does not infringe third-party rights or contain unlawful material</li>
<li>You have configured metadata (including &quot;Made for Kids&quot; and privacy settings) accurately</li>
</ul>
<h2>2. No paid features in this edition</h2>
<p>
You are solely responsible for content published to your YouTube channel through the
Service.
This edition has no Stripe integration, subscriptions, credits, pricing pages, or paywalls.
Full layout, watermark, API, and playlist features are available to signed-in users of the
instance. Any paid offering lives only on the separate hosted SaaS product.
</p>
<h2>5. Acceptable use</h2>
<p>You agree not to:</p>
<ul>
<li>Use the Service for unlawful, harmful, or abusive purposes</li>
<li>Upload malware, attempt unauthorized access, or interfere with the Service</li>
<li>Circumvent quotas, plan limits, or technical restrictions</li>
<li>Resell or commercially exploit the hosted Service without authorization</li>
<li>Use the Service in a way that violates Google, YouTube, or third-party terms</li>
</ul>
<p>We may suspend or terminate access for violations or risks to the Service or other users.</p>
<h2>6. YouTube and third-party services</h2>
<h2>3. Your content and YouTube</h2>
<p>
The Service integrates with Google OAuth and the YouTube API. Your use of those services is
subject to Google&apos;s and YouTube&apos;s terms and policies. We are not responsible for
changes, outages, quota limits, or enforcement actions taken by YouTube.
You are solely responsible for media you upload and for compliance with copyright law and
YouTube&apos;s terms. Connecting Google/YouTube authorizes the instance to upload on your
behalf within the scopes granted.
</p>
<h2>7. Open-source software</h2>
<h2>4. Acceptable use</h2>
<p>
Portions of Songs2YT are available as open-source software. Self-hosting is permitted under
the applicable open-source license. The hosted Service, enterprise features, managed
infrastructure, and certain premium capabilities may require a separate commercial license
or subscription.
Do not use the software to infringe rights, abuse YouTube or Google APIs, or circumvent
another party&apos;s security. Operators may suspend access on their instances.
</p>
<h2>8. Fees and billing</h2>
<h2>5. Contact</h2>
<p>
Paid plans are billed according to the pricing displayed at the time of purchase. Taxes may
apply. Subscriptions renew automatically unless cancelled in accordance with our{" "}
<Link href="/refund">Refund Policy</Link>. Failure to pay may result in downgrade or
suspension.
</p>
<h3>8.1 Pro plan quota</h3>
<p>
Independent Artist (Pro) subscribers receive a monthly video quota that resets on the{" "}
<strong>1st day of each calendar month</strong> (UTC). Unused quota does not roll over to
the next month unless we expressly grant an extension in writing.
</p>
<p>
Pro subscribers may contact{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a> to request a manual
quota reset or temporary extension. Each Pro account is entitled to up to{" "}
<strong>five (5) manual monthly quota resets per calendar year</strong>. We review requests
in good faith and may decline requests that are abusive, repetitive without cause, or
inconsistent with fair use. Approved resets do not increase the annual limit of five
requests.
</p>
<h3>8.2 Pro API access</h3>
<p>
Independent Artist (Pro) subscribers may generate an API key in account settings to upload
files and create batch video jobs programmatically. API access requires an active Pro
subscription, a connected YouTube account, and compliance with the same quota, file-type, and
resolution limits as the web interface. API keys are personal, must be kept confidential, and
may be revoked by you or by us if misused. We may apply rate limits and suspend API access
for abuse, security incidents, or plan downgrades.
</p>
<h2>9. Availability and support</h2>
<p>
We strive for high availability but do not guarantee uninterrupted access. Maintenance,
updates, and outages may occur. Support levels depend on your plan. Self-hosted DIY
deployments without a paid setup are community-supported unless otherwise agreed in
writing.
</p>
<h2>10. Disclaimer of warranties</h2>
<p>
THE SERVICE IS PROVIDED &quot;AS IS&quot; AND &quot;AS AVAILABLE&quot; TO THE MAXIMUM EXTENT
PERMITTED BY LAW. WE DISCLAIM ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. WE DO NOT WARRANT THAT ENCODING,
UPLOADS, OR METADATA TRANSFER WILL BE ERROR-FREE OR UNINTERRUPTED.
</p>
<h2>11. Limitation of liability</h2>
<p>
TO THE MAXIMUM EXTENT PERMITTED BY LAW, WE SHALL NOT BE LIABLE FOR INDIRECT, INCIDENTAL,
SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS, REVENUE, DATA, OR
GOODWILL. OUR TOTAL LIABILITY FOR ANY CLAIM ARISING OUT OF THESE TERMS OR THE SERVICE IS
LIMITED TO THE AMOUNT YOU PAID US IN THE TWELVE (12) MONTHS BEFORE THE EVENT GIVING RISE TO
THE CLAIM, OR EUR 100 IF YOU USE THE FREE PLAN ONLY.
</p>
<p>
Some jurisdictions do not allow certain limitations, so some of the above may not apply to
you.
</p>
<h2>12. Indemnification</h2>
<p>
You agree to indemnify and hold us harmless from claims arising out of your content, your
use of the Service, or your violation of these Terms or applicable law.
</p>
<h2>13. Termination</h2>
<p>
You may stop using the Service at any time. We may suspend or terminate your access if you
breach these Terms, create risk or legal exposure, or where required by law. Upon
termination, your right to use the hosted Service ends. Provisions that by nature should
survive will survive.
</p>
<h2>14. Governing law</h2>
<p>
These Terms are governed by the laws of [Your jurisdiction / country], excluding conflict
of law rules. Courts in [Your jurisdiction] shall have exclusive jurisdiction unless
mandatory consumer protection laws in your country provide otherwise.
</p>
<h2>15. Changes</h2>
<p>
We may update these Terms from time to time. Continued use after changes become effective
constitutes acceptance of the revised Terms, where permitted by law.
</p>
<h2>16. Contact</h2>
<p>
Questions about these Terms:{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>
Package / project contact:{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>. Instance-specific
support: contact your operator. Docs: <a href={DOCS_URL}>{DOCS_URL}</a>. Source:{" "}
<a href={GITEA_URL}>{GITEA_URL}</a>.
</p>
</LegalPageLayout>
);
Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

+4 -2
View File
@@ -1,5 +1,7 @@
# Watermark image
Place `watermark.png` here for the bottom-right video overlay.
The image should include the full attribution: "Uploaded through Songs2YT.com".
Place `watermark.png` here for the default bottom-right video overlay.
Curated Pro typography fonts live in `fonts/` (run `node scripts/fetch-watermark-fonts.mjs`).
The image should include the full attribution: "Uploaded through Songs2VID.com".
If missing, FFmpeg falls back to bottom-right drawtext with the same message.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 374 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

+1 -1
View File
@@ -37,7 +37,7 @@ export function AccountPrivacyActions({ email }: Props) {
const dataRequestSubject = encodeURIComponent("Data export request");
const dataRequestBody = encodeURIComponent(
`Hello,\n\nI would like to request a copy of my personal data associated with my Songs2YT account (${email}).\n\nThank you.`,
`Hello,\n\nI would like to request a copy of my personal data associated with my Songs2VID account (${email}).\n\nThank you.`,
);
return (
+37 -116
View File
@@ -1,94 +1,55 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { API_DOCS_URL } from "@/lib/plans";
type ApiKeyStatus = {
configured: boolean;
prefix: string | null;
};
type RateLimitStatus = {
limit: number;
used: number;
remaining: number;
windowSeconds: number;
resetsInSeconds: number;
bonus?: number;
};
type Props = {
initialStatus: ApiKeyStatus;
initialRateLimit: RateLimitStatus;
initialStatus: { configured: boolean; prefix: string | null };
initialRateLimit: {
limit: number;
used: number;
remaining: number;
windowSeconds: number;
resetsInSeconds: number;
};
};
export function ApiKeySettings({ initialStatus, initialRateLimit }: Props) {
const [status, setStatus] = useState(initialStatus);
const [rateLimit, setRateLimit] = useState(initialRateLimit);
const [newKey, setNewKey] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
let active = true;
const refresh = () => {
fetch("/api/account/api-rate-limit")
.then(async (res) => {
if (!res.ok) return;
const data = await res.json();
if (active) setRateLimit(data);
})
.catch(() => {});
};
refresh();
const id = setInterval(refresh, 5000);
return () => {
active = false;
clearInterval(id);
};
}, []);
async function generateKey() {
if (
status.configured &&
!confirm("This will replace your existing API key. Continue?")
) {
return;
}
if (status.configured && !confirm("Replace your existing API key?")) return;
setLoading(true);
setError(null);
setSuccess(null);
setNewKey(null);
try {
const res = await fetch("/api/account/api-key", { method: "POST" });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to generate key");
if (!res.ok) throw new Error(data.error || "Failed to generate API key");
setNewKey(data.apiKey);
setStatus({ configured: true, prefix: data.prefix });
setSuccess("API key generated. Copy it now — it will not be shown again.");
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to generate key");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to generate API key");
} finally {
setLoading(false);
}
}
async function revokeKey() {
if (!confirm("Revoke the current API key?")) return;
if (!confirm("Revoke your API key?")) return;
setLoading(true);
setError(null);
setSuccess(null);
setNewKey(null);
try {
const res = await fetch("/api/account/api-key", { method: "DELETE" });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Failed to revoke key");
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to revoke API key");
setStatus({ configured: false, prefix: null });
setSuccess("API key revoked.");
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to revoke key");
setNewKey(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to revoke API key");
} finally {
setLoading(false);
}
@@ -97,55 +58,32 @@ export function ApiKeySettings({ initialStatus, initialRateLimit }: Props) {
return (
<div className="space-y-4">
<p className="text-sm text-gray-400">
Use the REST API to upload files and create batch video jobs programmatically.
Use the REST API to upload files and create video jobs programmatically. There are no
billing endpoints in this self-hosted edition.
</p>
<p className="text-xs text-gray-500">
Rate limit: {initialRateLimit.limit} requests per {initialRateLimit.windowSeconds} seconds.
</p>
<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">
<p className="text-sm font-medium text-white">API rate limit</p>
<p className="text-xs text-gray-400">Resets in {rateLimit.resetsInSeconds}s</p>
</div>
<p className="text-sm text-gray-300">
{rateLimit.used} / {rateLimit.limit} requests used this minute
</p>
<p className="text-xs text-gray-500">
Self-hosted: API rate limits are effectively unlimited.
</p>
</div>
{status.configured && status.prefix && (
<p className="text-sm text-gray-300">
Active key: <span className="font-mono text-white">{status.prefix}</span>
</p>
)}
{newKey && (
<div className="rounded border border-green-500/40 bg-green-500/10 p-4">
<p className="mb-2 text-sm font-medium text-green-300">Your new API key (copy now)</p>
<p className="mb-2 text-sm font-medium text-green-300">Copy your new API key now</p>
<code className="block break-all rounded bg-black/40 px-3 py-2 text-xs text-green-200">
{newKey}
</code>
</div>
)}
{success && (
<div className="rounded border border-green-500/40 bg-green-500/10 px-4 py-3 text-sm text-green-300">
{success}
</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>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
{error && <p className="text-sm text-red-300">{error}</p>}
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={generateKey}
disabled={loading}
className="inline-flex items-center justify-center rounded bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover disabled:opacity-50"
>
{loading ? "Working…" : status.configured ? "Regenerate API key" : "Generate API key"}
</button>
@@ -154,36 +92,19 @@ export function ApiKeySettings({ initialStatus, initialRateLimit }: Props) {
type="button"
onClick={revokeKey}
disabled={loading}
className="inline-flex items-center justify-center rounded border border-red-600/50 px-4 py-2 text-sm font-medium text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
className="rounded border border-red-600/50 px-4 py-2 text-sm font-medium text-red-300 hover:bg-red-500/10"
>
Revoke API key
</button>
)}
</div>
<div className="rounded border border-gray-800 bg-surface p-4 text-xs text-gray-400">
<p className="mb-2 font-semibold uppercase tracking-wider text-gray-500">Endpoints</p>
<ul className="space-y-2 font-mono">
<li>POST /api/v1/upload: upload image or audio file</li>
<li>POST /api/v1/jobs: create job from paths (recommended for large batches)</li>
<li>POST /api/v1/jobs/batch: small packs only (one-shot multipart)</li>
<li>GET /api/v1/playlists: list YouTube playlists</li>
<li>POST /api/v1/playlists: create a YouTube playlist</li>
<li>GET /api/v1/jobs: list jobs</li>
<li>GET /api/v1/jobs/:id: job status</li>
</ul>
<p className="mt-3">
Send <span className="text-gray-300">Authorization: Bearer YOUR_API_KEY</span> on every
request.{" "}
<a
href={API_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
Full API docs
</a>
</p>
<a
href={API_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="rounded border border-gray-600 px-4 py-2 text-sm text-gray-300 hover:text-white"
>
API documentation
</a>
</div>
</div>
);
-436
View File
@@ -1,436 +0,0 @@
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { ScrollReveal } from "@/components/ScrollReveal";
import { useInView } from "@/hooks/useInView";
import { useMockupProgress } from "@/hooks/useMockupProgress";
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
function FilmStripIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M2 8h20M2 16h20M6 4v4M6 16v4M10 4v4M10 16v4M14 4v4M14 16v4M18 4v4M18 16v4" />
<path d="m15 9 3 3-3 3" />
</svg>
);
}
function YouTubeIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.4.6A3 3 0 0 0 .5 6.2 31 31 0 0 0 0 12a31 31 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.6 9.4.6 9.4.6s7.5 0 9.4-.6a3 3 0 0 0 2.1-2.1A31 31 0 0 0 24 12a31 31 0 0 0-.5-5.8z" />
<path fill="#12121f" d="M9.75 15.02l6.35-3.02-6.35-3.02v6.04z" />
</svg>
);
}
function CoinsIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<ellipse cx="8" cy="6" rx="5" ry="2" />
<path d="M3 6v4c0 1.1 2.24 2 5 2s5-.9 5-2V6" />
<path d="M3 10v4c0 1.1 2.24 2 5 2s5-.9 5-2v-4" />
<ellipse cx="16" cy="14" rx="5" ry="2" />
<path d="M11 14v4c0 1.1 2.24 2 5 2s5-.9 5-2v-4" />
</svg>
);
}
function PlaylistIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 6h12M4 12h12M4 18h8" strokeLinecap="round" />
<path d="M17 14.5v5l4-2.5-4-2.5z" fill="currentColor" stroke="none" />
</svg>
);
}
function NoteIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
);
}
function WaveIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 12h1M7 12h1M10 8v8M13 10v4M16 7v10M19 11v2" strokeLinecap="round" />
</svg>
);
}
function GearsIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="3" />
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
);
}
const BENEFITS = [
{
title: "No editing required",
desc: "Skip complex video editors. Just upload an image and audio, and Songs2VID handles the rest.",
Icon: FilmStripIcon,
},
{
title: "YouTube-ready output",
desc: "Videos are encoded and uploaded with the metadata you set, ready for your channel.",
Icon: YouTubeIcon,
},
{
title: "Self-hosted, unlocked",
desc: "No video quotas or paywalls. Art-track layouts, typography, watermarks, and the REST API are all available.",
Icon: CoinsIcon,
},
{
title: "YouTube playlists",
desc: "Create YouTube playlists and add every upload from the dashboard or API.",
Icon: PlaylistIcon,
},
] as const;
const STOCK_THUMBS = [
"https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=120&h=120&fit=crop&auto=format",
"https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=120&h=120&fit=crop&auto=format",
"https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=120&h=120&fit=crop&auto=format",
"https://images.unsplash.com/photo-1487180144351-b8472da7d491?w=120&h=120&fit=crop&auto=format",
"https://images.unsplash.com/photo-1598488035139-bdbb2231ce04?w=120&h=120&fit=crop&auto=format",
"https://images.unsplash.com/photo-1571330735066-03aaa9429d89?w=120&h=120&fit=crop&auto=format",
"https://images.unsplash.com/photo-1619983081563-430f63602796?w=120&h=120&fit=crop&auto=format",
"https://images.unsplash.com/photo-1483412033650-1015ddeb83d1?w=120&h=120&fit=crop&auto=format",
] as const;
function TypewriterText({ text }: { text: string }) {
const [displayed, setDisplayed] = useState("");
const [done, setDone] = useState(false);
useEffect(() => {
let index = 0;
const interval = setInterval(() => {
index += 1;
setDisplayed(text.slice(0, index));
if (index >= text.length) {
clearInterval(interval);
setDone(true);
}
}, 45);
return () => clearInterval(interval);
}, [text]);
return (
<span className="text-xs font-medium text-gray-300">
{displayed}
{!done && <span className="ml-0.5 inline-block w-[2px] animate-pulse bg-red-400">|</span>}
</span>
);
}
function BenefitCard({
title,
desc,
Icon,
}: {
title: string;
desc: string;
Icon: typeof FilmStripIcon;
}) {
return (
<div className="group relative overflow-hidden rounded-xl border border-red-500/20 bg-surface p-6 shadow-[0_0_24px_rgba(239,68,68,0.08)] transition-all duration-300 hover:border-red-500/40 hover:shadow-[0_0_32px_rgba(239,68,68,0.18)]">
<Icon className="absolute right-5 top-5 h-8 w-8 text-gray-600 transition-colors duration-300 group-hover:text-red-500/50" />
<h3 className="pr-12 text-lg font-semibold text-white">{title}</h3>
<p className="mt-3 text-sm leading-relaxed text-gray-400">{desc}</p>
</div>
);
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
aria-hidden="true"
>
<path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function MockupProgressRow({
initialWidth,
midWidth,
active,
phaseOneMs = 3000,
phaseTwoMs = 2000,
}: {
initialWidth: string;
midWidth: string;
active: boolean;
phaseOneMs?: number;
phaseTwoMs?: number;
}) {
const { phase, processed, transitionMs } = useMockupProgress(active, phaseOneMs, phaseTwoMs);
const width = phase === 0 ? initialWidth : phase === 1 ? midWidth : "100%";
return (
<div>
<p className="mb-1 text-[10px] text-gray-500">{processed ? "Processed" : "Processing..."}</p>
<div className="flex items-center gap-2">
<div className="h-1 min-w-0 flex-1 rounded-full bg-gray-700">
<div
className="h-full rounded-full bg-white/80 ease-out"
style={{
width,
transition: phase >= 1 ? `width ${transitionMs}ms ease-out` : "none",
}}
/>
</div>
<CheckIcon
className={`h-3.5 w-3.5 shrink-0 text-accent transition-all duration-300 ${
processed ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
/>
</div>
</div>
);
}
function DashboardMockup() {
const { ref, inView } = useInView();
const sidebarIcons = [NoteIcon, WaveIcon, GearsIcon] as const;
return (
<div ref={ref} className="rounded-xl border border-gray-700/50 bg-surface-dark p-4">
<div className="flex gap-4">
<div className="flex w-11 shrink-0 flex-col items-center gap-3 rounded-lg bg-surface py-3">
<div className="h-2 w-2 rounded-full bg-gray-500" />
{sidebarIcons.map((Icon, i) => (
<div
key={i}
className="flex h-7 w-7 items-center justify-center rounded-md border border-gray-700/50 bg-surface-dark text-gray-500"
>
<Icon className="h-4 w-4" />
</div>
))}
</div>
<div className="min-w-0 flex-1 space-y-3">
<div className="flex items-center justify-between">
<TypewriterText text="Multi-step configuration" />
<div className="h-4 w-8 rounded-full bg-red-500/80" />
</div>
<div className="h-7 rounded border border-gray-700 bg-surface px-2 text-xs leading-7 text-gray-500">
Title
</div>
<div className="flex flex-wrap gap-1.5">
<span className="rounded bg-gray-700 px-2 py-0.5 text-[10px] text-gray-400">genre</span>
<span className="rounded bg-gray-700 px-2 py-0.5 text-[10px] text-gray-400">audio</span>
</div>
<div className="flex h-7 items-center justify-between rounded border border-gray-700 bg-surface px-2 text-xs text-gray-500">
Category <span className="text-gray-400">Music </span>
</div>
<div className="space-y-2 pt-1">
<MockupProgressRow active={inView} initialWidth="25%" midWidth="45%" phaseOneMs={3000} phaseTwoMs={2000} />
<MockupProgressRow active={inView} initialWidth="10%" midWidth="55%" phaseOneMs={3000} phaseTwoMs={2000} />
</div>
</div>
</div>
</div>
);
}
function ProcessFlow() {
return (
<div className="relative mt-4 overflow-hidden rounded-xl border border-gray-700/50 bg-surface-dark p-5">
<svg
className="pointer-events-none absolute inset-0 h-full w-full"
viewBox="0 0 420 240"
preserveAspectRatio="none"
aria-hidden="true"
>
<defs>
<linearGradient id="flowGradH" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="rgb(239 68 68 / 0.15)" />
<stop offset="50%" stopColor="rgb(239 68 68 / 0.55)" />
<stop offset="100%" stopColor="rgb(239 68 68 / 0.15)" />
</linearGradient>
<linearGradient id="flowGradV" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="rgb(239 68 68 / 0.5)" />
<stop offset="100%" stopColor="rgb(239 68 68 / 0.15)" />
</linearGradient>
</defs>
<path
d="M 58 52 C 120 52, 140 58, 168 72"
fill="none"
stroke="url(#flowGradH)"
strokeWidth="1.5"
className="benefits-flow-path"
/>
<path
d="M 58 118 C 120 108, 140 88, 168 78"
fill="none"
stroke="url(#flowGradH)"
strokeWidth="1.5"
className="benefits-flow-path"
style={{ animationDelay: "0.3s" }}
/>
<path
d="M 198 75 C 250 75, 285 68, 318 68"
fill="none"
stroke="url(#flowGradH)"
strokeWidth="1.5"
className="benefits-flow-path"
style={{ animationDelay: "0.6s" }}
/>
</svg>
<div className="relative z-10">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-4">
<FlowInput label="Batch Audio Files" icon="audio" />
<FlowInput label="Single Cover Image" icon="image" />
</div>
<div className="flex flex-1 flex-col items-center px-2 pt-6">
<div className="benefits-process-icon flex h-16 w-16 items-center justify-center rounded-xl border border-red-500/40 bg-surface shadow-[0_0_20px_rgba(239,68,68,0.25)]">
<Image
src="/database.png"
alt="Process"
width={40}
height={40}
className="h-10 w-10 object-contain brightness-0 invert opacity-90"
/>
</div>
<span className="mt-2 text-[10px] font-medium text-gray-400">Process</span>
</div>
<div className="flex flex-col items-center">
<div className="grid grid-cols-4 gap-1.5">
{STOCK_THUMBS.map((src) => (
<div
key={src}
className="relative h-11 w-11 overflow-hidden rounded border border-gray-700"
>
<Image
src={src}
alt=""
fill
sizes="44px"
className="scale-110 object-cover blur-[2px]"
/>
<div className="absolute inset-0 bg-black/25" />
<span className="absolute inset-0 flex items-center justify-center text-[9px] text-white/80">
</span>
</div>
))}
</div>
<svg
className="my-2 h-10 w-6 overflow-visible"
viewBox="0 0 6 40"
aria-hidden="true"
>
<line
x1="3"
y1="2"
x2="3"
y2="38"
stroke="rgb(239 68 68 / 0.55)"
strokeWidth="1.5"
className="benefits-flow-path"
style={{ animationDelay: "0.9s" }}
/>
</svg>
<div className="flex items-center gap-3">
<YouTubeIcon className="h-9 w-9 text-red-500" />
<div>
<p className="text-xs font-bold tracking-wider text-white">YouTube</p>
<p className="text-[10px] font-semibold tracking-[0.15em] text-red-400">DIRECT UPLOAD</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
function FlowInput({ label, icon }: { label: string; icon: "audio" | "image" }) {
return (
<div className="flex flex-col items-center gap-1.5">
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-gray-700 bg-surface">
{icon === "audio" ? (
<NoteIcon className="h-5 w-5 text-gray-500" />
) : (
<svg className="h-5 w-5 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="M21 15l-5-5L5 21" />
</svg>
)}
</div>
<span className="max-w-[72px] text-center text-[9px] leading-tight text-gray-500">{label}</span>
</div>
);
}
function BenefitsVisual() {
return (
<div className="rounded-2xl border border-gray-700/50 bg-surface p-5 shadow-2xl shadow-black/30">
<DashboardMockup />
<ProcessFlow />
</div>
);
}
function BenefitsScrollTitle({ sectionRef }: { sectionRef: React.RefObject<HTMLElement | null> }) {
return <SectionScrollTitle sectionRef={sectionRef} title="Benefits" />;
}
export function BenefitsSection() {
const sectionRef = useRef<HTMLElement>(null);
return (
<section
ref={sectionRef}
id="benefits"
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 py-24"
>
<BenefitsScrollTitle sectionRef={sectionRef} />
<div className="relative z-10 mx-auto max-w-7xl">
<ScrollReveal>
<h2 className="mb-12 text-center text-3xl font-bold text-white">Benefits</h2>
</ScrollReveal>
<div className="grid items-start gap-10 lg:grid-cols-[1fr_1.15fr] lg:gap-12">
<div className="flex flex-col gap-5">
{BENEFITS.map((benefit, index) => (
<ScrollReveal key={benefit.title} delay={index * 100} direction="left">
<BenefitCard title={benefit.title} desc={benefit.desc} Icon={benefit.Icon} />
</ScrollReveal>
))}
</div>
<ScrollReveal delay={200} direction="right">
<BenefitsVisual />
</ScrollReveal>
</div>
</div>
</section>
);
}
-160
View File
@@ -1,160 +0,0 @@
"use client";
import { useRef } from "react";
import { ScrollReveal } from "@/components/ScrollReveal";
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
import { DOCKER_HUB_URL, GITEA_URL } from "@/lib/plans";
function DockerIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" role="img" aria-label="Docker">
<path
fill="#2496ED"
d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"
/>
</svg>
);
}
function GiteaIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" role="img" aria-label="Gitea">
<path
fill="#609926"
d="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"
/>
</svg>
);
}
const DEPLOY_CARDS = [
{
name: "Docker Hub",
desc: "Pull the image and run Songs2YT on your own server with Docker Compose.",
Icon: DockerIcon,
cta: "View on Docker Hub",
href: DOCKER_HUB_URL,
accent: "text-[#2496ED]",
hover:
"hover:border-[#2496ED]/45 hover:bg-[#2496ED]/[0.06] hover:shadow-lg hover:shadow-[#2496ED]/20",
titleHover: "group-hover:text-[#2496ED]",
},
{
name: "Gitea",
desc: "Clone the open-source repository and deploy from source on your infrastructure.",
Icon: GiteaIcon,
cta: "View on Gitea",
href: GITEA_URL,
accent: "text-[#609926]",
hover:
"hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20",
titleHover: "group-hover:text-[#609926]",
},
] as const;
function DeployCard({
name,
desc,
Icon,
cta,
href,
accent,
hover,
titleHover,
}: (typeof DEPLOY_CARDS)[number]) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={`group block rounded-xl border border-gray-700/50 bg-surface p-6 transition-all duration-300 ${hover}`}
>
<div className="mb-4 flex items-center justify-between">
<Icon className="h-8 w-8 transition-transform duration-300 group-hover:scale-110" />
<span className="rounded bg-gray-800 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-gray-500 transition-colors duration-300 group-hover:bg-gray-700/80">
Open Source
</span>
</div>
<h3 className={`text-lg font-semibold text-white transition-colors duration-300 ${titleHover}`}>
{name}
</h3>
<p className="mt-2 text-sm leading-relaxed text-gray-400 transition-colors duration-300 group-hover:text-gray-300">
{desc}
</p>
<p className={`mt-4 text-sm font-medium ${accent} transition-all duration-300 group-hover:underline`}>
{cta}
</p>
</a>
);
}
export function DownloadSection() {
const sectionRef = useRef<HTMLElement>(null);
return (
<section
ref={sectionRef}
id="download"
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 py-24"
>
<SectionScrollTitle sectionRef={sectionRef} title="Download" />
<div className="relative z-10 mx-auto max-w-7xl">
<ScrollReveal>
<h2 className="mb-4 text-center text-3xl font-bold text-white">Download</h2>
<p className="mx-auto mb-12 max-w-2xl text-center text-gray-400">
Songs2YT is open source. Self-host on your own server with Docker or deploy from source.
</p>
</ScrollReveal>
<div className="grid items-start gap-10 lg:grid-cols-[1fr_1.1fr] lg:gap-12">
<ScrollReveal direction="left">
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold text-white">Run it yourself</h3>
<p className="mt-3 leading-relaxed text-gray-400">
Host Songs2YT on your infrastructure with full control over data, queues, and
storage. Ideal for teams and creators who want a private deployment.
</p>
</div>
<ul className="space-y-3 text-sm text-gray-400">
<li className="flex items-start gap-3">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
Docker image published to Docker Hub
</li>
<li className="flex items-start gap-3">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
Source code available on Gitea
</li>
<li className="flex items-start gap-3">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
Gitea Issues community support
</li>
<li className="flex items-start gap-3">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
PostgreSQL, Redis, FFmpeg, and worker included
</li>
</ul>
<div className="rounded-xl border border-gray-700/50 bg-surface-dark p-4">
<p className="mb-3 text-xs font-medium text-gray-500">Quick start</p>
<pre className="overflow-x-auto text-sm leading-relaxed text-gray-300">
<code>{`docker pull atakanozban/songs2yt:latest\ndocker compose up -d`}</code>
</pre>
</div>
</div>
</ScrollReveal>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-1">
{DEPLOY_CARDS.map((card, index) => (
<ScrollReveal key={card.name} delay={index * 120} direction="right">
<DeployCard {...card} />
</ScrollReveal>
))}
</div>
</div>
</div>
</section>
);
}
-199
View File
@@ -1,199 +0,0 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { Logo } from "@/components/Logo";
import {
API_DOCS_URL,
DOCKER_HUB_URL,
DOCS_URL,
GITEA_ISSUES_URL,
GITEA_URL,
SUPPORT_EMAIL,
WEBSITE_URL,
} from "@/lib/plans";
function GiteaIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" role="img" aria-hidden="true">
<path
fill="currentColor"
d="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"
/>
</svg>
);
}
function DockerIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" role="img" aria-hidden="true">
<path
fill="currentColor"
d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"
/>
</svg>
);
}
function FooterLink({
href,
children,
external,
}: {
href: string;
children: ReactNode;
external?: boolean;
}) {
const className = "transition-colors duration-300 hover:text-white";
if (external || href.startsWith("#") || href.startsWith("mailto:")) {
return (
<a
href={href}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
className={className}
>
{children}
</a>
);
}
return (
<Link href={href} className={className}>
{children}
</Link>
);
}
function LegalLink({
href,
children,
external,
}: {
href: string;
children: ReactNode;
external?: boolean;
}) {
const className = "transition-colors duration-300 hover:text-gray-300";
if (external) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" className={className}>
{children}
</a>
);
}
return (
<Link href={href} className={className}>
{children}
</Link>
);
}
const STATUS_URL = "https://status.atakanozban.com/status/2";
const DOCS_INTRO_URL = `${DOCS_URL}/docs/intro`;
export function Footer() {
const year = new Date().getFullYear();
return (
<footer className="mt-20 w-full border-t border-gray-800 bg-black/40 pb-8 pt-16 text-sm text-gray-400">
<div className="mx-auto max-w-6xl px-6">
<div className="grid grid-cols-1 gap-8 pb-12 md:grid-cols-12">
<div className="flex flex-col gap-4 md:col-span-5">
<Logo size="sm" />
<p className="max-w-sm text-gray-500">
Convert your audio tracks into stunning videos for YouTube. Beautiful, automated, and
fully open-source.
</p>
<div className="mt-2 flex items-center gap-4 text-gray-500">
<a
href={GITEA_URL}
target="_blank"
rel="noopener noreferrer"
title="Gitea"
className="transition-colors duration-300 hover:text-[#609926]"
>
<GiteaIcon className="h-5 w-5" />
</a>
<a
href={DOCKER_HUB_URL}
target="_blank"
rel="noopener noreferrer"
title="Docker Hub"
className="transition-colors duration-300 hover:text-[#2496ED]"
>
<DockerIcon className="h-5 w-5" />
</a>
</div>
</div>
<div className="grid grid-cols-2 gap-8 sm:grid-cols-3 md:col-span-7">
<div className="flex flex-col gap-3">
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Product
</span>
<FooterLink href="#download">Download</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="/terms">Terms of Service</FooterLink>
<FooterLink href={STATUS_URL} external>
Service Status
</FooterLink>
</div>
<div className="flex flex-col gap-3">
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Open Source
</span>
<FooterLink href={GITEA_URL} external>
Gitea Instance
</FooterLink>
<FooterLink href={DOCKER_HUB_URL} external>
Docker Image
</FooterLink>
<FooterLink href={GITEA_ISSUES_URL} external>
Report a Bug
</FooterLink>
</div>
<div className="flex flex-col gap-3">
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Contact
</span>
<FooterLink href={`mailto:${SUPPORT_EMAIL}`}>Support Email</FooterLink>
<span className="text-xs text-gray-600">Self-hosted community support</span>
</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">
<span>© {year} Songs2VID. All rights reserved.</span>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<LegalLink href="/privacy">Privacy Policy</LegalLink>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<LegalLink href="/terms">Terms of Service</LegalLink>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<LegalLink href={STATUS_URL} external>
Service Status
</LegalLink>
</div>
</div>
</footer>
);
}
+9 -4
View File
@@ -20,7 +20,9 @@ const STATUS_LABELS: Record<string, string> = {
export function JobProgress({ jobId }: Props) {
const [job, setJob] = useState<JobResponse | null>(null);
const [videosProcessed, setVideosProcessed] = useState<number | null>(null);
const [quota, setQuota] = useState<{
used: number;
} | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
@@ -39,7 +41,10 @@ export function JobProgress({ jobId }: Props) {
if (quotaRes.ok) {
const quotaData = await quotaRes.json();
if (active) setVideosProcessed(quotaData.used ?? 0);
if (active)
setQuota({
used: quotaData.used ?? 0,
});
}
} catch (err) {
if (active) setError(err instanceof Error ? err.message : "Error loading job");
@@ -76,9 +81,9 @@ export function JobProgress({ jobId }: Props) {
Overall: <span className="text-white">{job.status}</span>
</p>
</div>
{videosProcessed !== null && (
{quota && (
<div className="rounded bg-surface-light px-4 py-2 text-sm text-gray-300">
Self-hosted · {videosProcessed} videos processed
{quota.used} videos created
</div>
)}
</div>
-117
View File
@@ -1,117 +0,0 @@
"use client";
import { useState } from "react";
import { Logo } from "@/components/Logo";
import { MobileMenuButton, MobileSidebar, sidebarLinkClass } from "@/components/MobileSidebar";
import { DOCS_URL, WEBSITE_URL } from "@/lib/plans";
const NAV_LINKS = [
{ href: "#benefits", label: "Benefits", kind: "anchor" as const },
{ href: "#download", label: "Download", kind: "anchor" as const },
{ href: `${DOCS_URL}/docs/intro`, label: "Docs", kind: "external" as const },
{ href: WEBSITE_URL, label: "Hosted", kind: "external" as const },
{ href: "#support", label: "Support", kind: "anchor" as const },
] as const;
function NavAnchor({
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;
}) {
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
const id = href.replace("#", "");
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
window.history.pushState(null, "", href);
onNavigate?.();
};
return (
<a href={href} onClick={handleClick} className={className}>
{label}
</a>
);
}
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() {
const [menuOpen, setMenuOpen] = useState(false);
return (
<>
<header className="group absolute left-0 right-0 top-0 z-20 bg-transparent transition-all duration-300 hover:bg-black/70 hover:backdrop-blur-md">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
<Logo size="md" />
<div className="flex items-center gap-4">
<nav className="hidden items-center gap-10 sm:flex">
{NAV_LINKS.map((link) =>
link.kind === "external" ? (
<ExternalLink key={link.href} href={link.href} label={link.label} />
) : (
<NavAnchor key={link.href} href={link.href} label={link.label} />
),
)}
</nav>
<MobileMenuButton
open={menuOpen}
onClick={() => setMenuOpen((prev) => !prev)}
/>
</div>
</div>
</header>
<MobileSidebar open={menuOpen} onClose={() => setMenuOpen(false)} title="Menu">
{NAV_LINKS.map((link) =>
link.kind === "external" ? (
<ExternalLink
key={link.href}
href={link.href}
label={link.label}
onNavigate={() => setMenuOpen(false)}
className={sidebarLinkClass()}
/>
) : (
<NavAnchor
key={link.href}
href={link.href}
label={link.label}
onNavigate={() => setMenuOpen(false)}
className={sidebarLinkClass()}
/>
),
)}
</MobileSidebar>
</>
);
}
+440 -128
View File
@@ -10,7 +10,7 @@ import {
import { getVideoAttributionText } from "@/lib/branding";
import {
CURATED_FONTS,
googleFontsStylesheetUrl,
SYSTEM_FONT,
type CuratedFontKey,
type WatermarkFontKey,
} from "@/lib/fonts";
@@ -20,18 +20,30 @@ import {
BLUR_OPACITY_DEFAULT,
BLUR_OPACITY_MAX,
BLUR_OPACITY_MIN,
COMPOSITION_FAMILIES,
DEFAULT_LAYOUT,
LAYOUT_TEMPLATE_LABELS,
LAYOUT_TEMPLATES,
TEXT_OFFSET_MAX,
TEXT_OFFSET_MIN,
TEXT_PADDING_MAX,
TEXT_PADDING_MIN,
TITLE_ARTIST_GAP_MAX,
TITLE_ARTIST_GAP_MIN,
compositionFamilyForTemplate,
isLowerCornerTemplate,
lowerCornerCoverSide,
type LayoutSettings,
type LayoutTemplate,
} from "@/lib/layout";
import {
WATERMARK_WIDTH_FRACTION,
artistFontSizeForWidth,
curatedFontApiUrl,
previewFontFamilyCss,
scaleFontToPreview,
titleFontSizeForWidth,
watermarkFontSizeForWidth,
} from "@/lib/preview-typography";
import {
DEFAULT_WATERMARK,
WATERMARK_OFFSET_MAX,
@@ -44,17 +56,25 @@ import {
} from "@/lib/watermark";
type Props = {
locked?: boolean;
locked: boolean;
previewImageUrl: string | null;
title: string;
/** Optional custom blur-fill for lower-corner layouts. */
previewBackgroundUrl?: string | null;
/** On-video song title (art-track layouts). */
songTitle: string;
artist: string;
/** Encode width from selected resolution (e.g. 1280). */
encodeWidth?: number;
layout: LayoutSettings;
onLayoutChange: (next: LayoutSettings) => void;
watermark: WatermarkSettings;
onWatermarkChange: (next: WatermarkSettings) => void;
onUploadLogo: (file: File) => Promise<string>;
onUploadFont: (file: File) => Promise<string>;
onUploadBackground?: (file: File) => Promise<string>;
onClearBackground?: () => void;
logoPreviewUrl?: string | null;
hasCustomBackground?: boolean;
};
const WM_POSITION_LABELS: Record<WatermarkPosition, string> = {
@@ -67,16 +87,24 @@ const WM_POSITION_LABELS: Record<WatermarkPosition, string> = {
const CUSTOM_PREVIEW_FAMILY = "S2VIDCustomWm";
function previewFontFamily(fontKey: WatermarkFontKey | undefined): string {
return previewFontFamilyCss(fontKey);
}
function watermarkOverlayStyle(
position: WatermarkPosition,
offsetX: number,
offsetY: number,
sizedBox = false,
): CSSProperties {
const base: CSSProperties = {
position: "absolute",
maxWidth: "32%",
maxWidth: `${WATERMARK_WIDTH_FRACTION * 100}%`,
pointerEvents: "none",
zIndex: 5,
...(sizedBox
? { width: `${WATERMARK_WIDTH_FRACTION * 100}%` }
: null),
};
const ox = `${offsetX}px`;
const oy = `${offsetY}px`;
@@ -100,20 +128,15 @@ function watermarkOverlayStyle(
}
}
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,
label,
active,
onClick,
disabled,
}: {
template: LayoutTemplate | null;
label: string;
active: boolean;
onClick: () => void;
disabled: boolean;
@@ -138,20 +161,14 @@ function MiniThumb({
) : (
<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" && (
{(template === "COVER_LEFT_TEXT_RIGHT" ||
template === "COVER_RIGHT_TEXT_LEFT") && (
<>
<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" />
@@ -166,12 +183,18 @@ function MiniThumb({
<div className="absolute left-[34%] bottom-[12%] h-0.5 w-[32%] rounded bg-white/50" />
</>
)}
{(template === "LOWER_LEFT_COVER_TEXT" ||
template === "LOWER_RIGHT_COVER_TEXT") && (
<>
<div className="absolute bottom-[8%] left-[5%] h-[36%] w-[24%] bg-gray-300" />
<div className="absolute bottom-[26%] left-[34%] h-1 w-[40%] rounded bg-white/80" />
<div className="absolute bottom-[14%] left-[34%] h-0.5 w-[28%] 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>
<p className="px-2 py-1.5 text-[10px] font-medium text-gray-300">{label}</p>
</button>
);
}
@@ -179,8 +202,18 @@ function MiniThumb({
function previewCoverStyle(
template: LayoutTemplate,
padPct: number,
textPadding: number,
encodeWidth: number,
): CSSProperties {
const p = `${padPct}%`;
// Equal pixel inset on both axes (not % — % of width ≠ % of height on 16:9).
const cornerEdgePx = scaleFontToPreview(textPadding, encodeWidth);
// Assume 16:9 when only width is known; side is usually width-driven for lower corner.
const encodeHeight = Math.round((encodeWidth * 9) / 16);
const cornerSidePx = scaleFontToPreview(
lowerCornerCoverSide(encodeWidth, encodeHeight, textPadding),
encodeWidth,
);
switch (template) {
case "COVER_LEFT_TEXT_RIGHT":
return {
@@ -222,6 +255,24 @@ function previewCoverStyle(
maxHeight: "38%",
objectFit: "contain",
};
case "LOWER_LEFT_COVER_TEXT":
return {
position: "absolute",
left: cornerEdgePx,
bottom: cornerEdgePx,
width: cornerSidePx,
height: cornerSidePx,
objectFit: "contain",
};
case "LOWER_RIGHT_COVER_TEXT":
return {
position: "absolute",
right: cornerEdgePx,
bottom: cornerEdgePx,
width: cornerSidePx,
height: cornerSidePx,
objectFit: "contain",
};
}
}
@@ -231,11 +282,20 @@ function previewTextStyle(
textOffsetX: number,
textOffsetY: number,
titleArtistGap: number,
textPadding: number,
encodeWidth: number,
): CSSProperties {
const p = `${padPct}%`;
const shift = {
transform: undefined as string | undefined,
};
const cornerEdgePx = scaleFontToPreview(textPadding, encodeWidth);
const coverTextGapPx = scaleFontToPreview(
Math.max(10, Math.round(textPadding * 0.55)),
encodeWidth,
);
const encodeHeight = Math.round((encodeWidth * 9) / 16);
const cornerSidePx = scaleFontToPreview(
lowerCornerCoverSide(encodeWidth, encodeHeight, textPadding),
encodeWidth,
);
const baseGap = { display: "flex", flexDirection: "column" as const, gap: `${titleArtistGap}px` };
@@ -282,39 +342,72 @@ function previewTextStyle(
textAlign: "center",
alignItems: "center",
};
case "LOWER_LEFT_COVER_TEXT":
return {
...baseGap,
position: "absolute",
left: cornerEdgePx + cornerSidePx + coverTextGapPx + textOffsetX,
right: p,
bottom: cornerEdgePx,
height: cornerSidePx,
justifyContent: "center",
textAlign: "left",
transform: textOffsetY ? `translateY(${textOffsetY}px)` : undefined,
};
case "LOWER_RIGHT_COVER_TEXT":
return {
...baseGap,
position: "absolute",
left: p,
right: cornerEdgePx + cornerSidePx + coverTextGapPx - textOffsetX,
bottom: cornerEdgePx,
height: cornerSidePx,
justifyContent: "center",
textAlign: "right",
alignItems: "flex-end",
transform: textOffsetY ? `translateY(${textOffsetY}px)` : undefined,
};
}
void shift;
}
export function LayoutStudio({
locked = false,
locked,
previewImageUrl,
title,
previewBackgroundUrl = null,
songTitle,
artist,
encodeWidth = 1280,
layout,
onLayoutChange,
watermark,
onWatermarkChange,
onUploadLogo,
onUploadFont,
onUploadBackground,
onClearBackground,
logoPreviewUrl,
hasCustomBackground = false,
}: 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 [bgUploading, setBgUploading] = useState(false);
const [bgError, setBgError] = useState<string | null>(null);
// Always coalesce HMR / older session state may omit newly added fields
const L: LayoutSettings = {
...DEFAULT_LAYOUT,
...layout,
blurFill: layout.blurFill ?? DEFAULT_LAYOUT.blurFill,
blurAmount: layout.blurAmount ?? DEFAULT_LAYOUT.blurAmount,
blurOpacity: layout.blurOpacity ?? BLUR_OPACITY_DEFAULT,
textPadding: layout.textPadding ?? DEFAULT_LAYOUT.textPadding,
titleArtistGap: layout.titleArtistGap ?? DEFAULT_LAYOUT.titleArtistGap,
textOffsetX: layout.textOffsetX ?? DEFAULT_LAYOUT.textOffsetX,
textOffsetY: layout.textOffsetY ?? DEFAULT_LAYOUT.textOffsetY,
titleBold: layout.titleBold ?? DEFAULT_LAYOUT.titleBold,
};
const W: WatermarkSettings = {
...DEFAULT_WATERMARK,
@@ -337,8 +430,14 @@ export function LayoutStudio({
);
const wmOverlay = useMemo(
() => watermarkOverlayStyle(W.position, W.offsetX, W.offsetY),
[W.position, W.offsetX, W.offsetY],
() =>
watermarkOverlayStyle(
W.position,
W.offsetX,
W.offsetY,
W.mode === "default" || W.mode === "logo",
),
[W.position, W.offsetX, W.offsetY, W.mode],
);
const textFontStyle = useMemo(
@@ -346,14 +445,55 @@ export function LayoutStudio({
[W.fontKey],
);
const titlePreviewPx = useMemo(
() => scaleFontToPreview(titleFontSizeForWidth(encodeWidth), encodeWidth),
[encodeWidth],
);
const artistPreviewPx = useMemo(
() => scaleFontToPreview(artistFontSizeForWidth(encodeWidth), encodeWidth),
[encodeWidth],
);
const wmTextPreviewPx = useMemo(
() => scaleFontToPreview(watermarkFontSizeForWidth(encodeWidth), encodeWidth),
[encodeWidth],
);
useEffect(() => {
const 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);
const styleId = "s2vid-preview-curated-fonts";
let el = document.getElementById(styleId) as HTMLStyleElement | null;
if (!el) {
el = document.createElement("style");
el.id = styleId;
document.head.appendChild(el);
}
el.textContent = [
`@font-face {
font-family: '${SYSTEM_FONT.previewFamily}';
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}') format('truetype');
font-weight: 400;
font-display: swap;
}`,
`@font-face {
font-family: '${SYSTEM_FONT.previewFamily}';
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}?weight=bold') format('truetype');
font-weight: 600;
font-display: swap;
}`,
`@font-face {
font-family: '${SYSTEM_FONT.previewFamily}';
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}?weight=bold') format('truetype');
font-weight: 700;
font-display: swap;
}`,
...CURATED_FONTS.map(
(f) => `@font-face {
font-family: 'S2VIDPreview-${f.key}';
src: url('${curatedFontApiUrl(f.key)}') format('truetype');
font-weight: 400;
font-display: swap;
}`,
),
].join("\n");
}, []);
useEffect(() => {
@@ -416,7 +556,7 @@ export function LayoutStudio({
if (customFontObjectUrl) URL.revokeObjectURL(customFontObjectUrl);
setCustomFontObjectUrl(objectUrl);
const path = await onUploadFont(file);
patchWm({ mode: "text", fontKey: "custom", fontPath: path });
patchWm({ fontKey: "custom", fontPath: path });
} catch (err) {
setFontError(err instanceof Error ? err.message : "Font upload failed");
} finally {
@@ -424,6 +564,21 @@ export function LayoutStudio({
}
}
async function handleBackground(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file || locked || !onUploadBackground) return;
setBgError(null);
setBgUploading(true);
try {
await onUploadBackground(file);
} catch (err) {
setBgError(err instanceof Error ? err.message : "Background upload failed");
} finally {
setBgUploading(false);
}
}
function setFontKey(key: WatermarkFontKey) {
if (key === "custom") {
patchWm({ fontKey: "custom", fontPath: watermark.fontPath ?? null });
@@ -433,9 +588,15 @@ export function LayoutStudio({
}
const artTrack = Boolean(L.template);
const lowerCorner = isLowerCornerTemplate(L.template);
const blurPreviewUrl = previewBackgroundUrl || previewImageUrl;
return (
<div className="relative rounded-lg border border-gray-700 bg-surface p-6">
<div
className={`relative rounded-lg border border-gray-700 bg-surface p-6 ${
locked ? "opacity-80" : ""
}`}
>
<div className="mb-4 flex flex-wrap items-center gap-2">
<h3 className="text-lg font-medium text-white">Video layout</h3>
</div>
@@ -452,7 +613,7 @@ export function LayoutStudio({
<div className="absolute inset-0 bg-black" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewImageUrl}
src={blurPreviewUrl!}
alt=""
className="absolute inset-0 h-full w-full object-cover"
style={{
@@ -465,7 +626,12 @@ export function LayoutStudio({
<img
src={previewImageUrl}
alt=""
style={previewCoverStyle(L.template!, padPct)}
style={previewCoverStyle(
L.template!,
padPct,
L.textPadding,
encodeWidth,
)}
className="pointer-events-none"
/>
<div
@@ -475,56 +641,90 @@ export function LayoutStudio({
L.textOffsetX,
L.textOffsetY,
L.titleArtistGap,
L.textPadding,
encodeWidth,
)}
>
<p
className="max-w-full truncate text-sm font-semibold text-white drop-shadow"
style={
W.mode === "text" ? textFontStyle : undefined
}
className={`max-w-full truncate text-white drop-shadow ${
L.titleBold ? "font-semibold" : "font-normal"
}`}
style={{
...textFontStyle,
fontWeight: L.titleBold ? 600 : 400,
fontSize: `${titlePreviewPx}px`,
lineHeight: 1.15,
}}
>
{title.trim() || "Track title"}
{songTitle.trim() || "Track title"}
</p>
<p
className="max-w-full truncate text-xs text-white/75 drop-shadow"
style={
W.mode === "text" ? textFontStyle : undefined
}
className="max-w-full truncate text-white/75 drop-shadow"
style={{
...textFontStyle,
fontSize: `${artistPreviewPx}px`,
lineHeight: 1.15,
}}
>
{artist.trim() || "Artist"}
</p>
</div>
</>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-black">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewImageUrl}
alt=""
className="max-h-full max-w-full object-contain"
/>
<div className="absolute inset-0 bg-black">
{L.blurFill && (
// 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,
}}
/>
)}
<div className="absolute inset-0 flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewImageUrl}
alt=""
className="max-h-full max-w-full object-contain"
/>
</div>
</div>
)}
{W.mode !== "none" && (
<div style={wmOverlay} className="rounded bg-black/40 px-2 py-1">
{W.mode === "logo" && logoPreviewUrl ? (
<div style={wmOverlay}>
{W.mode === "default" ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src="/api/branding/watermark"
alt=""
className="h-auto w-full object-contain"
/>
) : W.mode === "logo" && logoPreviewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={logoPreviewUrl}
alt=""
className="max-h-14 w-auto object-contain"
className="h-auto w-full object-contain"
/>
) : (
) : W.mode === "text" ? (
<span
className="text-[11px] font-medium text-white/90 drop-shadow"
style={W.mode === "text" ? textFontStyle : undefined}
className="font-medium text-white/90 drop-shadow"
style={{
...textFontStyle,
fontSize: `${wmTextPreviewPx}px`,
}}
>
{W.mode === "text" && W.text?.trim()
{W.text?.trim()
? W.text.trim().slice(0, WATERMARK_TEXT_MAX)
: getVideoAttributionText()}
</span>
)}
) : null}
</div>
)}
</>
@@ -538,22 +738,114 @@ export function LayoutStudio({
{/* 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 })}
/>
))}
{COMPOSITION_FAMILIES.map((family) => {
const active =
family.id === "classic"
? L.template === null
: family.variants.includes(L.template as LayoutTemplate);
return (
<MiniThumb
key={family.id}
template={family.thumb}
label={family.label}
active={active}
disabled={locked}
onClick={() => {
if (family.id === "classic") {
patchLayout({ template: null });
return;
}
if (
L.template &&
family.variants.includes(L.template as LayoutTemplate)
) {
return;
}
patchLayout({ template: family.defaultTemplate, blurFill: false });
}}
/>
);
})}
</div>
{(() => {
const family = compositionFamilyForTemplate(L.template);
if (family.variants.length < 2) return null;
return (
<div className="mt-2 flex flex-wrap gap-2">
{family.variants.map((variant) => (
<button
key={variant}
type="button"
disabled={locked}
onClick={() => patchLayout({ template: variant })}
className={`rounded border px-2.5 py-1.5 text-[11px] font-medium transition-colors ${
L.template === variant
? "border-accent bg-accent/15 text-white"
: "border-gray-700 bg-black/40 text-gray-300 hover:border-gray-500"
} disabled:cursor-not-allowed disabled:opacity-50`}
>
{LAYOUT_TEMPLATE_LABELS[variant]}
</button>
))}
</div>
);
})()}
{!artTrack && (
<label className="mt-3 flex cursor-pointer items-start gap-2 text-sm text-gray-300">
<input
type="checkbox"
className="mt-0.5"
disabled={locked}
checked={L.blurFill}
onChange={(e) => patchLayout({ blurFill: e.target.checked })}
/>
<span>
<span className="font-medium text-gray-200">Blurred cover background</span>
<span className="mt-0.5 block text-xs text-gray-500">
Fill letterbox bars with a blurred cover instead of black. Adjust blur and opacity
below when enabled.
</span>
</span>
</label>
)}
{lowerCorner && (
<div className="mt-3 rounded border border-gray-700 bg-black/30 p-3">
<p className="mb-2 text-sm font-medium text-gray-300">Background image</p>
<p className="mb-2 text-xs text-gray-500">
Optional. Used as the blurred full-frame fill; the cover stays the sharp corner square.
Leave empty to blur the cover itself.
</p>
<div className="flex flex-wrap items-center gap-2">
<label
className={`cursor-pointer rounded border border-gray-600 bg-surface-dark px-3 py-1.5 text-xs text-gray-200 hover:border-gray-400 ${
locked || bgUploading ? "pointer-events-none opacity-40" : ""
}`}
>
{bgUploading ? "Uploading…" : hasCustomBackground ? "Replace image" : "Upload image"}
<input
type="file"
accept="image/*"
className="hidden"
disabled={locked || bgUploading || !onUploadBackground}
onChange={handleBackground}
/>
</label>
{hasCustomBackground && (
<button
type="button"
disabled={locked}
onClick={() => onClearBackground?.()}
className="rounded border border-gray-700 px-3 py-1.5 text-xs text-gray-400 hover:border-gray-500 hover:text-gray-200 disabled:opacity-40"
>
Use cover as background
</button>
)}
</div>
{bgError && <p className="mt-2 text-xs text-red-400">{bgError}</p>}
</div>
)}
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<label className="text-sm text-gray-400">
@@ -562,7 +854,7 @@ export function LayoutStudio({
type="range"
min={BLUR_AMOUNT_MIN}
max={BLUR_AMOUNT_MAX}
disabled={locked || !artTrack}
disabled={locked || !(artTrack || L.blurFill)}
value={L.blurAmount}
onChange={(e) => patchLayout({ blurAmount: Number(e.target.value) })}
className="mt-1 w-full disabled:opacity-40"
@@ -574,7 +866,7 @@ export function LayoutStudio({
type="range"
min={BLUR_OPACITY_MIN}
max={BLUR_OPACITY_MAX}
disabled={locked || !artTrack}
disabled={locked || !(artTrack || L.blurFill)}
value={L.blurOpacity}
onChange={(e) => patchLayout({ blurOpacity: Number(e.target.value) })}
className="mt-1 w-full disabled:opacity-40"
@@ -630,6 +922,71 @@ export function LayoutStudio({
className="mt-1 w-full disabled:opacity-40"
/>
</label>
<label
className={`flex items-center gap-2 text-sm text-gray-300 sm:col-span-2 ${
locked || !artTrack ? "opacity-40" : ""
}`}
>
<input
type="checkbox"
className="rounded border-gray-600 bg-surface-dark text-accent focus:ring-accent"
disabled={locked || !artTrack}
checked={L.titleBold}
onChange={(e) => patchLayout({ titleBold: e.target.checked })}
/>
Bold song title
</label>
</div>
{/* Typography (matches FFmpeg art-track + watermark text fonts) */}
<div className="mt-6 border-t border-gray-800 pt-5">
<p className="mb-3 text-sm font-medium text-gray-300">Typography</p>
<label className="block text-sm text-gray-400">
Font
<select
disabled={locked}
value={W.fontKey ?? "system"}
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
className="input-field mt-1"
>
<option value="system">{SYSTEM_FONT.label}</option>
{CURATED_FONTS.map((f) => (
<option key={f.key} value={f.key}>
{f.label}
</option>
))}
<option value="custom">Custom upload (.ttf / .otf)</option>
</select>
</label>
{W.fontKey === "custom" && (
<div className="mt-3">
<label className="mb-1 block text-sm text-gray-400">
Upload font (.ttf or .otf, max 10 MB)
</label>
<input
type="file"
accept=".ttf,.otf,font/ttf,font/otf"
disabled={locked || fontUploading}
onChange={(e) => void handleFont(e)}
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white"
/>
{fontUploading && (
<p className="mt-1 text-xs text-yellow-400">Uploading font</p>
)}
{fontError && <p className="mt-1 text-xs text-red-400">{fontError}</p>}
</div>
)}
{(W.fontKey === "custom" ||
(W.fontKey &&
W.fontKey !== "system" &&
CURATED_FONTS.some((f) => f.key === (W.fontKey as CuratedFontKey)))) && (
<p
className="mt-2 text-xs text-gray-500"
style={{ ...textFontStyle, fontSize: `${scaleFontToPreview(16, encodeWidth)}px` }}
>
Preview: The quick brown fox jumps over the lazy dog
</p>
)}
</div>
{/* Watermark section */}
@@ -674,51 +1031,6 @@ export function LayoutStudio({
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>
)}
+17 -5
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { DOCS_URL, GITEA_URL } from "@/lib/plans";
function LegalLink({
href,
@@ -33,7 +34,16 @@ export function LegalFooter() {
return (
<footer className="mt-auto w-full border-t border-gray-800 bg-black/40 py-8 text-xs text-gray-500">
<div className="mx-auto flex max-w-4xl flex-wrap items-center justify-center gap-x-4 gap-y-2 px-6 md:justify-start">
<span>© {year} Songs2YT. All rights reserved.</span>
<span>© {year} Songs2VID. MIT licensed.</span>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<span>
Made with by{" "}
<LegalLink href="https://www.atakanozban.com" external>
atakan
</LegalLink>
</span>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
@@ -41,16 +51,18 @@ export function LegalFooter() {
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<LegalLink href="/terms">Terms of Service</LegalLink>
<LegalLink href="/terms">Terms of Use</LegalLink>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<LegalLink href="/refund">Refund Policy</LegalLink>
<LegalLink href={DOCS_URL} external>
Documentation
</LegalLink>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<LegalLink href="https://status.atakanozban.com/status/2" external>
Service Status
<LegalLink href={GITEA_URL} external>
Source
</LegalLink>
</div>
</footer>
+3 -5
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import type { ReactNode } from "react";
import { Logo } from "@/components/Logo";
import { LEGAL_LAST_UPDATED } from "@/lib/legal/constants";
import { DOCS_URL } from "@/lib/plans";
type Props = {
title: string;
@@ -44,16 +45,13 @@ export function LegalPageLayout({ title, description, children }: Props) {
<Link href="/terms" className="hover:text-gray-300">
Terms
</Link>
<Link href="/refund" className="hover:text-gray-300">
Refund
</Link>
<a
href="https://status.atakanozban.com/status/2"
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="hover:text-gray-300"
>
Service Status
Documentation
</a>
</div>
</footer>
+9 -6
View File
@@ -1,30 +1,33 @@
import Link from "next/link";
import { BRAND_NAME } from "@/lib/branding";
type Props = {
href?: string;
size?: "sm" | "md";
size?: "sm" | "md" | "lg";
};
export function Logo({ href = "/", size = "md" }: Props) {
const textSize = size === "sm" ? "text-xl" : "text-2xl";
const textSize = size === "sm" ? "text-xl" : size === "lg" ? "text-3xl" : "text-2xl";
const content = (
<span className={`inline-flex items-baseline font-bold tracking-tight ${textSize}`}>
<span className={`relative inline-flex items-baseline font-bold tracking-tight ${textSize}`}>
<span className="text-white">Songs</span>
<span className="text-red-400">2YT</span>
<span className="text-red-400">2VID</span>
</span>
);
const label = BRAND_NAME;
if (href) {
return (
<Link href={href} className="inline-flex items-center" aria-label="Songs2YT">
<Link href={href} className="inline-flex items-center" aria-label={label}>
{content}
</Link>
);
}
return (
<span className="inline-flex items-center" aria-label="Songs2YT">
<span className="inline-flex items-center" aria-label={label}>
{content}
</span>
);
+1 -3
View File
@@ -79,8 +79,6 @@ export function PlaylistSelect({ value, onChange, enabled }: Props) {
}
}
if (!enabled) { return null; }
return (
<div className="space-y-3">
<div className="space-y-2">
@@ -133,7 +131,7 @@ export function PlaylistSelect({ value, onChange, enabled }: Props) {
}
}}
className="input-field w-full"
placeholder="My Songs2YT uploads"
placeholder="My Songs2VID uploads"
/>
</div>
<div>
+2 -4
View File
@@ -1,17 +1,15 @@
"use client";
import { Plan } from "@prisma/client";
import { getResolutionsForPlan } from "@/lib/plans";
type Props = {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
plan?: Plan;
};
export function ResolutionSelect({ value, onChange, disabled, plan = "FREE" }: Props) {
const resolutions = getResolutionsForPlan(plan);
export function ResolutionSelect({ value, onChange, disabled }: Props) {
const resolutions = getResolutionsForPlan();
return (
<select
-58
View File
@@ -1,58 +0,0 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
type Direction = "up" | "left" | "right";
type Props = {
children: ReactNode;
className?: string;
delay?: number;
direction?: Direction;
};
const OFFSET: Record<Direction, string> = {
up: "translate-y-10",
left: "-translate-x-10",
right: "translate-x-10",
};
export function ScrollReveal({
children,
className = "",
delay = 0,
direction = "up",
}: Props) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.unobserve(el);
}
},
{ threshold: 0.15, rootMargin: "0px 0px -40px 0px" },
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={`transition-all duration-700 ease-out ${className} ${
visible ? "translate-x-0 translate-y-0 opacity-100" : `opacity-0 ${OFFSET[direction]}`
}`}
style={{ transitionDelay: `${delay}ms` }}
>
{children}
</div>
);
}
-46
View File
@@ -1,46 +0,0 @@
"use client";
import { useEffect, useState, type RefObject } from "react";
type Props = {
sectionRef: RefObject<HTMLElement | null>;
title: string;
offset?: number;
};
export function SectionScrollTitle({ sectionRef, title, offset = 350 }: Props) {
const [offsetX, setOffsetX] = useState(0);
useEffect(() => {
const section = sectionRef.current;
if (!section) return;
const update = () => {
const rect = section.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const progress = Math.min(
1,
Math.max(0, (viewportHeight - rect.top) / (viewportHeight + rect.height * 0.5)),
);
setOffsetX(progress * offset);
};
update();
window.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
return () => {
window.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
};
}, [sectionRef, offset]);
return (
<span
className="pointer-events-none absolute bottom-0 left-1/2 z-0 select-none whitespace-nowrap text-7xl font-bold leading-none text-white/[0.04] sm:text-8xl lg:text-9xl"
style={{ transform: `translateX(calc(-50% + ${offsetX}px))` }}
aria-hidden="true"
>
{title}
</span>
);
}
-234
View File
@@ -1,234 +0,0 @@
"use client";
import { ScrollReveal } from "@/components/ScrollReveal";
import { useInView } from "@/hooks/useInView";
import { useMockupProgress } from "@/hooks/useMockupProgress";
function LayersIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
);
}
function SlidersIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3" />
<circle cx="4" cy="14" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="20" cy="16" r="2" />
</svg>
);
}
function CloudUploadIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M12 16V8M12 8l-3 3M12 8l3 3" />
<path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.9-1A4 4 0 1 1 17 18H7z" />
</svg>
);
}
function ImageIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="M21 15l-5-5L5 21" />
</svg>
);
}
function AudioIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
);
}
const STEPS = [
{
number: 1,
title: "Single/Batch creation",
desc: "One image, many audios, each becomes a separate video.",
Icon: LayersIcon,
},
{
number: 2,
title: "Per-video settings",
desc: "Title, tags, privacy, and category for every upload.",
Icon: SlidersIcon,
},
{
number: 3,
title: "Direct upload",
desc: "Connect YouTube once and publish automatically.",
Icon: CloudUploadIcon,
},
] as const;
function CheckIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
aria-hidden="true"
>
<path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function AppMockup() {
const phaseOneMs = 3000;
const phaseTwoMs = 2000;
const initialWidth = "20%";
const midWidth = "45%";
const { ref, inView } = useInView();
const { phase, processed, transitionMs } = useMockupProgress(inView, phaseOneMs, phaseTwoMs);
const width = phase === 0 ? initialWidth : phase === 1 ? midWidth : "100%";
return (
<div
ref={ref}
className="mt-10 overflow-hidden rounded-2xl border border-gray-700/60 bg-surface-dark shadow-2xl shadow-black/40"
>
<div className="flex items-center gap-2 border-b border-gray-700/50 px-4 py-3">
<span className="h-2.5 w-2.5 rounded-full bg-gray-600" />
<span className="h-2.5 w-2.5 rounded-full bg-gray-600" />
<span className="h-2.5 w-2.5 rounded-full bg-gray-600" />
</div>
<div className="p-6">
<div className="rounded-xl border border-gray-700/50 bg-surface p-6">
<div className="grid grid-cols-2 gap-4">
<div className="mockup-upload-box mockup-upload-box-image flex flex-col items-center justify-center rounded-lg border border-dashed border-gray-600 bg-surface-dark px-4 py-8">
<ImageIcon className="mockup-icon-image mb-3 h-8 w-8 text-gray-500" />
<span className="text-xs font-semibold tracking-widest text-gray-500">IMAGE</span>
</div>
<div className="mockup-upload-box mockup-upload-box-audio flex flex-col items-center justify-center rounded-lg border border-dashed border-gray-600 bg-surface-dark px-4 py-8">
<AudioIcon className="mockup-icon-audio mb-3 h-8 w-8 text-gray-500" />
<span className="text-xs font-semibold tracking-widest text-gray-500">AUDIO</span>
</div>
</div>
<p className="mt-6 text-sm text-gray-400">
{processed ? (
"Processed"
) : (
<>
Processing
<span className="mockup-dot-1">.</span>
<span className="mockup-dot-2">.</span>
<span className="mockup-dot-3">.</span>
</>
)}
</p>
<div className="mt-2 flex items-center gap-2">
<div className="relative h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-gray-700">
<div
className="relative h-full rounded-full bg-white/90 ease-out"
style={{
width,
transition: phase >= 1 ? `width ${transitionMs}ms ease-out` : "none",
}}
>
{!processed && (
<span className="mockup-progress-shimmer absolute inset-y-0 w-1/2 rounded-full bg-gradient-to-r from-transparent via-white/50 to-transparent" />
)}
</div>
</div>
<CheckIcon
className={`h-4 w-4 shrink-0 text-accent transition-all duration-300 ${
processed ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
/>
</div>
</div>
</div>
</div>
);
}
function StepCard({
number,
title,
desc,
Icon,
}: {
number: number;
title: string;
desc: string;
Icon: typeof LayersIcon;
}) {
return (
<div className="group relative overflow-hidden rounded-2xl border border-gray-700/50 bg-surface transition-all duration-300 hover:border-gray-500 hover:bg-surface-light hover:shadow-xl hover:shadow-black/30">
<span
className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 select-none text-[7.5rem] font-bold leading-none text-gray-600/20 transition-all duration-500 ease-out group-hover:left-1/2 group-hover:-translate-x-1/2 group-hover:scale-[1.18] group-hover:text-red-500/25 sm:text-[8.5rem]"
aria-hidden="true"
>
{number}
</span>
<div className="relative flex items-center gap-6 px-8 py-9 pl-24 sm:pl-28">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl border border-gray-600/50 bg-surface-dark text-gray-400 transition-all duration-300 group-hover:border-red-500/30 group-hover:text-red-400">
<Icon className="h-7 w-7" />
</div>
<div>
<h3 className="text-lg font-semibold text-white transition-colors duration-300 group-hover:text-red-400 sm:text-xl">
{title}
</h3>
<p className="mt-2 text-base leading-relaxed text-gray-400 transition-colors duration-300 group-hover:text-gray-300">
{desc}
</p>
</div>
</div>
</div>
);
}
export function StepsSection() {
return (
<section className="mx-auto max-w-6xl scroll-mt-20 px-6 pb-24 pt-32">
<div className="grid items-center gap-12 lg:grid-cols-2 lg:gap-16">
<ScrollReveal direction="left">
<div>
<h2 className="text-2xl font-bold leading-snug text-white sm:text-3xl">
Upload your content in 3 simple steps:
</h2>
<p className="mt-6 text-base leading-relaxed text-gray-400">
From a single track to a full batch, Songs2YT walks you through creating and
publishing videos to YouTube without touching a video editor.
</p>
<AppMockup />
</div>
</ScrollReveal>
<div className="flex flex-col gap-7">
{STEPS.map((step, index) => (
<ScrollReveal key={step.number} delay={index * 120} direction="right">
<StepCard
number={step.number}
title={step.title}
desc={step.desc}
Icon={step.Icon}
/>
</ScrollReveal>
))}
</div>
</div>
</section>
);
}
-116
View File
@@ -1,116 +0,0 @@
"use client";
import { useRef } from "react";
import { ScrollReveal } from "@/components/ScrollReveal";
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
import { GITEA_ISSUES_URL, SUPPORT_EMAIL } from "@/lib/plans";
type SupportCardProps = {
title: string;
description: string;
href: string;
cta: string;
external?: boolean;
hover: string;
titleHover: string;
linkClass: string;
linkHover: string;
};
function SupportCard({
title,
description,
href,
cta,
external,
hover,
titleHover,
linkClass,
linkHover,
}: SupportCardProps) {
return (
<a
href={href}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
className={`group block rounded-xl border border-gray-700/50 bg-surface/80 p-6 backdrop-blur-sm transition-all duration-300 ${hover}`}
>
<h3
className={`mb-2 text-lg font-semibold text-white transition-colors duration-300 ${titleHover}`}
>
{title}
</h3>
<p className="mb-4 text-sm leading-relaxed text-gray-400 transition-colors duration-300 group-hover:text-gray-300">
{description}
</p>
<span
className={`inline-flex text-sm font-medium transition-all duration-300 group-hover:underline ${linkClass} ${linkHover}`}
>
{cta}
</span>
</a>
);
}
const SUPPORT_CARDS: SupportCardProps[] = [
{
title: "🛠️ Community & Self-Hosting",
description:
"Found a bug, want to request a feature, or need help setting up your Docker instance? Open an issue on our self-hosted Gitea.",
href: GITEA_ISSUES_URL,
cta: "Open Gitea Issues",
external: true,
hover:
"hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20",
titleHover: "group-hover:text-[#609926]",
linkClass: "text-[#609926]",
linkHover: "group-hover:text-[#7ab33a]",
},
{
title: "📩 Billing & Premium Support",
description:
"Have questions about your Pro subscription, custom limits, or Enterprise inquiries? Drop us an email.",
href: `mailto:${SUPPORT_EMAIL}`,
cta: SUPPORT_EMAIL,
hover:
"hover:border-accent/50 hover:bg-accent/[0.06] hover:shadow-lg hover:shadow-accent/20",
titleHover: "group-hover:text-accent",
linkClass: "text-accent",
linkHover: "group-hover:text-accent-hover",
},
];
export function SupportSection() {
const sectionRef = useRef<HTMLElement>(null);
return (
<section
ref={sectionRef}
id="support"
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 pb-24 pt-24"
>
<SectionScrollTitle sectionRef={sectionRef} title="Support" />
<div className="relative z-10 mx-auto max-w-4xl">
<ScrollReveal>
<h2 className="mb-4 text-center text-3xl font-bold text-white">Support</h2>
<p className="mx-auto mb-10 max-w-2xl text-center text-gray-400">
Community help for self-hosters, or reach us directly for billing and premium support.
</p>
</ScrollReveal>
<div className="mx-auto mt-10 grid grid-cols-1 gap-8 text-left md:grid-cols-2">
{SUPPORT_CARDS.map((card, index) => (
<ScrollReveal
key={card.title}
direction={index === 0 ? "left" : "right"}
delay={index * 120}
>
<SupportCard {...card} />
</ScrollReveal>
))}
</div>
</div>
</section>
);
}
+90 -48
View File
@@ -2,18 +2,20 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Plan, Privacy } from "@prisma/client";
import { Privacy } from "@prisma/client";
import { filenameWithoutExtension } from "@/lib/constants";
import { getVideoAttributionText } from "@/lib/branding";
import { audioTagsToMetadata } from "@/lib/audio-tags";
import { resolveYouTubeTitle } from "@/lib/titles";
import { SONG_TITLE_MAX } from "@/lib/layout";
import type { ItemMetadata } from "@/lib/types";
import { DEFAULT_LAYOUT, type LayoutSettings } from "@/lib/layout";
import { DEFAULT_LAYOUT, isLowerCornerTemplate, type LayoutSettings } from "@/lib/layout";
import { DEFAULT_WATERMARK, type WatermarkSettings } from "@/lib/watermark";
import { CategorySelect } from "./CategorySelect";
import { LayoutStudio } from "./LayoutStudio";
import { PlaylistSelect } from "./PlaylistSelect";
import { PrivacyToggle } from "./PrivacyToggle";
import { ResolutionSelect } from "./ResolutionSelect";
const UPLOAD_CONCURRENCY = 4;
type AudioItem = {
@@ -43,6 +45,7 @@ function defaultMetadata(title = ""): ItemMetadata {
playlistId: null,
imagePath: null,
artist: null,
songTitle: null,
watermark: { ...DEFAULT_WATERMARK },
layout: { ...DEFAULT_LAYOUT },
};
@@ -62,8 +65,9 @@ export function UploadForm() {
const [jobWatermark, setJobWatermark] = useState<WatermarkSettings>({ ...DEFAULT_WATERMARK });
const [jobLayout, setJobLayout] = useState<LayoutSettings>({ ...DEFAULT_LAYOUT });
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
const [backgroundImagePath, setBackgroundImagePath] = useState<string | null>(null);
const [backgroundPreviewUrl, setBackgroundPreviewUrl] = useState<string | null>(null);
const [quota, setQuota] = useState<{
plan: Plan;
maxBatchSize: number;
used: number;
} | null>(null);
@@ -73,7 +77,6 @@ export function UploadForm() {
if (res.ok) {
const data = await res.json();
setQuota({
plan: data.plan,
maxBatchSize: data.maxBatchSize,
used: data.used ?? 0,
});
@@ -88,6 +91,7 @@ export function UploadForm() {
return () => {
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
if (logoPreviewUrl) URL.revokeObjectURL(logoPreviewUrl);
if (backgroundPreviewUrl) URL.revokeObjectURL(backgroundPreviewUrl);
audioItems.forEach((a) => {
if (a.itemImagePreview) URL.revokeObjectURL(a.itemImagePreview);
});
@@ -198,10 +202,6 @@ export function UploadForm() {
}
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);
@@ -261,6 +261,21 @@ export function UploadForm() {
return uploaded.path;
}
async function handleBackgroundUpload(file: File) {
const preview = URL.createObjectURL(file);
if (backgroundPreviewUrl) URL.revokeObjectURL(backgroundPreviewUrl);
setBackgroundPreviewUrl(preview);
const uploaded = await uploadFile(file, "image");
setBackgroundImagePath(uploaded.path);
return uploaded.path;
}
function clearBackgroundImage() {
if (backgroundPreviewUrl) URL.revokeObjectURL(backgroundPreviewUrl);
setBackgroundPreviewUrl(null);
setBackgroundImagePath(null);
}
function applyWatermarkToAll(next: WatermarkSettings) {
const normalized = { ...DEFAULT_WATERMARK, ...next };
setJobWatermark(normalized);
@@ -279,6 +294,9 @@ export function UploadForm() {
function applyLayoutToAll(next: LayoutSettings) {
const normalized = { ...DEFAULT_LAYOUT, ...next };
setJobLayout(normalized);
if (!isLowerCornerTemplate(normalized.template)) {
clearBackgroundImage();
}
setAudioItems((prev) =>
prev.map((item) => ({
...item,
@@ -295,9 +313,9 @@ export function UploadForm() {
if (!files.length) return;
setError(null);
const maxBatch = quota?.maxBatchSize ?? 3;
const maxBatch = quota?.maxBatchSize ?? 100;
if (audioItems.length + files.length > maxBatch) {
setError(`Your plan allows up to ${maxBatch} audio files per batch.`);
setError(`You can upload up to ${maxBatch} audio files per batch.`);
e.target.value = "";
return;
}
@@ -338,17 +356,28 @@ export function UploadForm() {
imagePath &&
!imageUploading &&
readyAudios.length > 0 &&
readyAudios.every((a) => a.metadata.title.trim()) &&
readyAudios.every((a) =>
Boolean(
resolveYouTubeTitle(
{
title: a.metadata.title,
songTitle: a.metadata.songTitle,
artist: a.metadata.artist,
},
),
),
) &&
!submitting;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
async function submitJob() {
if (!canSubmit || !imagePath) return;
setSubmitting(true);
setError(null);
try {
const effectiveWatermark: WatermarkSettings = jobWatermark;
const res = await fetch("/api/jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -359,17 +388,14 @@ export function UploadForm() {
audioFilename: item.file.name,
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,
},
imagePath: item.itemImagePath || item.metadata.imagePath || null,
includeWatermark: effectiveWatermark.mode !== "none",
watermark: effectiveWatermark,
layout: jobLayout,
backgroundImagePath:
isLowerCornerTemplate(jobLayout.template) && backgroundImagePath
? backgroundImagePath
: null,
},
})),
}),
@@ -377,9 +403,6 @@ export function UploadForm() {
const data = await res.json();
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");
}
@@ -390,11 +413,18 @@ export function UploadForm() {
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!canSubmit || !imagePath) return;
await submitJob();
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{quota && (
<div className="rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm text-gray-300">
Self-hosted · {quota.used} videos processed · up to {quota.maxBatchSize} files per batch
Self-hosted · {quota.used} videos created · up to {quota.maxBatchSize} files per batch
</div>
)}
@@ -423,7 +453,7 @@ export function UploadForm() {
</span>
</div>
<p className="mt-2 text-xs text-gray-500">
Shared cover for all tracks. Optionally override per audio below.
Shared cover for all tracks. You can override it per audio below.
</p>
</div>
@@ -452,14 +482,15 @@ export function UploadForm() {
<section className="space-y-4">
<h2 className="text-lg font-medium text-white">Video details (per audio)</h2>
<p className="text-sm text-gray-400">
Each audio file gets its own metadata. Title is auto-filled from the filename.
Each audio file gets its own metadata. Video title is used for YouTube; song title and
artist appear in art-track layouts.
</p>
<div className="rounded-lg border border-gray-700 bg-surface p-6">
<PlaylistSelect
value={playlistId}
onChange={handlePlaylistChange}
enabled={true}
enabled
/>
</div>
@@ -487,29 +518,40 @@ export function UploadForm() {
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Title">
<Field label="Video title (YouTube)">
<input
type="text"
value={item.metadata.title}
onChange={(e) => updateItemMetadata(item.id, { title: e.target.value })}
className="input-field"
required
placeholder="Title shown on YouTube"
/>
</Field>
<Field label="Artist (art-track layouts)">
<Field label="Song title (on-video)">
<input
type="text"
value={item.metadata.artist ?? ""}
onChange={(e) => updateItemMetadata(item.id, { artist: e.target.value })}
value={item.metadata.songTitle ?? ""}
onChange={(e) => updateItemMetadata(item.id, { songTitle: e.target.value })}
className="input-field"
placeholder="Optional shown in layout templates"
maxLength={80}
placeholder="Shown in art-track layout"
maxLength={SONG_TITLE_MAX}
/>
</Field>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Artist (on-video)">
<input
type="text"
value={item.metadata.artist ?? ""}
onChange={(e) => updateItemMetadata(item.id, { artist: e.target.value })}
className="input-field"
placeholder="Shown in art-track layout"
maxLength={80}
/>
</Field>
<Field label="Category">
<CategorySelect
value={item.metadata.categoryId}
@@ -548,19 +590,14 @@ export function UploadForm() {
<ResolutionSelect
value={item.metadata.resolution}
onChange={(v) => updateItemMetadata(item.id, { resolution: v })}
plan={quota?.plan}
/>
</Field>
<div className="relative rounded border border-gray-800 bg-surface-light/40 p-3">
<Field
label="Cover for this track (optional)"
>
<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);
@@ -574,7 +611,6 @@ export function UploadForm() {
{item.itemImagePath ? `Using ${item.itemImageName}` : `Uploading ${item.itemImageName}`}
</p>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2">
@@ -598,7 +634,6 @@ export function UploadForm() {
checked={item.metadata.creativeCommons}
onChange={(v) => updateItemMetadata(item.id, { creativeCommons: v })}
/>
</div>
</div>
))}
@@ -610,19 +645,26 @@ export function UploadForm() {
previewImageUrl={
audioItems.find((a) => a.itemImagePreview)?.itemImagePreview || imagePreviewUrl
}
title={audioItems[0]?.metadata.title || "Track title"}
previewBackgroundUrl={backgroundPreviewUrl}
songTitle={
audioItems[0]?.metadata.songTitle ||
audioItems[0]?.metadata.title ||
"Track title"
}
artist={audioItems[0]?.metadata.artist || ""}
encodeWidth={parseInt(readyAudios[0]?.metadata.resolution?.split("x")[0] || "1280", 10)}
layout={jobLayout}
onLayoutChange={applyLayoutToAll}
watermark={jobWatermark}
onWatermarkChange={applyWatermarkToAll}
onUploadLogo={handleLogoUpload}
onUploadFont={handleFontUpload}
onUploadBackground={handleBackgroundUpload}
onClearBackground={clearBackgroundImage}
hasCustomBackground={Boolean(backgroundImagePath)}
logoPreviewUrl={logoPreviewUrl}
/>
<button
type="submit"
disabled={!canSubmit}
+38 -10
View File
@@ -11,9 +11,14 @@ import { getVideoAttributionText } from "@/lib/branding";
import {
CURATED_FONTS,
googleFontsStylesheetUrl,
SYSTEM_FONT,
type CuratedFontKey,
type WatermarkFontKey,
} from "@/lib/fonts";
import {
curatedFontApiUrl,
previewFontFamilyCss,
} from "@/lib/preview-typography";
import {
WATERMARK_OFFSET_MAX,
WATERMARK_OFFSET_MIN,
@@ -26,7 +31,7 @@ import {
type Props = {
enabled: boolean;
locked?: boolean;
locked: boolean;
previewImageUrl: string | null;
value: WatermarkSettings;
onChange: (next: WatermarkSettings) => void;
@@ -78,15 +83,12 @@ function previewStyle(
}
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";
return previewFontFamilyCss(fontKey);
}
export function WatermarkPreview({
enabled,
locked = false,
locked,
previewImageUrl,
value,
onChange,
@@ -110,7 +112,32 @@ export function WatermarkPreview({
[value.fontKey],
);
// Load curated Google Fonts for live canvas preview
// Load bundled + curated fonts for live canvas preview (same files as FFmpeg)
useEffect(() => {
const styleId = "s2vid-watermark-preview-fonts";
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: '${SYSTEM_FONT.previewFamily}';
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}') format('truetype');
font-display: swap;
}`,
...CURATED_FONTS.map(
(f) => `@font-face {
font-family: 'S2VIDPreview-${f.key}';
src: url('${curatedFontApiUrl(f.key)}') format('truetype');
font-display: swap;
}`,
),
].join("\n");
}, []);
// Legacy Google Fonts link (kept for any older preview paths)
useEffect(() => {
const id = "s2vid-watermark-google-fonts";
if (document.getElementById(id)) return;
@@ -198,9 +225,10 @@ export function WatermarkPreview({
return (
<div
className="relative rounded-lg border border-gray-700 bg-surface p-6"
className={`relative rounded-lg border border-gray-700 bg-surface p-6 ${
locked ? "opacity-80" : ""
}`}
>
<div className="mb-4 flex flex-wrap items-center gap-2">
<h3 className="text-lg font-medium text-white">Watermark layout</h3>
</div>
@@ -286,7 +314,7 @@ export function WatermarkPreview({
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
className="input-field mt-1"
>
<option value="system">System default</option>
<option value="system">{SYSTEM_FONT.label}</option>
{CURATED_FONTS.map((f) => (
<option key={f.key} value={f.key} style={{ fontFamily: f.cssFamily }}>
{f.label}
+16 -5
View File
@@ -1,25 +1,36 @@
# Local infra only (Postgres + Redis). Use root docker-compose.yml for full stack.
# Host ports 5433/6380 avoid clashes with other local stacks on 5432/6379.
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: s2yt
POSTGRES_PASSWORD: s2yt
POSTGRES_DB: s2yt
POSTGRES_USER: songs2vid
POSTGRES_PASSWORD: songs2vid
POSTGRES_DB: songs2vid
ports:
- "127.0.0.1:5432:5432"
- "127.0.0.1:5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U songs2vid -d songs2vid"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
restart: unless-stopped
ports:
- "127.0.0.1:6379:6379"
- "127.0.0.1:6380:6379"
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
volumes:
postgres_data:
+9 -9
View File
@@ -3,13 +3,13 @@ services:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: s2yt
POSTGRES_PASSWORD: s2yt
POSTGRES_DB: s2yt
POSTGRES_USER: songs2vid
POSTGRES_PASSWORD: songs2vid
POSTGRES_DB: songs2vid
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U s2yt -d s2yt"]
test: ["CMD-SHELL", "pg_isready -U songs2vid -d songs2vid"]
interval: 5s
timeout: 5s
retries: 10
@@ -27,15 +27,15 @@ services:
retries: 10
web:
image: atakanozban/songs2vid:${S2VID_IMAGE_TAG:-latest}
build: .
restart: unless-stopped
ports:
- "${S2YT_PORT:-3000}:3000"
- "${S2VID_PORT:-3000}:3000"
env_file:
- .env
environment:
S2VID_EDITION: selfhosted
DATABASE_URL: postgresql://s2yt:s2yt@postgres:5432/s2yt
DATABASE_URL: postgresql://songs2vid:songs2vid@postgres:5432/songs2vid
REDIS_URL: redis://redis:6379
UPLOAD_DIR: /app/uploads
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
@@ -50,13 +50,13 @@ services:
sh -c "npx prisma db push && node server.js"
worker:
image: atakanozban/songs2vid:${S2VID_IMAGE_TAG:-latest}
build: .
restart: unless-stopped
env_file:
- .env
environment:
S2VID_EDITION: selfhosted
DATABASE_URL: postgresql://s2yt:s2yt@postgres:5432/s2yt
DATABASE_URL: postgresql://songs2vid:songs2vid@postgres:5432/songs2vid
REDIS_URL: redis://redis:6379
UPLOAD_DIR: /app/uploads
volumes:
+12
View File
@@ -0,0 +1,12 @@
# Songs2VID OSS documentation
This folder ships with the OSS repo so API reference stays available without the docs site.
| Doc | Contents |
|-----|----------|
| [API overview](./api/overview.md) | Auth, rate limits, recommended flows, discovery (`layoutTemplates`, `compositionFamilies`) |
| [API endpoints](./api/endpoints.md) | curl examples for upload, jobs, layouts (incl. lower-corner templates), playlists, errors |
Live docs (same product truth for self-host): [docs.songs2vid.com](https://docs.songs2vid.com)
Setup and env: see the root [README](../README.md) and [`.env.example`](../.env.example).
+380
View File
@@ -0,0 +1,380 @@
# Endpoints
Set these for the examples below:
```bash
export BASE_URL="http://localhost:3000" # local / self-hosted app
export API_KEY="s2yt_live_your_key_here"
```
Live HTML docs: [docs.songs2vid.com/docs/api/endpoints](https://docs.songs2vid.com/docs/api/endpoints).
## Discovery
```bash
curl "$BASE_URL/api/v1"
```
Returns the endpoint list and requirements (**no auth**). No billing routes are listed or implemented.
## Upload a file (two-step)
```bash
curl -X POST "$BASE_URL/api/v1/upload" \
-H "Authorization: Bearer $API_KEY" \
-F "file=@cover.jpg" \
-F "type=image"
```
```bash
curl -X POST "$BASE_URL/api/v1/upload" \
-H "Authorization: Bearer $API_KEY" \
-F "file=@track1.mp3" \
-F "type=audio"
```
```bash
# Optional: PNG watermark logo
curl -X POST "$BASE_URL/api/v1/upload" \
-H "Authorization: Bearer $API_KEY" \
-F "file=@logo.png" \
-F "type=logo"
```
```bash
# Optional: custom font (.ttf / .otf, max 10 MB)
curl -X POST "$BASE_URL/api/v1/upload" \
-H "Authorization: Bearer $API_KEY" \
-F "file=@Brand.ttf" \
-F "type=font"
```
### Request fields
| Field | Required | Notes |
|-------|----------|--------|
| `file` | Yes | Multipart file |
| `type` | Yes | `image` \| `audio` \| `logo` \| `font` |
### Allowed files
| `type` | Formats | Max size |
|--------|---------|----------|
| `image` | JPEG, PNG, WebP, GIF | 500 MB |
| `audio` | MP3, WAV, FLAC | 500 MB |
| `logo` | PNG only | 500 MB |
| `font` | `.ttf` / `.otf` | **10 MB** |
### Response
```json
{
"path": "/uploads/.../track.mp3",
"filename": "track.mp3",
"size": 4123456,
"audioTags": {
"title": "Song Title",
"artist": "Artist Name",
"album": "Album",
"genre": "Electronic",
"year": "2024"
}
}
```
`audioTags` is present for MP3 when tags are readable; otherwise `null`.
## Create job / render from paths (recommended)
Self-hosted unlocks per-track covers, custom watermarks/fonts, and art-track layouts. Max batch size: **100**. Watermarks are optional — nothing forces the default Songs2VID badge.
`POST /api/v1/render` is an **n8n-friendly alias** of `POST /api/v1/jobs` (identical body and response). `GET /api/v1/render` lists jobs like `GET /api/v1/jobs`.
Optional `webhookUrl` (absolute `http(s)` URL) makes Songs2VID POST JSON when items/jobs finish — preferred for n8n.
```bash
curl -X POST "$BASE_URL/api/v1/render" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"imagePath": "/uploads/.../cover.jpg",
"webhookUrl": "https://your-n8n.example/webhook/songs2vid-complete",
"items": [{
"audioPath": "/uploads/.../track.mp3",
"audioFilename": "track.mp3",
"metadata": {
"title": "My Artist - My Track (Official Audio)",
"songTitle": "My Track",
"artist": "My Artist",
"description": "",
"tags": "electronic",
"privacy": "PUBLIC",
"categoryId": "10",
"resolution": "1920x1080",
"notifySubscribers": true,
"madeForKids": false,
"embeddable": true,
"creativeCommons": false,
"includeWatermark": true,
"layout": {
"template": "LOWER_LEFT_COVER_TEXT",
"blurAmount": 60,
"blurOpacity": 85,
"textPadding": 48,
"titleArtistGap": 12,
"titleBold": true,
"textOffsetX": 0,
"textOffsetY": 0
},
"watermark": {
"mode": "default",
"fontKey": "montserrat",
"position": "bottom-right",
"offsetX": 24,
"offsetY": 24
},
"playlistId": null
}
}]
}'
```
Equivalent path: `POST $BASE_URL/api/v1/jobs` with the same JSON.
### Success response
```json
{
"jobId": "clxxxxxxxx",
"itemCount": 1,
"status": "PENDING",
"statusUrl": "/api/v1/jobs/clxxxxxxxx",
"webhookUrl": "https://your-n8n.example/webhook/songs2vid-complete",
"playlist": null
}
```
### Webhook payload
| `event` | Meaning |
|---------|---------|
| `job.item.completed` | One track finished (`youtubeVideoId` set) |
| `job.item.failed` | One track failed (`error` set) |
| `job.completed` | All items succeeded |
| `job.failed` | All items failed |
| `job.partial` | Mix of success and failure |
Community node: [`n8n-nodes-songs2vid`](https://www.npmjs.com/package/n8n-nodes-songs2vid) — see [docs/n8n.md](../n8n.md) and [docs.songs2vid.com/docs/n8n](https://docs.songs2vid.com/docs/n8n).
<details>
<summary>Legacy example without webhook (same metadata shape)</summary>
```bash
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 Artist - My Track (Official Audio)",
"songTitle": "My Track",
"artist": "My Artist",
"privacy": "PUBLIC",
"categoryId": "10",
"resolution": "1920x1080",
"includeWatermark": false
}
}]
}'
```
</details>
### Metadata fields
| Field | Type | Notes |
|-------|------|--------|
| `title` | string | YouTube video title |
| `songTitle` | string \| null | On-video song title for art-track (max **120**) |
| `artist` | string \| null | On-video artist line (max **80**) |
| `description` | string | YouTube description |
| `tags` | string | Comma-separated |
| `privacy` | string | `PUBLIC` \| `PRIVATE` \| `UNLISTED` |
| `categoryId` | string | YouTube category ID |
| `resolution` | string | See resolutions below |
| `notifySubscribers` | boolean | YouTube notify flag |
| `madeForKids` | boolean | Made for kids |
| `embeddable` | boolean | Allow embedding |
| `creativeCommons` | boolean | CC vs standard YouTube license |
| `includeWatermark` | boolean | Apply watermark settings |
| `imagePath` | string \| null | Per-track cover |
| `backgroundImagePath` | string \| null | Lower-corner templates only: separate blur-fill image (else cover is blurred) |
| `playlistId` | string \| null | Existing playlist ID |
| `layout` | object | Art-track layout |
| `watermark` | object | Watermark settings |
Snake_case aliases are accepted for layout/watermark fields.
### Resolutions
| Value | Aspect |
|-------|--------|
| `1920x1080` | 16:9 |
| `1280x720` | 16:9 |
| `854x480` | 16:9 |
| `720x720` | 1:1 |
| `640x360` | 16:9 |
| `426x240` | 16:9 |
### YouTube categories
| ID | Name |
|----|------|
| `1` | Film & Animation |
| `2` | Autos & Vehicles |
| `10` | Music |
| `15` | Pets & Animals |
| `17` | Sports |
| `19` | Travel & Events |
| `20` | Gaming |
| `22` | People & Blogs |
| `23` | Comedy |
| `24` | Entertainment |
| `25` | News & Politics |
| `26` | Howto & Style |
| `27` | Education |
| `28` | Science & Technology |
| `29` | Nonprofits & Activism |
### Watermark fields
`watermark.mode`: `none` | `default` | `text` | `logo`
- `none` — no overlay
- `default` — built-in Songs2VID badge PNG (`assets/watermark.png`)
- `logo` — requires prior `type=logo` upload; set `logoPath`
- `text` — custom string (max **80**); set `text`
`watermark.position`: `top-left` | `top-right` | `bottom-left` | `bottom-right` | `center`
`watermark.offsetX` / `offsetY`: `0``200` (default `20`)
`watermark.fontKey`: `system` | `inter` | `montserrat` | `roboto` | `oswald` | `playfair` | `custom` (styles art-track text and text watermarks)
### Art-track layouts
`metadata.layout.template` (or flat `layout_template` / `layoutTemplate`). Valid enums are also listed on `GET /api/v1` as `layoutTemplates`. Mirrored pairs used by the Layout Studio composition grid are listed under `compositionFamilies` (`side`: cover beside text; `lower`: lower corner).
| Enum | Description |
|------|-------------|
| `COVER_LEFT_TEXT_RIGHT` | Cover left, title & artist right |
| `COVER_TOP_TEXT_BOTTOM` | Cover top, title & artist below |
| `COVER_RIGHT_TEXT_LEFT` | Cover right, title & artist left |
| `CENTERED_COMPACT` | Centered cover + text stack |
| `LOWER_LEFT_COVER_TEXT` | Lower-left cover; `textPadding` is equal left + bottom inset (diagonal from frame corner) with title/artist to the right |
| `LOWER_RIGHT_COVER_TEXT` | Lower-right cover; `textPadding` is equal right + bottom inset (diagonal from frame corner) with title/artist to the left |
For lower-corner templates only, optional `metadata.backgroundImagePath` (or `background_image_path`) sets a separate full-frame blur fill. Upload with `type=image` first, then pass the returned path. The cover (`imagePath` / per-item `metadata.imagePath`) stays the sharp corner square. Omit the field to blur the cover itself (default). `blurAmount` / `blurOpacity` still apply to whichever image is used as the fill.
Optional fine-tuning (clamped; camelCase or snake_case):
| Field | Range | Default | Purpose |
|-------|-------|---------|---------|
| `blurAmount` / `blur_amount` | 0100 | 55 | Background `boxblur` intensity |
| `blurOpacity` / `blur_opacity` | 0100 | 100 | Blurred fill vs black |
| `blurFill` / `blur_fill` | boolean | `false` | Classic letterbox only: fill bars with blurred cover (ignored for art-track templates) |
| `textPadding` / `text_padding` | 16120 | 48 | Edge inset for cover/text. On lower-corner templates this value is applied equally on both axes (left=bottom or right=bottom) so the cover corner sits on a true diagonal from the frame corner |
| `titleArtistGap` / `title_artist_gap` | 064 | 10 | Space between title and artist |
| `titleBold` / `title_bold` | boolean | `true` | Bold song title (preview + FFmpeg) |
| `textOffsetX` / `text_offset_x` | 120120 | 0 | Shift text block horizontally |
| `textOffsetY` / `text_offset_y` | 120120 | 0 | Shift text block vertically |
Also set `metadata.songTitle` (max 120) and `metadata.artist` (max 80) for the on-video text lines. `metadata.title` remains the YouTube title.
Omit `layout.template` for classic letterbox (black-padded cover by default). Set `layout.blurFill` / `blur_fill` to `true` to fill letterbox bars with a blurred cover; then `blurAmount` / `blurOpacity` apply. Free-form cover coordinates (`x`, `y`, `coverX`, …) and layout-level `offsetX`/`offsetY` are **rejected**.
Invalid template strings return **400**:
```json
{ "error": "Invalid layout template. Refer to API documentation for valid enum values." }
```
## YouTube playlists
```bash
curl "$BASE_URL/api/v1/playlists" \
-H "Authorization: Bearer $API_KEY"
```
```bash
curl -X POST "$BASE_URL/api/v1/playlists" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"My Album","description":"From Songs2VID","privacy":"unlisted"}'
```
Or pass `createPlaylist` on a job / batch body to create a playlist and attach all videos.
## One-shot batch (small packs only)
```bash
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={"defaults":{"privacy":"PUBLIC"},"items":[{"title":"Track One"},{"title":"Track Two"}]}'
```
Prefer two-step upload + jobs for larger packs.
## Poll job status
```bash
curl "$BASE_URL/api/v1/jobs/JOB_ID" \
-H "Authorization: Bearer $API_KEY"
```
```bash
curl "$BASE_URL/api/v1/jobs?limit=10" \
-H "Authorization: Bearer $API_KEY"
```
`GET /api/v1/jobs` accepts `limit` (default **20**, max **100**).
### Job statuses
| Status | Meaning |
|--------|---------|
| `PENDING` | Queued |
| `PROCESSING` | Encoding or uploading |
| `COMPLETED` | All items succeeded |
| `FAILED` | All items failed |
| `PARTIAL` | Mix of completed and failed |
### Item statuses
| Status | Meaning |
|--------|---------|
| `PENDING` | Waiting |
| `ENCODING` | FFmpeg building video |
| `UPLOADING` | Uploading to YouTube |
| `COMPLETED` | Live (`youtubeVideoId` set) |
| `FAILED` | Failed (`error` message) |
YouTube channel daily upload caps are enforced by Google, not Songs2VID.
## HTTP errors
| Status | When |
|--------|------|
| `400` | Validation error |
| `401` | Missing or invalid API key |
| `403` | YouTube not connected, or request not allowed |
| `404` | Job not found |
| `429` | API rate limit exceeded |
There is no `402` payment / credits status in OSS.
+68
View File
@@ -0,0 +1,68 @@
# API overview
Programmatic uploads and batch jobs for self-hosted Songs2VID. Generate your API key under **Dashboard → Settings → API key**.
Keys start with `s2yt_live_` and are shown once at creation. The OSS / Docker image always allows API use — there is no plan, credit, or paywall gate.
There are **no billing endpoints** in this edition.
## Authentication
Send the key on every request:
```http
Authorization: Bearer s2yt_live_your_key_here
```
Requirements:
- YouTube channel connected (sign in with Google OAuth that includes YouTube scopes)
OAuth setup: root [README](../../README.md) and Googles [OAuth 2.0 for Web Server Applications](https://developers.google.com/identity/protocols/oauth2/web-server). YouTube scopes/API: [YouTube Data API Overview](https://developers.google.com/youtube/v3/getting-started).
## Rate limits
Self-hosted OSS uses a very high per-account ceiling (effectively unlimited for normal automation). You will rarely see `429`.
If a limit is hit, the response is **429** with `retryAfterSeconds` and a `Retry-After` header. See [Endpoints — HTTP errors](./endpoints.md#http-errors).
## Choosing a flow
### Recommended: two-step (especially 5+ audio files)
1. Upload each file with `POST /api/v1/upload`
2. Create the job with `POST /api/v1/render` (alias of `/api/v1/jobs`) — JSON paths + optional `webhookUrl`
This avoids huge multipart bodies. Max batch size is **100** tracks per job.
Prefer `webhookUrl` for n8n so long encodes do not block an HTTP Request node. Details: [n8n](../n8n.md).
### One-shot batch: small packs only
`POST /api/v1/jobs/batch` accepts one cover image and a few audio files in a single multipart request. Large bodies often fail with:
```text
failed to parse body as FormData
```
Prefer two-step for albums or long tracklists.
## Job lifecycle
1. Create job → status `PENDING`
2. Worker picks items → `ENCODING``UPLOADING``COMPLETED` or `FAILED`
3. Job rolls up to `COMPLETED`, `FAILED`, or `PARTIAL`
Poll with `GET /api/v1/jobs/:id`.
## Discovery
```http
GET /api/v1
```
Returns the endpoint list, requirements, `layoutTemplates` (every art-track enum id + label, including `LOWER_LEFT_COVER_TEXT` / `LOWER_RIGHT_COVER_TEXT`), and `compositionFamilies` (UI grouping for mirrored left/right variants). No auth required.
## Next
See [Endpoints](./endpoints.md) for curl examples. n8n: [docs/n8n.md](../n8n.md). Hosted HTML docs: [docs.songs2vid.com/docs/api/overview](https://docs.songs2vid.com/docs/api/overview).
+36
View File
@@ -0,0 +1,36 @@
# n8n integration (OSS)
Automate self-hosted Songs2VID with the community package **[`n8n-nodes-songs2vid`](https://www.npmjs.com/package/n8n-nodes-songs2vid)**.
Full hosted guide: [https://docs.songs2vid.com/docs/n8n](https://docs.songs2vid.com/docs/n8n)
## Install
In n8n → **Settings → Community nodes** → install:
```text
n8n-nodes-songs2vid
```
Or from this repo for local development:
```bash
cd integrations/n8n
npm install
npm run build
```
## Auth
1. Dashboard → **Settings → API key** → create `s2yt_live_…`
2. n8n credential **Songs2VID API**: paste key, Base URL = your instance (e.g. `http://localhost:3000`)
OSS has **no plan gate** — API keys work whenever YouTube is connected.
## Flow
1. **Upload** cover (`type=image`) and audio (`type=audio`)
2. **Create Render** (`POST /api/v1/render`) with paths + optional `webhookUrl`
3. Handle completion via n8n **Webhook** or poll `GET /api/v1/jobs/:id`
Package source: [`integrations/n8n`](../integrations/n8n/).
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
*.tsbuildinfo
.DS_Store
*.tgz
.env
.env.*
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Songs2VID
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+89
View File
@@ -0,0 +1,89 @@
# n8n-nodes-songs2vid
Official community node for **[Songs2VID](https://songs2vid.com)** — turn audio + cover art into YouTube-ready videos from [n8n](https://n8n.io/).
**Documentation:** [https://docs.songs2vid.com/docs/n8n](https://docs.songs2vid.com/docs/n8n) · in-repo [docs/n8n.md](../../docs/n8n.md)
Self-hosted OSS: set **Base URL** to your instance. API keys work without a paid plan (YouTube must be connected).
## Install
### From n8n (recommended)
1. Open n8n → **Settings → Community nodes**
2. **Install a community node**
3. Enter:
```text
n8n-nodes-songs2vid
```
4. Confirm and restart n8n if prompted
5. Search the canvas for **Songs2VID**
Self-hosted n8n needs community packages enabled (`N8N_COMMUNITY_PACKAGES_ENABLED=true`).
### From npm (manual)
```bash
cd ~/.n8n
npm install n8n-nodes-songs2vid
```
Restart n8n.
## Credentials
1. Create an API key under [Dashboard → Settings](https://songs2vid.com/dashboard/settings) (`s2yt_live_…`)
2. Cloud requires **Developer & Automation** (€15) or **Enterprise**, plus YouTube connected
3. In n8n: create a **Songs2VID API** credential
- **API Key:** your token
- **Base URL:** `https://songs2vid.com` (or your self-hosted origin, no trailing slash)
Requests use `Authorization: Bearer …` only.
## Quick start
1. **File → Upload** — cover (`type=image`) → save `path`
2. **File → Upload** — audio (`type=audio`) → save `path` / `filename`
3. **Render → Create Render** — paths + privacy + resolution
Optional: **Additional Fields → Webhook URL** for async completion
4. Handle `job.completed` / `job.item.completed` on an n8n **Webhook** node, or poll **Get Render Status**
## Operations
| Resource | Operation | API |
|----------|-----------|-----|
| Discovery | Get API Catalog | `GET /api/v1` |
| File | Upload | `POST /api/v1/upload` |
| Render | Create Render | `POST /api/v1/render` |
| Render | Create Job (alias) | `POST /api/v1/jobs` |
| Render | Create Batch | `POST /api/v1/jobs/batch` |
| Render | Get Render Status | `GET /api/v1/jobs/:id` |
| Render | List Renders | `GET /api/v1/jobs` |
| Playlist | List / Create | `/api/v1/playlists` |
| API Key | List / Create / Delete | `/api/v1/user/api-keys` |
Layout, watermark, playlist, and multi-track options are under **Additional Fields** on Create Render.
There is no Delete Render endpoint — finished videos live on YouTube.
## Links
- Guide: https://songs2vid.com/docs/n8n
- REST endpoints: https://songs2vid.com/docs/api/endpoints
- Workflow template: https://songs2vid.com/docs/n8n/workflow-template.json
- npm: https://www.npmjs.com/package/n8n-nodes-songs2vid
## Development
```bash
npm install
npm run build
```
`prepublishOnly` runs the build before `npm publish`.
## License
MIT
@@ -0,0 +1,57 @@
import type {
IAuthenticateGeneric,
ICredentialTestRequest,
ICredentialType,
INodeProperties,
} from "n8n-workflow";
/**
* Songs2VID REST auth — cloud accepts Authorization: Bearer only.
* Paste the raw key (s2yt_live_…) or a full "Bearer …" value.
*/
export class Songs2VidApi implements ICredentialType {
name = "songs2VidApi";
displayName = "Songs2VID API";
documentationUrl = "https://songs2vid.com/docs/n8n";
properties: INodeProperties[] = [
{
displayName: "API Key",
name: "apiKey",
type: "string",
typeOptions: { password: true },
default: "",
required: true,
description:
"Developer+ key from Dashboard → Settings (starts with s2yt_live_). Sent as Authorization: Bearer.",
},
{
displayName: "Base URL",
name: "baseUrl",
type: "string",
default: "https://songs2vid.com",
required: true,
description: "Cloud origin or self-hosted base (no trailing slash).",
},
];
authenticate: IAuthenticateGeneric = {
type: "generic",
properties: {
headers: {
Authorization:
'={{ $credentials.apiKey.toString().startsWith("Bearer ") ? $credentials.apiKey : "Bearer " + $credentials.apiKey }}',
},
},
};
test: ICredentialTestRequest = {
request: {
baseURL: "={{ $credentials.baseUrl.replace(/\\/$/, \"\") }}",
url: "/api/v1/user/api-keys",
method: "GET",
},
};
}
+14
View File
@@ -0,0 +1,14 @@
const { src, dest } = require("gulp");
/**
* Copy node/credential icons (+ codex JSON) into dist so n8n can resolve
* `icon: "file:songs2vid.svg"` next to the compiled .js files.
*/
function copyIcons() {
return src(
["nodes/**/*.{png,svg,json}", "credentials/**/*.{png,svg,json}"],
{ base: "." },
).pipe(dest("dist"));
}
exports["build:icons"] = copyIcons;
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env node
"use strict";
module.exports = {};
@@ -0,0 +1,18 @@
{
"node": "n8n-nodes-songs2vid.songs2Vid",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Marketing", "Productivity"],
"resources": {
"credentialDocumentation": [
{
"url": "https://songs2vid.com/docs/n8n"
}
],
"primaryDocumentation": [
{
"url": "https://songs2vid.com/docs/api/endpoints"
}
]
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" fill="none">
<rect width="60" height="60" rx="12" fill="#0a0a0a"/>
<text x="8" y="38" font-family="Arial,sans-serif" font-size="16" font-weight="700" fill="#fff">S2</text>
<text x="30" y="38" font-family="Arial,sans-serif" font-size="16" font-weight="700" fill="#f87171">VID</text>
</svg>

After

Width:  |  Height:  |  Size: 355 B

+5242
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
{
"name": "n8n-nodes-songs2vid",
"version": "1.0.0",
"description": "n8n community node for Songs2VID — upload audio/covers, create YouTube renders, poll jobs, manage playlists & API keys",
"keywords": [
"n8n-community-node-package",
"n8n-node",
"songs2vid",
"audio-to-video",
"youtube"
],
"license": "MIT",
"homepage": "https://songs2vid.com/docs/n8n",
"author": {
"name": "Songs2VID",
"url": "https://songs2vid.com"
},
"repository": {
"type": "git",
"url": "https://git.atakanozban.com/Songs2VID/songs2vid.git",
"directory": "integrations/n8n"
},
"bugs": {
"url": "https://songs2vid.com/docs/n8n"
},
"main": "index.js",
"scripts": {
"build": "tsc && gulp build:icons",
"dev": "tsc --watch",
"prepublishOnly": "npm run build"
},
"files": [
"dist"
],
"n8n": {
"n8nNodesApiVersion": 1,
"credentials": [
"dist/credentials/Songs2VidApi.credentials.js"
],
"nodes": [
"dist/nodes/Songs2Vid/Songs2Vid.node.js"
]
},
"devDependencies": {
"@types/node": "^20.11.0",
"gulp": "^4.0.2",
"n8n-workflow": "^1.70.0",
"typescript": "^5.3.3"
},
"peerDependencies": {
"n8n-workflow": "*"
},
"engines": {
"node": ">=18.10"
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2019",
"module": "commonjs",
"lib": ["ES2019"],
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["credentials/**/*", "nodes/**/*"],
"exclude": ["dist", "node_modules"]
}
@@ -0,0 +1,395 @@
{
"name": "Songs2VID — Audio → Render → YouTube (full)",
"meta": {
"templateCredsSetupCompleted": false,
"instanceId": "songs2vid-docs-template-v2"
},
"nodes": [
{
"parameters": {},
"id": "manual-trigger",
"name": "When clicking Test workflow",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
0,
0
]
},
{
"parameters": {
"path": "songs2vid-complete",
"httpMethod": "POST",
"responseMode": "onReceived",
"options": {}
},
"id": "webhook-complete",
"name": "Songs2VID Job Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
360
],
"webhookId": "songs2vid-complete"
},
{
"parameters": {
"content": "## Inputs\nProvide binary fields (`cover`, `audio`) or replace Manual Trigger with Drive/Dropbox.\n\nCredential: Header Auth → Name `Authorization`, Value `Bearer s2yt_live_…`\n\nSet env `SONGS2VID_WEBHOOK_URL` to this workflow's Production Webhook URL.",
"height": 240,
"width": 320
},
"id": "sticky-inputs",
"name": "Setup",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-320,
-40
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SONGS2VID_BASE_URL || 'https://songs2vid.com' }}/api/v1/upload",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"contentType": "multipart-form-data",
"bodyParameters": {
"parameters": [
{
"parameterType": "formBinaryData",
"name": "file",
"inputDataFieldName": "cover"
},
{
"name": "type",
"value": "image"
}
]
},
"options": {}
},
"id": "upload-cover",
"name": "Upload Cover Image",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
280,
0
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_ME",
"name": "Songs2VID API Key"
}
}
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SONGS2VID_BASE_URL || 'https://songs2vid.com' }}/api/v1/upload",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"contentType": "multipart-form-data",
"bodyParameters": {
"parameters": [
{
"parameterType": "formBinaryData",
"name": "file",
"inputDataFieldName": "audio"
},
{
"name": "type",
"value": "audio"
}
]
},
"options": {}
},
"id": "upload-audio",
"name": "Upload Audio",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
520,
0
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_ME",
"name": "Songs2VID API Key"
}
}
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SONGS2VID_BASE_URL || 'https://songs2vid.com' }}/api/v1/render",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"imagePath\": \"{{ $('Upload Cover Image').item.json.path }}\",\n \"webhookUrl\": \"{{ $env.SONGS2VID_WEBHOOK_URL || '' }}\",\n \"items\": [{\n \"audioPath\": \"{{ $('Upload Audio').item.json.path }}\",\n \"audioFilename\": \"{{ $('Upload Audio').item.json.filename }}\",\n \"metadata\": {\n \"title\": \"{{ $('Upload Audio').item.json.audioTags?.title || $('Upload Audio').item.json.filename }}\",\n \"songTitle\": \"{{ $('Upload Audio').item.json.audioTags?.title || '' }}\",\n \"artist\": \"{{ $('Upload Audio').item.json.audioTags?.artist || '' }}\",\n \"description\": \"Uploaded via n8n + Songs2VID\",\n \"tags\": \"music,songs2vid\",\n \"privacy\": \"UNLISTED\",\n \"categoryId\": \"10\",\n \"resolution\": \"1920x1080\",\n \"notifySubscribers\": false,\n \"madeForKids\": false,\n \"embeddable\": true,\n \"creativeCommons\": false,\n \"includeWatermark\": false\n }\n }]\n}",
"options": {}
},
"id": "create-render",
"name": "Create Render",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
760,
0
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_ME",
"name": "Songs2VID API Key"
}
}
},
{
"parameters": {
"method": "GET",
"url": "={{ $env.SONGS2VID_BASE_URL || 'https://songs2vid.com' }}{{ $json.statusUrl }}",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"options": {}
},
"id": "poll-status",
"name": "Get Render Status (optional poll)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1000,
0
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_ME",
"name": "Songs2VID API Key"
}
},
"disabled": true
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "job-done",
"leftValue": "={{ $json.body?.event || $json.event }}",
"rightValue": "job.completed",
"operator": {
"type": "string",
"operation": "equals"
}
},
{
"id": "item-done",
"leftValue": "={{ $json.body?.event || $json.event }}",
"rightValue": "job.item.completed",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "or"
},
"options": {}
},
"id": "if-completed",
"name": "Render Completed?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
280,
360
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "job-fail",
"leftValue": "={{ $json.body?.event || $json.event }}",
"rightValue": "job.failed",
"operator": {
"type": "string",
"operation": "equals"
}
},
{
"id": "item-fail",
"leftValue": "={{ $json.body?.event || $json.event }}",
"rightValue": "job.item.failed",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "or"
},
"options": {}
},
"id": "if-failed",
"name": "Render Failed?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
280,
560
]
},
{
"parameters": {
"content": "## YouTube live\nVideo ID: `{{ $json.body?.youtubeVideoId || $json.youtubeVideoId }}`\n\nhttps://youtu.be/{{ $json.body?.youtubeVideoId || $json.youtubeVideoId }}",
"height": 180,
"width": 360
},
"id": "sticky-success",
"name": "Success",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
560,
320
]
},
{
"parameters": {
"content": "## Failed\n`{{ $json.body?.error || $json.error }}`",
"height": 160,
"width": 360
},
"id": "sticky-fail",
"name": "Failure",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
560,
520
]
}
],
"connections": {
"When clicking Test workflow": {
"main": [
[
{
"node": "Upload Cover Image",
"type": "main",
"index": 0
}
]
]
},
"Upload Cover Image": {
"main": [
[
{
"node": "Upload Audio",
"type": "main",
"index": 0
}
]
]
},
"Upload Audio": {
"main": [
[
{
"node": "Create Render",
"type": "main",
"index": 0
}
]
]
},
"Create Render": {
"main": [
[
{
"node": "Get Render Status (optional poll)",
"type": "main",
"index": 0
}
]
]
},
"Songs2VID Job Webhook": {
"main": [
[
{
"node": "Render Completed?",
"type": "main",
"index": 0
}
]
]
},
"Render Completed?": {
"main": [
[
{
"node": "Success",
"type": "main",
"index": 0
}
],
[
{
"node": "Render Failed?",
"type": "main",
"index": 0
}
]
]
},
"Render Failed?": {
"main": [
[
{
"node": "Failure",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"tags": [
{
"name": "songs2vid"
},
{
"name": "n8n"
},
{
"name": "youtube"
}
],
"triggerCount": 0,
"updatedAt": "2026-08-08T00:00:00.000Z",
"versionId": "2"
}
+3 -24
View File
@@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { findUserByApiKey } from "./api-keys";
import { checkApiRateLimit } from "./api-rate-limit";
import { hasProFeatures } from "./edition";
import { getSessionUser } from "./session";
function extractBearerToken(req: NextRequest) {
@@ -10,7 +9,7 @@ function extractBearerToken(req: NextRequest) {
return auth.slice(7).trim();
}
export async function requirePaidApiUser(req: NextRequest) {
export async function requireApiUser(req: NextRequest) {
const token = extractBearerToken(req);
if (!token) {
return {
@@ -30,16 +29,6 @@ export async function requirePaidApiUser(req: NextRequest) {
};
}
if (!hasProFeatures(user.plan)) {
return {
error: NextResponse.json(
{ error: "API access is unavailable for this account" },
{ status: 403 },
),
user: null,
};
}
if (!user.youtubeConnection) {
return {
error: NextResponse.json(
@@ -70,8 +59,8 @@ export async function requirePaidApiUser(req: NextRequest) {
return { error: null, user };
}
export async function requirePaidUserFromSessionOrApi(req: NextRequest) {
const apiResult = await requirePaidApiUser(req);
export async function requireUserFromSessionOrApi(req: NextRequest) {
const apiResult = await requireApiUser(req);
if (apiResult.user) return apiResult;
const sessionUser = await getSessionUser();
@@ -84,16 +73,6 @@ export async function requirePaidUserFromSessionOrApi(req: NextRequest) {
};
}
if (!hasProFeatures(sessionUser.plan)) {
return {
error: NextResponse.json(
{ error: "API access is unavailable for this account" },
{ status: 403 },
),
user: null,
};
}
if (!sessionUser.youtubeConnection) {
return {
error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }),
+3 -16
View File
@@ -1,11 +1,6 @@
import IORedis from "ioredis";
import { prisma } from "./db";
import { isSelfHostedEdition } from "./edition";
export const DEFAULT_API_RATE_LIMIT = 60;
export const DEFAULT_API_RATE_LIMIT = 100_000;
export const API_RATE_WINDOW_SECONDS = 60;
export const MAX_ADMIN_API_RATE_BONUS = 120;
const SELFHOSTED_API_RATE_LIMIT = 100_000;
type MemoryBucket = {
count: number;
@@ -36,13 +31,8 @@ function rateKey(userId: string) {
}
export async function getEffectiveApiRateLimit(userId: string) {
if (isSelfHostedEdition()) return SELFHOSTED_API_RATE_LIMIT;
const user = await prisma.user.findUnique({
where: { id: userId },
select: { apiRateLimitBonus: true, plan: true },
});
if (!user || user.plan !== "PREMIUM") return DEFAULT_API_RATE_LIMIT;
return DEFAULT_API_RATE_LIMIT + Math.max(0, user.apiRateLimitBonus);
void userId;
return DEFAULT_API_RATE_LIMIT;
}
function memoryStatus(userId: string, limit: number) {
@@ -120,9 +110,6 @@ function checkMemoryRateLimit(userId: string, limit: number) {
}
export async function checkApiRateLimit(userId: string) {
if (isSelfHostedEdition()) {
return { ok: true as const, limit: SELFHOSTED_API_RATE_LIMIT, remaining: SELFHOSTED_API_RATE_LIMIT };
}
const limit = await getEffectiveApiRateLimit(userId);
const client = getRedis();
if (!client) return checkMemoryRateLimit(userId, limit);
+9 -5
View File
@@ -31,17 +31,21 @@ export async function readAudioTags(filePath: string): Promise<AudioTagMetadata
export function audioTagsToMetadata(
tags: AudioTagMetadata,
fallbackTitle: string,
): Pick<ItemMetadata, "title" | "description" | "tags"> {
const title =
tags.title ||
(tags.artist ? `${tags.artist}${tags.album ? ` - ${tags.album}` : ""}` : fallbackTitle);
): Pick<ItemMetadata, "title" | "description" | "tags" | "artist" | "songTitle"> {
const songTitle = tags.title || fallbackTitle;
const videoTitle =
tags.artist && tags.title
? `${tags.artist} - ${tags.title}`
: tags.title || fallbackTitle;
const description = [tags.artist, tags.album, tags.year].filter(Boolean).join(" · ");
const tagParts = [tags.genre, tags.artist].filter(Boolean);
return {
title,
title: videoTitle,
songTitle,
description,
tags: tagParts.join(", "),
artist: tags.artist || null,
};
}
-3
View File
@@ -2,7 +2,6 @@ import { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import { encryptSecret } from "./crypto/secrets";
import { prisma } from "./db";
import { getInitialQuotaResetAt } from "./quota";
import { fetchYouTubeChannel } from "./youtube/upload";
export const authOptions: NextAuthOptions = {
@@ -36,7 +35,6 @@ export const authOptions: NextAuthOptions = {
email: user.email,
name: user.name,
image: user.image,
quotaResetAt: getInitialQuotaResetAt(),
},
update: {
name: user.name,
@@ -87,7 +85,6 @@ export const authOptions: NextAuthOptions = {
});
if (dbUser) {
session.user.id = dbUser.id;
session.user.plan = dbUser.plan;
session.user.youtubeConnected = !!dbUser.youtubeConnection;
session.user.channelTitle = dbUser.youtubeConnection?.channelTitle;
}
+2 -2
View File
@@ -1,7 +1,7 @@
export const BRAND_NAME = "Songs2VID";
export const BRAND_DOMAIN = `${BRAND_NAME}.com`;
export const BRAND_DOMAIN = "songs2vid.com";
export const VIDEO_ATTRIBUTION_PREFIX = "Uploaded through";
export function getVideoAttributionText() {
return `${VIDEO_ATTRIBUTION_PREFIX} ${BRAND_DOMAIN}`;
return `${VIDEO_ATTRIBUTION_PREFIX} ${BRAND_NAME}.com`;
}
-7
View File
@@ -1,10 +1,3 @@
export const FREE_PLAN = {
monthlyQuota: 14,
maxFileSizeBytes: 30 * 1024 * 1024,
maxBatchSize: 3,
watermarkOptional: true,
} as const;
export const RESOLUTIONS = [
{ value: "1920x1080", label: "1920x1080 (16:9)", width: 1920, height: 1080 },
{ value: "1280x720", label: "1280x720 (16:9)", width: 1280, height: 720 },
+4 -14
View File
@@ -1,17 +1,7 @@
import { Plan } from "@prisma/client";
/**
* Self-hosted / OSS edition. Prefer S2VID_EDITION; S2YT_EDITION kept for older compose files.
*/
export function isSelfHostedEdition(): boolean {
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";
}
/** All Pro features are unlocked in the OSS / self-hosted edition. */
export function hasProFeatures(_plan?: Plan): boolean {
return true;
}
export function hasProFeatures(): boolean {
return true;
}
+4 -34
View File
@@ -1,41 +1,11 @@
import { Plan } from "@prisma/client";
import type { LayoutSettings } from "./layout";
import {
PREMIUM_REQUIRED_CODE,
type WatermarkSettings,
} from "./watermark";
import 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";
}
}
export function assertCustomWatermarkAllowed(_settings: WatermarkSettings) {}
/** OSS: all watermark / typography features unlocked. */
export function assertCustomWatermarkAllowed(_plan: Plan, _settings: WatermarkSettings) {
return;
}
export function assertArtTrackLayoutAllowed(_settings: LayoutSettings) {}
/** 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,
};
}
) {}
+20
View File
@@ -0,0 +1,20 @@
/**
* Modular FFmpeg filter fragments for the Songs2VID brand watermark overlay.
* Always bottom-right with padding; scales to a fraction of frame width.
*/
export function buildBrandWatermarkOverlayFilters(options: {
baseLabel: string;
watermarkInputIndex: number;
watermarkWidthPx: number;
offsetX?: number;
offsetY?: number;
outLabel?: string;
}): string[] {
const ox = options.offsetX ?? 20;
const oy = options.offsetY ?? 20;
const out = options.outLabel ?? "vout";
return [
`[${options.watermarkInputIndex}:v]scale=${options.watermarkWidthPx}:-1[wm]`,
`[${options.baseLabel}][wm]overlay=W-w-${ox}:H-h-${oy}[${out}]`,
];
}
+151 -23
View File
@@ -8,11 +8,18 @@ import {
isCuratedFontKey,
sanitizeFontfileForFilter,
} from "../fonts";
import { resolveCuratedFontPath } from "../fonts-server";
import { resolveCuratedFontPath, resolveSystemFontPath, assertFontFile } from "../fonts-server";
import {
buildArtTrackFilterComplex,
buildClassicBlurFillFilterComplex,
isLowerCornerTemplate,
normalizeLayoutSettings,
type LayoutSettings,
} from "../layout";
import {
WATERMARK_WIDTH_FRACTION,
watermarkFontSizeForWidth,
} from "../preview-typography";
import { getWatermarkPath } from "../storage";
import {
buildDrawtextFilter,
@@ -21,6 +28,7 @@ import {
sanitizeDrawtext,
type WatermarkSettings,
} from "../watermark";
import { buildBrandWatermarkOverlayFilters } from "./brand-watermark";
function getFfmpegPath(): string {
if (process.env.FFMPEG_PATH) return process.env.FFMPEG_PATH;
@@ -56,9 +64,26 @@ export async function assertPngFile(filePath: string): Promise<void> {
async function resolveFontfileEscaped(
settings: WatermarkSettings,
weight: "regular" | "bold" = "regular",
): Promise<string | null> {
const key = settings.fontKey ?? "system";
if (key === "system") return null;
if (key === "system") {
const preferred = resolveSystemFontPath(weight);
const fallback = weight === "bold" ? resolveSystemFontPath("regular") : preferred;
for (const fontPath of weight === "bold" ? [preferred, fallback] : [preferred]) {
if (!(await fileExists(fontPath))) continue;
try {
await assertFontFile(fontPath);
return sanitizeFontfileForFilter(fontPath);
} catch (err) {
console.warn(
`[ffmpeg] system font invalid at ${fontPath}: ${err instanceof Error ? err.message : err}`,
);
}
}
console.warn(`[ffmpeg] system font missing; using FFmpeg default`);
return null;
}
if (key === "custom") {
if (!settings.fontPath) return null;
@@ -69,12 +94,19 @@ async function resolveFontfileEscaped(
}
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;
const preferred = resolveCuratedFontPath(key, weight);
const fallback = weight === "bold" ? resolveCuratedFontPath(key, "regular") : preferred;
for (const fontPath of weight === "bold" && preferred !== fallback ? [preferred, fallback] : [preferred]) {
if (!(await fileExists(fontPath))) continue;
try {
await assertFontFile(fontPath);
return sanitizeFontfileForFilter(fontPath);
} catch {
/* try next */
}
}
return sanitizeFontfileForFilter(fontPath);
console.warn(`[ffmpeg] curated font missing: ${key}; using system font`);
return null;
}
return null;
@@ -84,6 +116,38 @@ 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`;
}
/**
* Plan-aware audio for MP4.
* OSS / self-hosted defaults to Pro-quality 320k AAC.
*/
export type AudioEncodeOptions = {
bitrateKbps: 192 | 320;
copyWhenSafe: boolean;
audioPath: string;
};
function isCopySafeAudioPath(audioPath: string): boolean {
const ext = path.extname(audioPath).toLowerCase();
return ext === ".aac" || ext === ".m4a" || ext === ".mp4";
}
function pushAudioEncodeArgs(args: string[], audio: AudioEncodeOptions): void {
if (audio.copyWhenSafe && isCopySafeAudioPath(audio.audioPath)) {
args.push("-c:a", "copy");
return;
}
args.push(
"-c:a",
"aac",
"-b:a",
`${audio.bitrateKbps}k`,
"-ar",
"44100",
"-ac",
"2",
);
}
/**
* Art-track filter that ends at `outLabel` instead of hardcoded [laid].
*/
@@ -94,6 +158,13 @@ function artTrackFilterEndingAt(
return buildArtTrackFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`);
}
function classicBlurFillEndingAt(
opts: Parameters<typeof buildClassicBlurFillFilterComplex>[0],
outLabel: string,
): string {
return buildClassicBlurFillFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`);
}
export async function encodeVideo(options: {
imagePath: string;
audioPath: string;
@@ -102,26 +173,63 @@ export async function encodeVideo(options: {
includeWatermark: boolean;
watermark?: Partial<WatermarkSettings> | null;
layout?: LayoutSettings | null;
title?: string;
/** On-video song title (art-track layouts). */
songTitle?: string;
artist?: string | null;
/**
* Optional full-frame background for lower-corner templates.
* When set with LOWER_LEFT / LOWER_RIGHT, blur fill uses this image; cover stays sharp.
*/
backgroundImagePath?: string | null;
/** Defaults to 320k (self-hosted / studio). */
audioBitrateKbps?: 192 | 320;
audioCopyWhenSafe?: boolean;
}): Promise<void> {
const res = getResolution(options.resolution);
if (!res) throw new Error(`Invalid resolution: ${options.resolution}`);
const audioEncode: AudioEncodeOptions = {
bitrateKbps: options.audioBitrateKbps ?? 320,
copyWhenSafe: options.audioCopyWhenSafe ?? true,
audioPath: options.audioPath,
};
await fs.mkdir(path.dirname(options.outputPath), { recursive: true });
const settings = normalizeWatermarkSettings(options.watermark, options.includeWatermark);
const layout = options.layout?.template ? options.layout : null;
const watermarkWidth = Math.max(1, Math.round(res.width * 0.32));
const fontSize = Math.max(16, Math.round(res.width * 0.018));
const fontfile = await resolveFontfileEscaped(settings);
const layoutSettings = options.layout
? normalizeLayoutSettings(options.layout)
: null;
const artTrack = Boolean(layoutSettings?.template);
const classicBlurFill = Boolean(layoutSettings && !artTrack && layoutSettings.blurFill);
const layout = artTrack && layoutSettings ? layoutSettings : null;
const watermarkWidth = Math.max(1, Math.round(res.width * WATERMARK_WIDTH_FRACTION));
const fontSize = watermarkFontSizeForWidth(res.width);
const fontfile = await resolveFontfileEscaped(settings, "regular");
const titleFontfile =
layout?.titleBold ? await resolveFontfileEscaped(settings, "bold") : null;
// Only pass a distinct bold face when it differs from regular (avoids faux-bold when bold TTF exists).
const titleFontfileDistinct =
titleFontfile && titleFontfile !== fontfile ? titleFontfile : null;
const args = ["-y", "-loop", "1", "-r", "1", "-i", options.imagePath, "-i", options.audioPath];
let nextInput = 2;
let separateBackgroundInputIndex: number | null = null;
let logoInputIndex: number | null = null;
let defaultWmInputIndex: number | null = null;
const useSeparateBackground =
Boolean(layout && isLowerCornerTemplate(layout.template) && options.backgroundImagePath);
if (useSeparateBackground && options.backgroundImagePath) {
if (!(await fileExists(options.backgroundImagePath))) {
throw new Error("Background image file not found");
}
args.push("-loop", "1", "-r", "1", "-i", options.backgroundImagePath);
separateBackgroundInputIndex = nextInput++;
}
const needsLogo = settings.mode === "logo" && Boolean(settings.logoPath);
const defaultPath = getWatermarkPath();
const useDefaultPng =
@@ -150,16 +258,17 @@ export async function encodeVideo(options: {
? ("default-text" as const)
: ("none" as const);
// Fast path: classic letterbox, no watermark
if (!layout && applyWm === "none") {
// Fast path: classic letterbox (black bars), no watermark, no blur fill
if (!layout && !classicBlurFill && applyWm === "none") {
args.push("-vf", classicScaleFilter(res.width, res.height));
args.push(
"-c:v",
"libx264",
"-tune",
"stillimage",
"-c:a",
"copy",
);
pushAudioEncodeArgs(args, audioEncode);
args.push(
"-shortest",
"-pix_fmt",
"yuv420p",
@@ -174,7 +283,7 @@ export async function encodeVideo(options: {
const filterParts: string[] = [];
if (layout) {
const titleEscaped = sanitizeDrawtext(options.title?.trim() || "Untitled");
const titleEscaped = sanitizeDrawtext(options.songTitle?.trim() || "Untitled");
const artistRaw = options.artist?.trim();
const artistEscaped = artistRaw ? sanitizeDrawtext(artistRaw) : null;
filterParts.push(
@@ -186,6 +295,20 @@ export async function encodeVideo(options: {
titleEscaped,
artistEscaped,
fontfileEscaped: fontfile,
titleFontfileEscaped: titleFontfileDistinct,
separateBackgroundInputIndex,
},
baseLabel,
),
);
} else if (classicBlurFill && layoutSettings) {
filterParts.push(
classicBlurFillEndingAt(
{
width: res.width,
height: res.height,
blurAmount: layoutSettings.blurAmount,
blurOpacity: layoutSettings.blurOpacity,
},
baseLabel,
),
@@ -212,10 +335,14 @@ export async function encodeVideo(options: {
});
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]`,
...buildBrandWatermarkOverlayFilters({
baseLabel,
watermarkInputIndex: defaultWmInputIndex,
watermarkWidthPx: watermarkWidth,
offsetX: settings.offsetX,
offsetY: settings.offsetY,
}),
);
} else if (applyWm === "default-text") {
const draw = buildDrawtextFilter({
@@ -225,7 +352,7 @@ export async function encodeVideo(options: {
position: settings.position,
offsetX: settings.offsetX,
offsetY: settings.offsetY,
fontfileEscaped: null,
fontfileEscaped: fontfile,
});
filterParts.push(`[${baseLabel}]${draw}[vout]`);
}
@@ -236,8 +363,9 @@ export async function encodeVideo(options: {
"libx264",
"-tune",
"stillimage",
"-c:a",
"copy",
);
pushAudioEncodeArgs(args, audioEncode);
args.push(
"-shortest",
"-pix_fmt",
"yuv420p",
+17 -4
View File
@@ -2,19 +2,32 @@
import fs from "fs/promises";
import path from "path";
import { CURATED_FONTS, type CuratedFontKey } from "./fonts";
import { CURATED_FONTS, SYSTEM_FONT, type CuratedFontKey } from "./fonts";
export function getFontsDir(): string {
return path.join(process.cwd(), "assets", "fonts");
}
/** Resolve the bundled system (Arimo) font for FFmpeg + preview. */
export function resolveSystemFontPath(weight: "regular" | "bold" = "regular"): string {
const file = weight === "bold" ? SYSTEM_FONT.boldFile : SYSTEM_FONT.file;
const safe = file.replace(/[^a-zA-Z0-9._-]/g, "");
if (safe !== file) throw new Error("Invalid font asset name");
return path.join(getFontsDir(), safe);
}
/** Resolve a curated font file on disk (must exist under assets/fonts). */
export function resolveCuratedFontPath(key: CuratedFontKey): string {
export function resolveCuratedFontPath(
key: CuratedFontKey,
weight: "regular" | "bold" = "regular",
): string {
const meta = CURATED_FONTS.find((f) => f.key === key);
if (!meta) throw new Error("Unknown font");
const file =
weight === "bold" && meta.boldFile ? meta.boldFile : meta.file;
// 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");
const safe = file.replace(/[^a-zA-Z0-9._-]/g, "");
if (safe !== file) throw new Error("Invalid font asset name");
return path.join(getFontsDir(), safe);
}
+14
View File
@@ -3,6 +3,15 @@
/** Max custom font upload size (10 MB). */
export const FONT_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
/** Bundled default font for preview + FFmpeg parity (Apache 2.0, Google Arimo). */
export const SYSTEM_FONT = {
key: "system",
label: "Arimo",
file: "Arimo-Regular.ttf",
boldFile: "Arimo-Bold.ttf",
previewFamily: "S2VIDPreview-system",
} as const;
export const CURATED_FONTS = [
{
key: "inter",
@@ -10,6 +19,7 @@ export const CURATED_FONTS = [
cssFamily: "Inter",
googleCss: "Inter:wght@400;600",
file: "Inter-Regular.ttf",
boldFile: null as string | null,
},
{
key: "montserrat",
@@ -17,6 +27,7 @@ export const CURATED_FONTS = [
cssFamily: "Montserrat",
googleCss: "Montserrat:wght@400;600",
file: "Montserrat-Regular.ttf",
boldFile: null as string | null,
},
{
key: "roboto",
@@ -24,6 +35,7 @@ export const CURATED_FONTS = [
cssFamily: "Roboto",
googleCss: "Roboto:wght@400;500",
file: "Roboto-Regular.ttf",
boldFile: null as string | null,
},
{
key: "oswald",
@@ -31,6 +43,7 @@ export const CURATED_FONTS = [
cssFamily: "Oswald",
googleCss: "Oswald:wght@400;500",
file: "Oswald-Regular.ttf",
boldFile: null as string | null,
},
{
key: "playfair",
@@ -38,6 +51,7 @@ export const CURATED_FONTS = [
cssFamily: "Playfair Display",
googleCss: "Playfair+Display:wght@400;600",
file: "PlayfairDisplay-Regular.ttf",
boldFile: null as string | null,
},
] as const;
+117 -62
View File
@@ -1,15 +1,13 @@
import fs from "fs/promises";
import path from "path";
import { Plan, Privacy } from "@prisma/client";
import { Privacy } from "@prisma/client";
import { readAudioTags } from "../audio-tags";
import { isAllowedResolution } from "../constants";
import { filenameWithoutExtension, isAllowedResolution } from "../constants";
import { prisma } from "../db";
import { hasProFeatures } from "../edition";
import {
assertArtTrackLayoutAllowed,
assertCustomWatermarkAllowed,
assertPerItemImagesAllowed,
PremiumRequiredError,
} from "../entitlements";
import { FONT_UPLOAD_MAX_BYTES } from "../fonts";
import { assertFontFile } from "../fonts-server";
@@ -18,7 +16,9 @@ import { moveFile, writeUploadedFile } from "../fs-utils";
import {
ARTIST_MAX,
INVALID_LAYOUT_TEMPLATE_MESSAGE,
isLowerCornerTemplate,
normalizeLayoutSettings,
SONG_TITLE_MAX,
type LayoutSettings,
} from "../layout";
import { enqueueVideoJob } from "../queue/client";
@@ -27,7 +27,7 @@ import {
isAudioExtensionAllowed,
isResolutionAllowedForPlan,
} from "../plans";
import { releaseReservationSplit, reserveQuota } from "../quota";
import { reserveQuota } from "../quota";
import { getJobDir } from "../storage";
import {
assertPathInUserUploads,
@@ -35,10 +35,12 @@ import {
sanitizeUploadSessionKey,
} from "../upload-paths";
import type { CreateJobPayload, ItemMetadata } from "../types";
import { resolveBurnedSongTitle, resolveYouTubeTitle, validateYouTubeTitle } from "../titles";
import {
normalizeWatermarkSettings,
WATERMARK_TEXT_MAX,
} from "../watermark";
import { applyBrandWatermarkPolicy } from "../watermark-policy";
/** Merge nested + flat layout fields, then normalize (throws on bad template / coords). */
export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSettings {
@@ -55,6 +57,11 @@ export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSetting
metadata.layout?.blur_amount ??
metadata.blurAmount ??
metadata.blur_amount,
blurFill:
metadata.layout?.blurFill ??
(metadata.layout as { blur_fill?: boolean } | null | undefined)?.blur_fill ??
metadata.blurFill ??
metadata.blur_fill,
blurOpacity:
metadata.layout?.blurOpacity ??
(metadata.layout as { blur_opacity?: number } | null | undefined)?.blur_opacity ??
@@ -80,11 +87,19 @@ export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSetting
(metadata.layout as { text_offset_y?: number } | null | undefined)?.text_offset_y ??
metadata.textOffsetY ??
metadata.text_offset_y,
titleBold:
metadata.layout?.titleBold ??
(metadata.layout as { title_bold?: boolean } | null | undefined)?.title_bold ??
metadata.titleBold ??
metadata.title_bold,
});
}
export function validateItemMetadata(metadata: CreateJobPayload["items"][0]["metadata"]) {
if (!metadata.title?.trim()) return "Each video must have a title";
export function validateItemMetadata(
metadata: CreateJobPayload["items"][0]["metadata"],
) {
const titleErr = validateYouTubeTitle(metadata);
if (titleErr) return titleErr;
if (!isAllowedResolution(metadata.resolution)) return "Invalid resolution";
if (!["PUBLIC", "PRIVATE", "UNLISTED"].includes(metadata.privacy)) {
return "Invalid privacy setting";
@@ -95,6 +110,9 @@ export function validateItemMetadata(metadata: CreateJobPayload["items"][0]["met
if (metadata.artist && metadata.artist.length > ARTIST_MAX) {
return `Artist must be at most ${ARTIST_MAX} characters`;
}
if (metadata.songTitle && metadata.songTitle.length > SONG_TITLE_MAX) {
return `Song title must be at most ${SONG_TITLE_MAX} characters`;
}
try {
resolveLayoutFromMetadata(metadata);
} catch (err) {
@@ -106,7 +124,7 @@ export function validateItemMetadata(metadata: CreateJobPayload["items"][0]["met
return null;
}
export async function validateJobPayload(user: { id: string; plan: Plan }, body: CreateJobPayload) {
export async function validateJobPayload(user: { id: string }, body: CreateJobPayload) {
if (!body.imagePath || !body.items?.length) {
return "Image and at least one audio file required";
}
@@ -116,11 +134,8 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
for (const item of body.items) {
const metaError = validateItemMetadata(item.metadata);
if (metaError) return metaError;
if (!isResolutionAllowedForPlan(item.metadata.resolution, user.plan)) {
return `Resolution ${item.metadata.resolution} is not available on your plan`;
}
if (item.metadata.playlistId && !hasProFeatures(user.plan)) {
return "Adding videos to a YouTube playlist is unavailable for this account";
if (!isResolutionAllowedForPlan(item.metadata.resolution)) {
return `Resolution ${item.metadata.resolution} is not available`;
}
const wm = normalizeWatermarkSettings(
@@ -128,10 +143,9 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
item.metadata.includeWatermark,
);
try {
assertCustomWatermarkAllowed(user.plan, wm);
assertArtTrackLayoutAllowed(user.plan, resolveLayoutFromMetadata(item.metadata));
assertCustomWatermarkAllowed(wm);
assertArtTrackLayoutAllowed(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;
}
@@ -142,13 +156,12 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
}
try {
assertPerItemImagesAllowed(user.plan, body.imagePath, itemImagePaths);
assertPerItemImagesAllowed(body.imagePath, itemImagePaths);
} catch (err) {
if (err instanceof PremiumRequiredError) return err.message;
throw err;
}
const limits = getPlanLimits(user.plan);
const limits = getPlanLimits();
try {
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
@@ -159,8 +172,8 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
}
for (const item of body.items) {
if (!isAudioExtensionAllowed(item.audioFilename, user.plan)) {
return `Audio file ${item.audioFilename} is not supported on your plan`;
if (!isAudioExtensionAllowed(item.audioFilename)) {
return `Audio file ${item.audioFilename} is not supported`;
}
const audioPath = assertPathInUserUploads(user.id, item.audioPath);
await fs.access(audioPath);
@@ -178,6 +191,18 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
}
}
const layoutForBg = resolveLayoutFromMetadata(item.metadata);
const bgRaw =
item.metadata.backgroundImagePath ?? item.metadata.background_image_path ?? null;
if (bgRaw && isLowerCornerTemplate(layoutForBg.template)) {
const bgPath = assertPathInUserUploads(user.id, bgRaw);
await fs.access(bgPath);
const st = await fs.stat(bgPath);
if (st.size > limits.maxFileSizeBytes) {
return `Background image for ${item.audioFilename} exceeds size limit`;
}
}
const wm = normalizeWatermarkSettings(
item.metadata.watermark,
item.metadata.includeWatermark,
@@ -194,7 +219,7 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
if (wm.mode === "text" && !wm.text?.trim()) {
return "Watermark text mode requires non-empty text";
}
if (wm.mode === "text" && wm.fontKey === "custom") {
if (wm.fontKey === "custom") {
if (!wm.fontPath) {
return "Custom font selected but no font file uploaded";
}
@@ -208,7 +233,6 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
}
}
} catch (err) {
if (err instanceof PremiumRequiredError) return err.message;
if (err instanceof Error && err.message === "Invalid upload path") {
return "Invalid upload path";
}
@@ -229,7 +253,7 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
}
export async function createVideoJob(
user: { id: string; plan: Plan },
user: { id: string },
body: CreateJobPayload,
) {
const validationError = await validateJobPayload(user, body);
@@ -237,41 +261,58 @@ export async function createVideoJob(
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;
throw new Error(validationError);
}
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
const items = body.items.map((item) => ({
...item,
audioPath: assertPathInUserUploads(user.id, item.audioPath),
itemImagePath: item.metadata.imagePath
? assertPathInUserUploads(user.id, item.metadata.imagePath)
: null,
watermarkLogoPath: item.metadata.watermark?.logoPath
? assertPathInUserUploads(user.id, item.metadata.watermark.logoPath)
: null,
watermarkFontPath:
item.metadata.watermark?.fontKey === "custom" && item.metadata.watermark?.fontPath
? assertPathInUserUploads(user.id, item.metadata.watermark.fontPath)
const items = body.items.map((item) => {
const layout = resolveLayoutFromMetadata(item.metadata);
const bgRaw =
item.metadata.backgroundImagePath ?? item.metadata.background_image_path ?? null;
const backgroundImagePath =
bgRaw && isLowerCornerTemplate(layout.template)
? assertPathInUserUploads(user.id, bgRaw)
: null;
return {
...item,
audioPath: assertPathInUserUploads(user.id, item.audioPath),
itemImagePath: item.metadata.imagePath
? assertPathInUserUploads(user.id, item.metadata.imagePath)
: null,
}));
backgroundImagePath,
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);
await reserveQuota(user.id, items.length);
const webhookRaw = body.webhookUrl?.trim() || null;
if (webhookRaw) {
try {
const u = new URL(webhookRaw);
if (u.protocol !== "https:" && u.protocol !== "http:") {
throw new Error("webhookUrl must be http(s)");
}
} catch {
throw new Error("Invalid webhookUrl. Provide an absolute http(s) URL for n8n callbacks.");
}
}
try {
const job = await prisma.job.create({
data: {
userId: user.id,
imagePath,
webhookUrl: webhookRaw,
items: {
create: items.map((item) => {
const wm = normalizeWatermarkSettings(
create: items.map((item, index) => {
const wm = applyBrandWatermarkPolicy(
item.metadata.watermark
? {
...item.metadata.watermark,
@@ -282,10 +323,16 @@ export async function createVideoJob(
item.metadata.includeWatermark,
);
const layout = resolveLayoutFromMetadata(item.metadata);
const youtubeTitle = resolveYouTubeTitle(item.metadata);
const burnedTitle = resolveBurnedSongTitle(
item.metadata,
filenameWithoutExtension(item.audioFilename),
);
return {
audioPath: item.audioPath,
audioFilename: item.audioFilename,
title: item.metadata.title.trim(),
title: youtubeTitle,
songTitle: burnedTitle,
description: item.metadata.description || "",
tags: item.metadata.tags || "",
privacy: item.metadata.privacy as Privacy,
@@ -297,25 +344,27 @@ export async function createVideoJob(
creativeCommons: item.metadata.creativeCommons,
includeWatermark: wm.mode !== "none",
itemImagePath: item.itemImagePath,
backgroundImagePath: item.backgroundImagePath,
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,
watermarkFontKey: wm.fontKey ?? "system",
watermarkFontPath:
wm.mode === "text" && wm.fontKey === "custom" ? item.watermarkFontPath : null,
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,
blurFill: layout.blurFill,
blurAmount: layout.blurAmount,
blurOpacity: layout.blurOpacity,
textPadding: layout.textPadding,
titleArtistGap: layout.titleArtistGap,
titleBold: layout.titleBold,
textOffsetX: layout.textOffsetX,
textOffsetY: layout.textOffsetY,
playlistId: item.metadata.playlistId?.trim() || null,
billingSource: "QUOTA",
};
}),
},
@@ -353,6 +402,19 @@ export async function createVideoJob(
}
}
let newBackgroundImage: string | null = null;
if (item.backgroundImagePath) {
const src = item.backgroundImagePath;
if (moved.has(src)) {
newBackgroundImage = moved.get(src)!;
} else {
const ext = path.extname(src);
newBackgroundImage = path.join(jobDir, `${item.id}-bg${ext}`);
await moveFile(src, newBackgroundImage);
moved.set(src, newBackgroundImage);
}
}
let newLogo: string | null = null;
if (item.watermarkLogoPath) {
const src = item.watermarkLogoPath;
@@ -383,6 +445,7 @@ export async function createVideoJob(
data: {
audioPath: newAudioPath,
itemImagePath: newItemImage,
backgroundImagePath: newBackgroundImage,
watermarkLogoPath: newLogo,
watermarkFontPath: newFont,
},
@@ -398,7 +461,6 @@ export async function createVideoJob(
return job;
} catch (err) {
await releaseReservationSplit(user.id, reservation).catch(() => {});
throw err;
}
}
@@ -409,15 +471,11 @@ export async function saveUploadedFile(
userId: string,
file: File,
type: UploadFileType,
plan: Plan,
options?: { sessionKey?: string },
) {
const limits = getPlanLimits(plan);
const limits = getPlanLimits();
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");
}
@@ -429,9 +487,6 @@ export async function saveUploadedFile(
}
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) {
@@ -466,9 +521,9 @@ export async function saveUploadedFile(
) {
throw new Error("Invalid audio file type");
}
if (!isAudioExtensionAllowed(file.name, plan)) {
if (!isAudioExtensionAllowed(file.name)) {
const allowed = limits.allowedAudioExtensions.join(", ");
throw new Error(`Your plan supports ${allowed} audio files only`);
throw new Error(`Supported audio formats: ${allowed}`);
}
}
+1 -7
View File
@@ -1,5 +1,3 @@
import type { Plan } from "@prisma/client";
import { hasProFeatures } from "../edition";
import type { CreateJobPayload, CreatePlaylistRequest, ItemMetadata } from "../types";
import { createYouTubePlaylist } from "../youtube/upload";
@@ -22,7 +20,7 @@ export function parseCreatePlaylistInput(raw: unknown): CreatePlaylistRequest |
}
export async function applyCreatePlaylistToItems(
user: { id: string; plan: Plan },
user: { id: string },
items: CreateJobPayload["items"],
createPlaylist: CreatePlaylistRequest | null | undefined,
) {
@@ -30,10 +28,6 @@ export async function applyCreatePlaylistToItems(
return { items, playlist: null as Awaited<ReturnType<typeof createYouTubePlaylist>> | null };
}
if (!hasProFeatures(user.plan)) {
throw new Error("Creating a YouTube playlist requires the Pro plan");
}
const playlist = await createYouTubePlaylist(user.id, createPlaylist);
const nextItems = items.map((item) => ({
...item,
+247 -11
View File
@@ -8,6 +8,8 @@ export const LAYOUT_TEMPLATES = [
"COVER_TOP_TEXT_BOTTOM",
"COVER_RIGHT_TEXT_LEFT",
"CENTERED_COMPACT",
"LOWER_LEFT_COVER_TEXT",
"LOWER_RIGHT_COVER_TEXT",
] as const;
export type LayoutTemplate = (typeof LAYOUT_TEMPLATES)[number];
@@ -37,11 +39,31 @@ export const TEXT_OFFSET_MIN = -120;
export const TEXT_OFFSET_MAX = 120;
export const TEXT_OFFSET_DEFAULT = 0;
/** Lower-corner cover size as a fraction of encode frame width. */
export const LOWER_CORNER_COVER_WIDTH_FRACTION = 0.23;
export function lowerCornerCoverSide(
width: number,
height: number,
textPadding: number,
): number {
const edge = clampTextPadding(textPadding);
return Math.round(
Math.min(width * LOWER_CORNER_COVER_WIDTH_FRACTION, height - edge * 2),
);
}
export const ARTIST_MAX = 80;
export const SONG_TITLE_MAX = 120;
export type LayoutSettings = {
/** When null, classic letterbox (no art-track layout / blur fill). */
/** When null, classic letterbox (no art-track layout). */
template: LayoutTemplate | null;
/**
* Classic letterbox only: fill letterbox bars with a blurred cover.
* Ignored when `template` is set (art-track always uses a blur fill).
*/
blurFill: boolean;
/** 0100 → FFmpeg boxblur intensity. */
blurAmount: number;
/** 0100 → how visible the blurred fill is (vs black). */
@@ -54,16 +76,20 @@ export type LayoutSettings = {
textOffsetX: number;
/** Shift text block vertically within the template. */
textOffsetY: number;
/** Bold weight for the on-video song title (artist stays regular). */
titleBold: boolean;
};
export const DEFAULT_LAYOUT: LayoutSettings = {
template: null,
blurFill: false,
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,
titleBold: true,
};
export function isLayoutTemplate(v: unknown): v is LayoutTemplate {
@@ -118,6 +144,8 @@ type RawLayoutInput = {
template?: unknown;
layoutTemplate?: unknown;
layout_template?: unknown;
blurFill?: unknown;
blur_fill?: unknown;
blurAmount?: unknown;
blur_amount?: unknown;
blurOpacity?: unknown;
@@ -130,6 +158,8 @@ type RawLayoutInput = {
text_offset_x?: unknown;
textOffsetY?: unknown;
text_offset_y?: unknown;
titleBold?: unknown;
title_bold?: unknown;
/** Rejected clients must not send free-form cover coordinates. */
x?: unknown;
y?: unknown;
@@ -188,15 +218,25 @@ export function normalizeLayoutSettings(
(input as RawLayoutInput).textOffsetY ??
(input as RawLayoutInput).text_offset_y ??
(input as LayoutSettings).textOffsetY;
const boldRaw =
(input as RawLayoutInput).titleBold ??
(input as RawLayoutInput).title_bold ??
(input as LayoutSettings).titleBold;
const blurFillRaw =
(input as RawLayoutInput).blurFill ??
(input as RawLayoutInput).blur_fill ??
(input as LayoutSettings).blurFill;
return {
template,
blurFill: template !== null ? false : Boolean(blurFillRaw),
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),
titleBold: boldRaw === undefined || boldRaw === null ? true : Boolean(boldRaw),
};
}
@@ -341,6 +381,54 @@ export function computeLayoutGeometry(
};
break;
}
case "LOWER_LEFT_COVER_TEXT": {
// Equal left + bottom inset so the cover corner sits on a true diagonal from (0, H).
const edge = pad;
const side = lowerCornerCoverSide(width, height, pad);
const coverTextGap = Math.max(10, Math.round(pad * 0.55));
const textX = edge + side + coverTextGap;
const coverTop = height - edge - side;
const textBlockH = titleFontSize + lineGap + artistFontSize;
const textMidY = coverTop + Math.round(side / 2);
const titleY = Math.max(edge, textMidY - Math.round(textBlockH / 2));
base = {
coverMaxW: side,
coverMaxH: side,
coverX: String(edge),
coverY: `H-h-${edge}`,
titleFontSize,
artistFontSize,
titleX: String(textX),
titleY: String(titleY),
artistX: String(textX),
artistY: String(titleY + titleFontSize + lineGap),
};
break;
}
case "LOWER_RIGHT_COVER_TEXT": {
// Equal right + bottom inset (mirror of lower-left diagonal).
const edge = pad;
const side = lowerCornerCoverSide(width, height, pad);
const coverTextGap = Math.max(10, Math.round(pad * 0.55));
const coverTop = height - edge - side;
const textBlockH = titleFontSize + lineGap + artistFontSize;
const textMidY = coverTop + Math.round(side / 2);
const titleY = Math.max(edge, textMidY - Math.round(textBlockH / 2));
const textInset = edge + side + coverTextGap;
base = {
coverMaxW: side,
coverMaxH: side,
coverX: `W-w-${edge}`,
coverY: `H-h-${edge}`,
titleFontSize,
artistFontSize,
titleX: `W-text_w-${textInset}`,
titleY: String(titleY),
artistX: `W-text_w-${textInset}`,
artistY: String(titleY + titleFontSize + lineGap),
};
break;
}
default: {
throw new Error(INVALID_LAYOUT_TEMPLATE_MESSAGE);
}
@@ -349,13 +437,27 @@ export function computeLayoutGeometry(
return applyTextOffsets(base, textOffsetX, textOffsetY);
}
export function isLowerCornerTemplate(
template: LayoutTemplate | null | undefined,
): boolean {
return template === "LOWER_LEFT_COVER_TEXT" || template === "LOWER_RIGHT_COVER_TEXT";
}
export function buildArtTrackFilterComplex(opts: {
width: number;
height: number;
layout: LayoutSettings;
titleEscaped: string;
artistEscaped: string | null;
/** Regular-weight font for artist (and title when not bold / no bold file). */
fontfileEscaped?: string | null;
/** Bold font for title when `layout.titleBold` and a bold face is available. */
titleFontfileEscaped?: string | null;
/**
* When set, use this FFmpeg input index as the blur-fill source instead of
* splitting the cover (`[0:v]`). Cover stays on input 0 as the sharp overlay.
*/
separateBackgroundInputIndex?: number | null;
}): string {
const { width: W, height: H, layout } = opts;
if (!layout.template) {
@@ -377,15 +479,84 @@ export function buildArtTrackFilterComplex(opts: {
: `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 artistFontPart = opts.fontfileEscaped ? `:fontfile='${opts.fontfileEscaped}'` : "";
const titleUsesBoldFace = Boolean(layout.titleBold && opts.titleFontfileEscaped);
const titleFontPart = titleUsesBoldFace
? `:fontfile='${opts.titleFontfileEscaped}'`
: artistFontPart;
// Faux-bold stroke when bold is requested but no bold TTF is available (custom/curated).
const titleFauxBold =
layout.titleBold && !titleUsesBoldFace ? `:borderw=1:bordercolor=white@0.95` : "";
const titleDraw = `drawtext=text='${opts.titleEscaped}'${titleFontPart}${titleFauxBold}: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}`
? `,drawtext=text='${opts.artistEscaped}'${artistFontPart}:fontsize=${geo.artistFontSize}:fontcolor=white@0.75:x=${geo.artistX}:y=${geo.artistY}`
: "";
const parts: string[] = [`[0:v]split=2[bg][fg]`];
const separateBgIdx =
typeof opts.separateBackgroundInputIndex === "number" &&
Number.isFinite(opts.separateBackgroundInputIndex) &&
opts.separateBackgroundInputIndex >= 0
? opts.separateBackgroundInputIndex
: null;
const parts: string[] = [];
const bgSrc = separateBgIdx !== null ? `[${separateBgIdx}:v]` : "[bg]";
if (separateBgIdx !== null) {
parts.push(
`[0:v]scale=${geo.coverMaxW}:${geo.coverMaxH}:force_original_aspect_ratio=decrease[cover]`,
);
} else {
parts.push(`[0:v]split=2[bg][fg]`);
parts.push(
`[fg]scale=${geo.coverMaxW}:${geo.coverMaxH}:force_original_aspect_ratio=decrease[cover]`,
);
}
// Fade blurred fill toward black when opacity < 100%
if (opacity >= 0.999) {
parts.push(`${bgSrc}${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(
`${bgSrc}${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(
`[blurred][cover]overlay=${geo.coverX}:${geo.coverY}[composed]`,
`[composed]${titleDraw}${artistDraw}[laid]`,
);
return parts.join(";");
}
/**
* Classic letterbox with blurred cover fill (no on-video title/artist).
* Ends at `[laid]` same convention as art-track for encode relabeling.
*/
export function buildClassicBlurFillFilterComplex(opts: {
width: number;
height: number;
blurAmount: number;
blurOpacity: number;
}): string {
const { width: W, height: H } = opts;
const blurSeg = boxblurFilterSegment(opts.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(opts.blurOpacity, BLUR_OPACITY_DEFAULT) / 100;
const parts: string[] = [
`[0:v]split=2[bg][fg]`,
`[fg]scale=${W}:${H}:force_original_aspect_ratio=decrease[cover]`,
];
if (opacity >= 0.999) {
parts.push(`[bg]${bgChain}[blurred]`);
} else if (opacity <= 0.001) {
@@ -400,12 +571,7 @@ export function buildArtTrackFilterComplex(opts: {
);
}
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]`,
);
parts.push(`[blurred][cover]overlay=(W-w)/2:(H-h)/2[laid]`);
return parts.join(";");
}
@@ -414,4 +580,74 @@ export const LAYOUT_TEMPLATE_LABELS: Record<LayoutTemplate, string> = {
COVER_TOP_TEXT_BOTTOM: "Cover top · text bottom",
COVER_RIGHT_TEXT_LEFT: "Cover right · text left",
CENTERED_COMPACT: "Centered compact",
LOWER_LEFT_COVER_TEXT: "Lower left · cover + text",
LOWER_RIGHT_COVER_TEXT: "Lower right · cover + text",
};
/** Composition picker families — mirrored pairs share one tile + variant toggles. */
export type CompositionFamilyId =
| "classic"
| "side"
| "top"
| "centered"
| "lower";
export type CompositionFamily = {
id: CompositionFamilyId;
/** Tile label in the composition grid. */
label: string;
/** Which thumb art to draw (null = classic letterbox). */
thumb: LayoutTemplate | null;
/** Templates in this family; length > 1 shows variant toggles when selected. */
variants: LayoutTemplate[];
/** Default when first selecting the family. */
defaultTemplate: LayoutTemplate | null;
};
export const COMPOSITION_FAMILIES: readonly CompositionFamily[] = [
{
id: "classic",
label: "Classic letterbox",
thumb: null,
variants: [],
defaultTemplate: null,
},
{
id: "side",
label: "Cover beside text",
thumb: "COVER_LEFT_TEXT_RIGHT",
variants: ["COVER_LEFT_TEXT_RIGHT", "COVER_RIGHT_TEXT_LEFT"],
defaultTemplate: "COVER_LEFT_TEXT_RIGHT",
},
{
id: "top",
label: "Cover top · text bottom",
thumb: "COVER_TOP_TEXT_BOTTOM",
variants: ["COVER_TOP_TEXT_BOTTOM"],
defaultTemplate: "COVER_TOP_TEXT_BOTTOM",
},
{
id: "centered",
label: "Centered compact",
thumb: "CENTERED_COMPACT",
variants: ["CENTERED_COMPACT"],
defaultTemplate: "CENTERED_COMPACT",
},
{
id: "lower",
label: "Lower corner · cover + text",
thumb: "LOWER_LEFT_COVER_TEXT",
variants: ["LOWER_LEFT_COVER_TEXT", "LOWER_RIGHT_COVER_TEXT"],
defaultTemplate: "LOWER_LEFT_COVER_TEXT",
},
] as const;
export function compositionFamilyForTemplate(
template: LayoutTemplate | null,
): CompositionFamily {
if (template === null) return COMPOSITION_FAMILIES[0]!;
const found = COMPOSITION_FAMILIES.find((f) =>
f.variants.includes(template),
);
return found ?? COMPOSITION_FAMILIES[0]!;
}
+5 -5
View File
@@ -1,13 +1,13 @@
import { SUPPORT_EMAIL, SALES_EMAIL } from "@/lib/plans";
import { SUPPORT_EMAIL } from "@/lib/plans";
import { BRAND_DOMAIN, BRAND_NAME } from "@/lib/branding";
export const LEGAL_LAST_UPDATED = "July 13, 2026";
export const LEGAL_LAST_UPDATED = "August 3, 2026";
export const LEGAL_OPERATOR = {
name: "Songs2YT",
name: BRAND_NAME,
legalName: "Atakan Doğan Özban",
address: "Universitas u. 2/A",
city: "7622 Pécs, Hungary",
email: SUPPORT_EMAIL,
salesEmail: SALES_EMAIL,
website: "https://www.songs2yt.com",
website: `https://www.${BRAND_DOMAIN}`,
} as const;

Some files were not shown because too many files have changed in this diff Show More