Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8c2e01dbe | ||
|
|
db644ac204 | ||
|
|
0b1a88b070 | ||
|
|
3126784e94 | ||
|
|
d951ecb489 | ||
|
|
bcca0a6e6f | ||
|
|
00dbc132ed | ||
|
|
82f1f904df | ||
|
|
01cfeafb88 | ||
|
|
6b4e7b0ee0 | ||
|
|
105b608cd2 | ||
|
|
6e587613d6 | ||
|
|
2aa8a84781 |
@@ -10,4 +10,3 @@ uploads
|
|||||||
deploy-*.tgz
|
deploy-*.tgz
|
||||||
scripts
|
scripts
|
||||||
bg-video
|
bg-video
|
||||||
integrations
|
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
# Songs2VID OSS is always self-hosted and payment-free.
|
DATABASE_URL="postgresql://s2yt:s2yt@localhost:5432/s2yt"
|
||||||
# Do not set Stripe, billing, credits, pricing, or S2VID_EDITION variables — they are unused.
|
REDIS_URL="redis://localhost:6379"
|
||||||
|
|
||||||
# --- Required ---
|
|
||||||
DATABASE_URL="postgresql://songs2vid:songs2vid@localhost:5433/songs2vid"
|
|
||||||
REDIS_URL="redis://localhost:6380"
|
|
||||||
NEXTAUTH_URL="http://localhost:3000"
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
NEXTAUTH_SECRET="replace-with-a-long-random-secret"
|
NEXTAUTH_SECRET="replace-with-a-long-random-secret"
|
||||||
|
|
||||||
GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com"
|
GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com"
|
||||||
GOOGLE_CLIENT_SECRET="your-google-client-secret"
|
GOOGLE_CLIENT_SECRET="your-google-client-secret"
|
||||||
|
|
||||||
# --- Optional ---
|
|
||||||
# TOKEN_ENCRYPTION_KEY="replace-with-another-long-random-secret" # falls back to NEXTAUTH_SECRET
|
|
||||||
UPLOAD_DIR="./uploads"
|
UPLOAD_DIR="./uploads"
|
||||||
# FFMPEG_PATH="C:/path/to/ffmpeg.exe" # override bundled / image ffmpeg
|
# Self-hosted OSS edition (unlocked features, no Stripe / credits)
|
||||||
# S2VID_PORT=3000 # host port for docker-compose.yml (default 3000)
|
S2VID_EDITION="selfhosted"
|
||||||
# S2VID_IMAGE_TAG=latest # Docker Hub tag when using published image
|
# Optional legacy alias (also accepted by lib/edition.ts):
|
||||||
|
# S2YT_EDITION="selfhosted"
|
||||||
NEXT_PUBLIC_DOCS_URL="https://docs.songs2vid.com"
|
# Public docs (default: https://docs.songs2vid.com)
|
||||||
|
# NEXT_PUBLIC_DOCS_URL="https://docs.songs2vid.com"
|
||||||
NEXT_PUBLIC_GITEA_ISSUES_URL="https://git.atakanozban.com/Songs2VID/songs2vid/issues"
|
NEXT_PUBLIC_GITEA_ISSUES_URL="https://git.atakanozban.com/Songs2VID/songs2vid/issues"
|
||||||
NEXT_PUBLIC_GITEA_URL="https://git.atakanozban.com/Songs2VID"
|
NEXT_PUBLIC_GITEA_URL="https://git.atakanozban.com/Songs2VID/songs2vid"
|
||||||
NEXT_PUBLIC_DOCKER_HUB_URL="https://hub.docker.com/r/atakanozban/songs2vid"
|
NEXT_PUBLIC_DOCKER_HUB_URL="https://hub.docker.com/r/atakanozban/songs2vid"
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ node_modules/
|
|||||||
.next/
|
.next/
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
*.tsbuildinfo
|
|
||||||
tsconfig.tsbuildinfo
|
|
||||||
website/node_modules/
|
website/node_modules/
|
||||||
website/build/
|
website/build/
|
||||||
website/.docusaurus/
|
website/.docusaurus/
|
||||||
|
*.tgz
|
||||||
|
.DS_Store
|
||||||
|
basibozuk_cover.jpg
|
||||||
|
bg-video/
|
||||||
|
_restore/
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
# Songs2VID OSS self-hosted image.
|
|
||||||
|
|
||||||
FROM node:22-bookworm-slim AS deps
|
FROM node:22-bookworm-slim AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -7,6 +6,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-cert
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
COPY prisma ./prisma
|
COPY prisma ./prisma
|
||||||
|
# Use npm install (not ci): lockfiles generated on Windows can omit Linux-only optional packages (@emnapi/*).
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
FROM node:22-bookworm-slim AS builder
|
FROM node:22-bookworm-slim AS builder
|
||||||
@@ -22,37 +22,31 @@ FROM node:22-bookworm-slim AS runner
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV S2VID_EDITION=selfhosted
|
||||||
ENV UPLOAD_DIR=/app/uploads
|
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 \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
openssl ca-certificates ffmpeg \
|
openssl ca-certificates ffmpeg \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& groupadd --system --gid 1001 nodejs \
|
&& groupadd --system --gid 1001 nodejs \
|
||||||
&& useradd --system --uid 1001 --gid nodejs --create-home --home-dir /home/nextjs nextjs \
|
&& useradd --system --uid 1001 --gid nodejs 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
|
USER nextjs
|
||||||
|
|
||||||
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
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 Atakan Doğan Özban
|
Copyright (c) 2026 Songs2YT
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -1,98 +1,86 @@
|
|||||||
# 🎬 Songs2VID
|
# 🎵 Songs2VID
|
||||||
|
|
||||||
Payment-free, self-hosted software that turns cover art and audio into YouTube videos
|
<p align="center">
|
||||||
(FFmpeg encoding + BullMQ jobs). Every deployment has full entitlements — there are no
|
<img src="https://img.shields.io/badge/Status-100%25%20Operational-brightgreen?style=for-the-badge" alt="Status">
|
||||||
plans, credits, purchases, subscriptions, paywalls, or Stripe.
|
<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>
|
||||||
|
|
||||||
Sign in with Google, open the dashboard, and create videos. Settings cover account,
|
<p align="center">
|
||||||
YouTube connection, and API keys only.
|
<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>
|
||||||
|
|
||||||

|
---
|
||||||
|
|
||||||
## 🚀 Quick start (Docker)
|
## What's the deal?
|
||||||
|
|
||||||
Published image: [`atakanozban/songs2vid:latest`](https://hub.docker.com/r/atakanozban/songs2vid)
|
**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.
|
||||||
|
|
||||||
1. Clone this repo (or copy `docker-compose.yml` + `.env.example`).
|
This is the **open-source (self-hosted) edition**:
|
||||||
2. Copy `.env.example` to `.env` and set at least:
|
* **Zero practical limits:** no video quotas, credits, or Stripe paywalls
|
||||||
- `NEXTAUTH_SECRET`
|
* **Fully unlocked:** playlists, REST API, art-track layouts, custom watermarks/fonts, per-track covers (`S2VID_EDITION=selfhosted`)
|
||||||
- `GOOGLE_CLIENT_ID`
|
* **Your infra:** run it on your own server
|
||||||
- `GOOGLE_CLIENT_SECRET`
|
|
||||||
3. In [Google Cloud Console](https://console.cloud.google.com/), enable **YouTube Data API v3**
|
> 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)
|
||||||
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):
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
* **Docker & Docker Compose** (recommended)
|
||||||
|
* *Or:* Node 20+, Postgres, Redis, FFmpeg
|
||||||
|
* Google Cloud OAuth client with **YouTube Data API v3** enabled
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start (Docker)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
cp .env.example .env
|
||||||
|
# Fill in GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, NEXTAUTH_SECRET, and NEXTAUTH_URL
|
||||||
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Compose uses the published image by default (`atakanozban/songs2vid:latest`) and can also
|
* Open `http://localhost:3000`
|
||||||
build from this repo (`docker compose up -d --build`).
|
* OAuth redirect URL: `{NEXTAUTH_URL}/api/auth/callback/google`
|
||||||
|
* `S2VID_EDITION=selfhosted` is set by compose (no Stripe keys required)
|
||||||
|
|
||||||
Open http://localhost:3000 → sign in → dashboard.
|
## Local Development
|
||||||
|
|
||||||
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
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
docker compose -f docker-compose.dev.yml up -d
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
cp .env.example .env # then edit secrets / Google OAuth
|
|
||||||
npm install
|
npm install
|
||||||
npm run db:migrate
|
npm run db:push
|
||||||
npm run dev:all
|
npm run dev # Terminal 1
|
||||||
|
npm run worker # Terminal 2
|
||||||
```
|
```
|
||||||
|
|
||||||
- App: http://localhost:3000
|
Optional: fetch curated watermark fonts into `assets/fonts/` with `node scripts/fetch-watermark-fonts.mjs`.
|
||||||
- `dev:all` runs Next.js and the BullMQ worker
|
|
||||||
|
|
||||||
## ⚙️ Environment
|
## Features
|
||||||
|
|
||||||
See [`.env.example`](./.env.example) for the required and optional variables. Matching guide:
|
* Batch audio → YouTube video jobs
|
||||||
[docs.songs2vid.com/docs/environment](https://docs.songs2vid.com/docs/environment).
|
* 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`)
|
||||||
|
|
||||||
## ⚖️ Legal (self-hosted)
|
## Stack
|
||||||
|
|
||||||
In-app **Privacy** and **Terms** pages describe self-hosted responsibility: the operator of
|
* Next.js · Postgres · Redis / BullMQ · NextAuth (Google) · FFmpeg · YouTube Data API
|
||||||
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).
|
|
||||||
|
|
||||||
## 🧱 Stack
|
## Links
|
||||||
|
|
||||||
- Next.js 15, TypeScript, Tailwind
|
* Docs: https://docs.songs2vid.com
|
||||||
- PostgreSQL and Prisma
|
* Hosted: https://songs2vid.com
|
||||||
- Redis and BullMQ
|
* Gitea: https://git.atakanozban.com/Songs2VID/songs2vid
|
||||||
- NextAuth with Google OAuth (YouTube scopes)
|
* Docker Hub: https://hub.docker.com/r/atakanozban/songs2vid
|
||||||
- FFmpeg
|
|
||||||
- YouTube Data API v3
|
|
||||||
|
|
||||||
## 🔗 Links
|
## License
|
||||||
|
|
||||||
- Docs: [docs.songs2vid.com](https://docs.songs2vid.com)
|
MIT
|
||||||
- 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)
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createUserApiKey, getUserApiKeyStatus, revokeUserApiKey } from "@/lib/api-keys";
|
import { createUserApiKey, getUserApiKeyStatus, revokeUserApiKey } from "@/lib/api-keys";
|
||||||
|
import { hasProFeatures } from "@/lib/edition";
|
||||||
import { getSessionUser } from "@/lib/session";
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
|
||||||
async function requireApiKeyAccess() {
|
async function requireApiKeyAccess() {
|
||||||
@@ -7,6 +8,15 @@ async function requireApiKeyAccess() {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null };
|
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 };
|
return { error: null, user };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
|
import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
|
||||||
|
import { hasProFeatures } from "@/lib/edition";
|
||||||
import { getSessionUser } from "@/lib/session";
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@@ -7,6 +8,13 @@ export async function GET() {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
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);
|
const status = await getApiRateLimitStatus(user.id);
|
||||||
return NextResponse.json(status);
|
return NextResponse.json(status);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,8 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ jobId: job.id });
|
return NextResponse.json({ jobId: job.id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Failed to create job";
|
const message = err instanceof Error ? err.message : "Failed to create job";
|
||||||
return NextResponse.json({ error: message }, { status: 400 });
|
const status = message.includes("Quota exceeded") ? 403 : 400;
|
||||||
|
return NextResponse.json({ error: message }, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
|
||||||
import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job";
|
import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job";
|
||||||
import { getSessionUser } from "@/lib/session";
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
|
||||||
@@ -27,11 +28,14 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await saveUploadedFile(user.id, file, type as UploadFileType, {
|
const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan, {
|
||||||
sessionKey,
|
sessionKey,
|
||||||
});
|
});
|
||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof PremiumRequiredError) {
|
||||||
|
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
|
||||||
|
}
|
||||||
const message = err instanceof Error ? err.message : "Upload failed";
|
const message = err instanceof Error ? err.message : "Upload failed";
|
||||||
return NextResponse.json({ error: message }, { status: 400 });
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { requireApiUser } from "@/lib/api-auth";
|
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
const { error, user } = await requireApiUser(_req);
|
const { error, user } = await requirePaidApiUser(_req);
|
||||||
if (error || !user) return error!;
|
if (error || !user) return error!;
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { Privacy } from "@prisma/client";
|
import { Privacy } from "@prisma/client";
|
||||||
import { requireApiUser } from "@/lib/api-auth";
|
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||||
import { createVideoJob, saveUploadedFile } from "@/lib/jobs/create-job";
|
import { createVideoJob, saveUploadedFile } from "@/lib/jobs/create-job";
|
||||||
import {
|
import {
|
||||||
applyCreatePlaylistToItems,
|
applyCreatePlaylistToItems,
|
||||||
@@ -22,7 +22,6 @@ type BatchItemInput = Partial<ItemMetadata> & {
|
|||||||
function defaultMetadata(title: string): ItemMetadata {
|
function defaultMetadata(title: string): ItemMetadata {
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
songTitle: null,
|
|
||||||
description: "",
|
description: "",
|
||||||
tags: "",
|
tags: "",
|
||||||
privacy: "PUBLIC",
|
privacy: "PUBLIC",
|
||||||
@@ -34,13 +33,12 @@ function defaultMetadata(title: string): ItemMetadata {
|
|||||||
creativeCommons: false,
|
creativeCommons: false,
|
||||||
includeWatermark: false,
|
includeWatermark: false,
|
||||||
playlistId: null,
|
playlistId: null,
|
||||||
artist: null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { error, user } = await requireApiUser(req);
|
const { error, user } = await requirePaidApiUser(req);
|
||||||
if (error || !user) return error!;
|
if (error || !user) return error!;
|
||||||
|
|
||||||
const formData = await req.formData();
|
const formData = await req.formData();
|
||||||
@@ -55,10 +53,10 @@ export async function POST(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const limits = getPlanLimits();
|
const limits = getPlanLimits(user.plan);
|
||||||
if (audioFiles.length > limits.maxBatchSize) {
|
if (audioFiles.length > limits.maxBatchSize) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Batch limit exceeded. Max ${limits.maxBatchSize} files per batch.` },
|
{ error: `Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.` },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -97,10 +95,10 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionKey = Date.now().toString();
|
const sessionKey = Date.now().toString();
|
||||||
const imageUpload = await saveUploadedFile(user.id, image, "image", { sessionKey });
|
const imageUpload = await saveUploadedFile(user.id, image, "image", user.plan, { sessionKey });
|
||||||
|
|
||||||
const uploads = await mapWithConcurrency(audioFiles, 4, (audio) =>
|
const uploads = await mapWithConcurrency(audioFiles, 4, (audio) =>
|
||||||
saveUploadedFile(user.id, audio, "audio", { sessionKey }),
|
saveUploadedFile(user.id, audio, "audio", user.plan, { sessionKey }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const builtItems: CreateJobPayload["items"] = uploads.map((upload, i) => {
|
const builtItems: CreateJobPayload["items"] = uploads.map((upload, i) => {
|
||||||
@@ -142,7 +140,9 @@ export async function POST(req: NextRequest) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Batch upload failed:", err);
|
console.error("Batch upload failed:", err);
|
||||||
const message = err instanceof Error ? err.message : "Batch upload failed";
|
const message = err instanceof Error ? err.message : "Batch upload failed";
|
||||||
const status = message.includes("Batch limit exceeded") ? 400 : 500;
|
const status = message.includes("Quota exceeded") || message.includes("Batch limit exceeded")
|
||||||
|
? 403
|
||||||
|
: 500;
|
||||||
return NextResponse.json({ error: message }, { status });
|
return NextResponse.json({ error: message }, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { requireApiUser } from "@/lib/api-auth";
|
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||||
import { createVideoJob } from "@/lib/jobs/create-job";
|
import { createVideoJob } from "@/lib/jobs/create-job";
|
||||||
import {
|
import {
|
||||||
applyCreatePlaylistToItems,
|
applyCreatePlaylistToItems,
|
||||||
@@ -9,7 +9,7 @@ import { prisma } from "@/lib/db";
|
|||||||
import type { CreateJobPayload } from "@/lib/types";
|
import type { CreateJobPayload } from "@/lib/types";
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { error, user } = await requireApiUser(req);
|
const { error, user } = await requirePaidApiUser(req);
|
||||||
if (error || !user) return error!;
|
if (error || !user) return error!;
|
||||||
|
|
||||||
const body = (await req.json()) as CreateJobPayload & { createPlaylist?: unknown };
|
const body = (await req.json()) as CreateJobPayload & { createPlaylist?: unknown };
|
||||||
@@ -35,27 +35,25 @@ export async function POST(req: NextRequest) {
|
|||||||
const job = await createVideoJob(user, {
|
const job = await createVideoJob(user, {
|
||||||
imagePath: body.imagePath,
|
imagePath: body.imagePath,
|
||||||
items,
|
items,
|
||||||
webhookUrl: body.webhookUrl ?? null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
itemCount: job.items.length,
|
itemCount: job.items.length,
|
||||||
status: job.status,
|
status: job.status,
|
||||||
statusUrl: `/api/v1/jobs/${job.id}`,
|
|
||||||
webhookUrl: body.webhookUrl ?? null,
|
|
||||||
playlist: playlist
|
playlist: playlist
|
||||||
? { id: playlist.id, title: playlist.title, privacy: playlist.privacy }
|
? { id: playlist.id, title: playlist.title, privacy: playlist.privacy }
|
||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Failed to create job";
|
const message = err instanceof Error ? err.message : "Failed to create job";
|
||||||
return NextResponse.json({ error: message }, { status: 400 });
|
const status = message.includes("Quota exceeded") ? 403 : 400;
|
||||||
|
return NextResponse.json({ error: message }, { status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const { error, user } = await requireApiUser(req);
|
const { error, user } = await requirePaidApiUser(req);
|
||||||
if (error || !user) return error!;
|
if (error || !user) return error!;
|
||||||
|
|
||||||
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100);
|
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { requireApiUser } from "@/lib/api-auth";
|
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||||
import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist";
|
import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist";
|
||||||
import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload";
|
import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const { error, user } = await requireApiUser(req);
|
const { error, user } = await requirePaidApiUser(req);
|
||||||
if (error || !user) return error!;
|
if (error || !user) return error!;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -18,7 +18,7 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { error, user } = await requireApiUser(req);
|
const { error, user } = await requirePaidApiUser(req);
|
||||||
if (error || !user) return error!;
|
if (error || !user) return error!;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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";
|
|
||||||
@@ -1,9 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import {
|
|
||||||
COMPOSITION_FAMILIES,
|
|
||||||
LAYOUT_TEMPLATE_LABELS,
|
|
||||||
LAYOUT_TEMPLATES,
|
|
||||||
} from "@/lib/layout";
|
|
||||||
import { API_DOCS_URL } from "@/lib/plans";
|
import { API_DOCS_URL } from "@/lib/plans";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@@ -11,54 +6,26 @@ export async function GET() {
|
|||||||
name: "Songs2VID API",
|
name: "Songs2VID API",
|
||||||
version: "1.0",
|
version: "1.0",
|
||||||
authentication: "Authorization: Bearer <api_key>",
|
authentication: "Authorization: Bearer <api_key>",
|
||||||
requirements: ["YouTube account connected"],
|
requirements: ["Self-hosted edition", "YouTube account connected"],
|
||||||
rateLimit: "100,000 requests per minute per account",
|
rateLimit: "Generous self-hosted limits (see docs)",
|
||||||
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: {
|
guidance: {
|
||||||
recommended:
|
recommended:
|
||||||
"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",
|
"For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/jobs with the returned paths",
|
||||||
batch:
|
batch:
|
||||||
"POST /api/v1/jobs/batch is for small packs only. Large multipart bodies may fail with 'failed to parse body as FormData'",
|
"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: [
|
endpoints: [
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/api/v1/upload",
|
path: "/api/v1/upload",
|
||||||
description:
|
description: "Upload a single image or audio file (use for two-step flow)",
|
||||||
"Upload image, audio, PNG logo, or .ttf/.otf font (font ≤10MB)",
|
body: "multipart/form-data: file, type (image|audio)",
|
||||||
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",
|
method: "POST",
|
||||||
path: "/api/v1/jobs",
|
path: "/api/v1/jobs",
|
||||||
description:
|
description: "Create a video job from uploaded file paths (recommended for large batches)",
|
||||||
"Create a video job from uploaded file paths with layouts, watermark, and per-item covers",
|
body: "application/json: { imagePath, items[] }",
|
||||||
body: "application/json: { imagePath, webhookUrl?, items[{ audioPath, audioFilename, metadata }] }",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -81,13 +48,13 @@ export async function GET() {
|
|||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/api/v1/jobs",
|
path: "/api/v1/jobs",
|
||||||
description: "List recent jobs (also available as GET /api/v1/render)",
|
description: "List recent jobs",
|
||||||
query: "limit (default 20, max 100)",
|
query: "limit (default 20, max 100)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/api/v1/jobs/:id",
|
path: "/api/v1/jobs/:id",
|
||||||
description: "Get job status and item details (poll for n8n)",
|
description: "Get job status and item details",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
docs: API_DOCS_URL,
|
docs: API_DOCS_URL,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { requireApiUser } from "@/lib/api-auth";
|
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||||
|
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
|
||||||
import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job";
|
import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job";
|
||||||
|
|
||||||
export const maxDuration = 120;
|
export const maxDuration = 120;
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { error, user } = await requireApiUser(req);
|
const { error, user } = await requirePaidApiUser(req);
|
||||||
if (error || !user) return error!;
|
if (error || !user) return error!;
|
||||||
|
|
||||||
const formData = await req.formData();
|
const formData = await req.formData();
|
||||||
@@ -24,9 +25,12 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await saveUploadedFile(user.id, file, type as UploadFileType);
|
const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan);
|
||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof PremiumRequiredError) {
|
||||||
|
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
|
||||||
|
}
|
||||||
const message = err instanceof Error ? err.message : "Upload failed";
|
const message = err instanceof Error ? err.message : "Upload failed";
|
||||||
return NextResponse.json({ error: message }, { status: 400 });
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { hasProFeatures } from "@/lib/edition";
|
||||||
import { getSessionUser } from "@/lib/session";
|
import { getSessionUser } from "@/lib/session";
|
||||||
import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist";
|
import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist";
|
||||||
import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload";
|
import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload";
|
||||||
@@ -8,6 +9,15 @@ async function requirePremiumYouTubeUser() {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null };
|
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) {
|
if (!user.youtubeConnection) {
|
||||||
return {
|
return {
|
||||||
error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }),
|
error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { DashboardShell } from "@/components/DashboardShell";
|
|||||||
import { getUserApiKeyStatus } from "@/lib/api-keys";
|
import { getUserApiKeyStatus } from "@/lib/api-keys";
|
||||||
import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
|
import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
|
||||||
import { ApiKeySettings } from "@/components/ApiKeySettings";
|
import { ApiKeySettings } from "@/components/ApiKeySettings";
|
||||||
|
import { getQuotaInfo } from "@/lib/quota";
|
||||||
|
|
||||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -28,10 +29,9 @@ export default async function SettingsPage() {
|
|||||||
});
|
});
|
||||||
if (!user) redirect("/");
|
if (!user) redirect("/");
|
||||||
|
|
||||||
const [apiKeyStatus, apiRateLimit] = await Promise.all([
|
const quota = await getQuotaInfo(user.id);
|
||||||
getUserApiKeyStatus(user.id),
|
const apiKeyStatus = await getUserApiKeyStatus(user.id);
|
||||||
getApiRateLimitStatus(user.id),
|
const apiRateLimit = await getApiRateLimitStatus(user.id);
|
||||||
]);
|
|
||||||
const youtube = user.youtubeConnection;
|
const youtube = user.youtubeConnection;
|
||||||
const channelUrl = youtube ? `https://www.youtube.com/channel/${youtube.channelId}` : null;
|
const channelUrl = youtube ? `https://www.youtube.com/channel/${youtube.channelId}` : null;
|
||||||
|
|
||||||
@@ -39,51 +39,80 @@ export default async function SettingsPage() {
|
|||||||
<DashboardShell channelTitle={youtube?.channelTitle}>
|
<DashboardShell channelTitle={youtube?.channelTitle}>
|
||||||
<div className="mx-auto max-w-4xl px-6 py-8">
|
<div className="mx-auto max-w-4xl px-6 py-8">
|
||||||
<h1 className="mb-6 text-2xl font-bold text-white">Settings</h1>
|
<h1 className="mb-6 text-2xl font-bold text-white">Settings</h1>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
||||||
<h2 className="mb-4 text-lg font-semibold text-white">Account</h2>
|
<h2 className="mb-4 text-lg font-semibold text-white">Account information</h2>
|
||||||
<dl className="space-y-3 text-sm">
|
<dl className="space-y-3 text-sm">
|
||||||
<InfoRow label="Name" value={user.name || "Not set"} />
|
<InfoRow label="Name" value={user.name || "Not set"} />
|
||||||
<InfoRow label="Email" value={user.email} />
|
<InfoRow label="Email" value={user.email} />
|
||||||
<InfoRow label="Videos created" value={String(user.createdVideoCount)} />
|
</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`} />
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
||||||
<h2 className="mb-4 text-lg font-semibold text-white">YouTube</h2>
|
<h2 className="mb-4 text-lg font-semibold text-white">API access</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} />
|
<ApiKeySettings initialStatus={apiKeyStatus} initialRateLimit={apiRateLimit} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
||||||
<h2 className="mb-2 text-lg font-semibold text-white">Account deletion & data</h2>
|
<h2 className="mb-2 text-lg font-semibold text-white">Account deletion & data</h2>
|
||||||
<p className="mb-4 text-sm text-gray-400">
|
<p className="mb-4 text-sm text-gray-400">
|
||||||
Request a copy of your data or permanently delete your account and uploads.
|
Request a copy of your data or permanently delete your account and associated uploads.
|
||||||
</p>
|
</p>
|
||||||
<AccountPrivacyActions email={user.email} />
|
<AccountPrivacyActions email={user.email} />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -116,68 +116,6 @@
|
|||||||
.mobile-sidebar-link-danger:hover::before {
|
.mobile-sidebar-link-danger:hover::before {
|
||||||
@apply bg-red-400;
|
@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 {
|
@keyframes mockup-float {
|
||||||
@@ -308,12 +246,3 @@
|
|||||||
transform: scale(0.6) rotate(90deg);
|
transform: scale(0.6) rotate(90deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes badge-marquee-scroll {
|
|
||||||
from {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 374 KiB |
@@ -5,18 +5,10 @@ import { Providers } from "./providers";
|
|||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: DEFAULT_TITLE,
|
title: "Songs2YT",
|
||||||
description: DEFAULT_DESCRIPTION,
|
description:
|
||||||
applicationName: SITE_NAME,
|
"Songs2YT is an automation tool that converts your audio and image files into high-quality videos and uploads them directly to YouTube.",
|
||||||
icons: {
|
|
||||||
icon: "/favicon.png",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -25,8 +17,8 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en">
|
||||||
<body className={inter.className} suppressHydrationWarning>
|
<body className={inter.className}>
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,27 +1,106 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { Logo } from "@/components/Logo";
|
import { BenefitsSection } from "@/components/BenefitsSection";
|
||||||
|
import { DownloadSection } from "@/components/DownloadSection";
|
||||||
|
import { LandingNavbar } from "@/components/LandingNavbar";
|
||||||
import { SignInButton } from "@/components/SignInButton";
|
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() {
|
export default async function HomePage() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (session) redirect("/dashboard");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-dvh items-center justify-center bg-surface px-6">
|
<main className="min-h-screen">
|
||||||
<div className="w-full max-w-sm rounded-xl border border-gray-700 bg-surface-light p-8 text-center shadow-xl">
|
<section className="relative min-h-dvh overflow-hidden">
|
||||||
<div className="mb-6 flex justify-center">
|
<video
|
||||||
<Logo size="lg" />
|
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>
|
||||||
</div>
|
</div>
|
||||||
<SignInButton large />
|
</section>
|
||||||
<p className="mt-5 text-xs leading-relaxed text-gray-500">
|
|
||||||
By continuing, you accept the{" "}
|
<StepsSection />
|
||||||
<Link href="/terms" className="hover:text-gray-300">Terms</Link> and{" "}
|
<BenefitsSection />
|
||||||
<Link href="/privacy" className="hover:text-gray-300">Privacy Policy</Link>.
|
<DownloadSection />
|
||||||
</p>
|
<SupportSection />
|
||||||
</div>
|
<Footer />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,76 +1,189 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import { LegalPageLayout } from "@/components/LegalPageLayout";
|
import { LegalPageLayout } from "@/components/LegalPageLayout";
|
||||||
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
|
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
|
||||||
import { DOCS_URL, GITEA_URL } from "@/lib/plans";
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Privacy Policy | Songs2YT",
|
||||||
|
description: "How Songs2YT collects, uses, and protects your personal data.",
|
||||||
|
};
|
||||||
|
|
||||||
export default function PrivacyPage() {
|
export default function PrivacyPage() {
|
||||||
return (
|
return (
|
||||||
<LegalPageLayout title="Privacy Policy">
|
<LegalPageLayout
|
||||||
|
title="Privacy Policy"
|
||||||
|
description="How we handle your personal data when you use Songs2YT."
|
||||||
|
>
|
||||||
|
<h2>1. Overview</h2>
|
||||||
<p>
|
<p>
|
||||||
This policy describes how a <strong>self-hosted Songs2VID</strong> instance typically
|
This Privacy Policy explains how {LEGAL_OPERATOR.name} ("we", "us")
|
||||||
handles data. Songs2VID OSS does not include payment processing, plans, credits, or
|
processes personal data when you use our website, hosted cloud service, and related features
|
||||||
Stripe. The person or organization that operates this deployment (the "operator")
|
that convert audio and images into videos for upload to YouTube.
|
||||||
controls the server, database, uploads, logs, and Google OAuth configuration, and is
|
</p>
|
||||||
responsible for privacy compliance for their users.
|
<p>
|
||||||
|
We process personal data in accordance with applicable data protection laws, including the
|
||||||
|
General Data Protection Regulation (GDPR) where it applies.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>1. Who is responsible</h2>
|
<h2>2. Data controller</h2>
|
||||||
<p>
|
<p>
|
||||||
For this software package as published by {LEGAL_OPERATOR.legalName}, contact:{" "}
|
{LEGAL_OPERATOR.legalName}
|
||||||
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>. For data on{" "}
|
<br />
|
||||||
<em>this running instance</em>, contact the operator of the deployment you signed into —
|
{LEGAL_OPERATOR.address}
|
||||||
not necessarily the hosted SaaS at songs2vid.com, which is a separate product with its own
|
<br />
|
||||||
policies.
|
{LEGAL_OPERATOR.city}
|
||||||
|
<br />
|
||||||
|
Email: <a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>2. Data this software processes</h2>
|
<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>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Account profile from Google sign-in (email, name, avatar)</li>
|
<li>Your name, email address, and profile image (from Google)</li>
|
||||||
<li>YouTube OAuth tokens (encrypted at rest when configured) and channel metadata</li>
|
<li>OAuth tokens required to authenticate your session</li>
|
||||||
<li>Uploaded cover art, audio, optional logos/fonts, and derived video files</li>
|
<li>YouTube connection data, including channel ID and title</li>
|
||||||
<li>Job metadata (titles, tags, privacy, layouts, watermarks)</li>
|
<li>Encrypted YouTube API access and refresh tokens needed to upload videos on your behalf</li>
|
||||||
<li>API key hashes and rate-limit counters</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>3. Google and YouTube</h2>
|
<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>
|
||||||
<p>
|
<p>
|
||||||
Google and YouTube process account and upload data under their own terms and policies.
|
If you purchase a paid plan, payment processing is handled by our payment provider. We do
|
||||||
Operators must configure OAuth correctly and respect{" "}
|
not store full payment card details on our servers. We may receive billing status,
|
||||||
<a
|
subscription identifiers, and transaction references.
|
||||||
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>
|
</p>
|
||||||
|
|
||||||
<h2>4. Your choices</h2>
|
<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>
|
||||||
<p>
|
<p>
|
||||||
Use <strong>Dashboard → Settings</strong> to export or delete your account data on this
|
These providers process data only as necessary to deliver their services and under
|
||||||
instance, or ask the operator. You can also revoke Google access in your{" "}
|
appropriate contractual safeguards where required.
|
||||||
<a
|
|
||||||
href="https://security.google.com/settings/security/permissions"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Google Account permissions
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>5. Documentation</h2>
|
<h2>Google User Data Sharing and Disclosure</h2>
|
||||||
<p>
|
<p>
|
||||||
Product docs: <a href={DOCS_URL}>{DOCS_URL}</a>. Source:{" "}
|
We do not sell, share, transfer, or disclose any Google user data to any third parties.
|
||||||
<a href={GITEA_URL}>{GITEA_URL}</a>.
|
</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>
|
||||||
</p>
|
</p>
|
||||||
</LegalPageLayout>
|
</LegalPageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,51 +1,197 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
import { LegalPageLayout } from "@/components/LegalPageLayout";
|
import { LegalPageLayout } from "@/components/LegalPageLayout";
|
||||||
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
|
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
|
||||||
import { DOCS_URL, GITEA_URL } from "@/lib/plans";
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Terms of Service | Songs2YT",
|
||||||
|
description: "Terms and conditions for using the Songs2YT hosted service.",
|
||||||
|
};
|
||||||
|
|
||||||
export default function TermsPage() {
|
export default function TermsPage() {
|
||||||
return (
|
return (
|
||||||
<LegalPageLayout title="Terms of Use">
|
<LegalPageLayout
|
||||||
|
title="Terms of Service"
|
||||||
|
description="Please read these terms carefully before using Songs2YT."
|
||||||
|
>
|
||||||
|
<h2>1. Agreement</h2>
|
||||||
<p>
|
<p>
|
||||||
Songs2VID OSS is open-source, <strong>payment-free, self-hosted software</strong>. These
|
These Terms of Service ("Terms") govern your access to and use of the Songs2YT
|
||||||
terms describe use of the software and of instances that run it. They are{" "}
|
website and hosted cloud service (the "Service") operated by{" "}
|
||||||
<strong>not</strong> the paid cloud Terms of Service for songs2vid.com (a separate hosted
|
{LEGAL_OPERATOR.legalName} ("we", "us"). By creating an account or using
|
||||||
product with its own billing and policies).
|
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.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>1. Software license and warranty</h2>
|
<h2>2. The Service</h2>
|
||||||
<p>
|
<p>
|
||||||
The source is provided under the license in the repository <code>LICENSE</code> file. The
|
Songs2YT converts user-provided images and audio files into videos and can upload them to
|
||||||
software is provided <strong>without warranty</strong> of any kind. The operator of each
|
YouTube using your connected Google/YouTube account. Features, limits, and availability
|
||||||
instance is responsible for availability, security, backups, configuration, and who may
|
depend on your plan.
|
||||||
sign in.
|
</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.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>2. No paid features in this edition</h2>
|
<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 "Made for Kids" and privacy settings) accurately</li>
|
||||||
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
This edition has no Stripe integration, subscriptions, credits, pricing pages, or paywalls.
|
You are solely responsible for content published to your YouTube channel through the
|
||||||
Full layout, watermark, API, and playlist features are available to signed-in users of the
|
Service.
|
||||||
instance. Any paid offering lives only on the separate hosted SaaS product.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>3. Your content and YouTube</h2>
|
<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>
|
||||||
<p>
|
<p>
|
||||||
You are solely responsible for media you upload and for compliance with copyright law and
|
The Service integrates with Google OAuth and the YouTube API. Your use of those services is
|
||||||
YouTube's terms. Connecting Google/YouTube authorizes the instance to upload on your
|
subject to Google's and YouTube's terms and policies. We are not responsible for
|
||||||
behalf within the scopes granted.
|
changes, outages, quota limits, or enforcement actions taken by YouTube.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>4. Acceptable use</h2>
|
<h2>7. Open-source software</h2>
|
||||||
<p>
|
<p>
|
||||||
Do not use the software to infringe rights, abuse YouTube or Google APIs, or circumvent
|
Portions of Songs2YT are available as open-source software. Self-hosting is permitted under
|
||||||
another party's security. Operators may suspend access on their instances.
|
the applicable open-source license. The hosted Service, enterprise features, managed
|
||||||
|
infrastructure, and certain premium capabilities may require a separate commercial license
|
||||||
|
or subscription.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>5. Contact</h2>
|
<h2>8. Fees and billing</h2>
|
||||||
<p>
|
<p>
|
||||||
Package / project contact:{" "}
|
Paid plans are billed according to the pricing displayed at the time of purchase. Taxes may
|
||||||
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>. Instance-specific
|
apply. Subscriptions renew automatically unless cancelled in accordance with our{" "}
|
||||||
support: contact your operator. Docs: <a href={DOCS_URL}>{DOCS_URL}</a>. Source:{" "}
|
<Link href="/refund">Refund Policy</Link>. Failure to pay may result in downgrade or
|
||||||
<a href={GITEA_URL}>{GITEA_URL}</a>.
|
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 "AS IS" AND "AS AVAILABLE" 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>
|
||||||
</p>
|
</p>
|
||||||
</LegalPageLayout>
|
</LegalPageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
@@ -1,7 +1,5 @@
|
|||||||
# Watermark image
|
# Watermark image
|
||||||
|
|
||||||
Place `watermark.png` here for the default bottom-right video overlay.
|
Place `watermark.png` here for the bottom-right video overlay.
|
||||||
|
The image should include the full attribution: "Uploaded through Songs2YT.com".
|
||||||
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.
|
If missing, FFmpeg falls back to bottom-right drawtext with the same message.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 374 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 32 KiB |
@@ -37,7 +37,7 @@ export function AccountPrivacyActions({ email }: Props) {
|
|||||||
|
|
||||||
const dataRequestSubject = encodeURIComponent("Data export request");
|
const dataRequestSubject = encodeURIComponent("Data export request");
|
||||||
const dataRequestBody = encodeURIComponent(
|
const dataRequestBody = encodeURIComponent(
|
||||||
`Hello,\n\nI would like to request a copy of my personal data associated with my Songs2VID account (${email}).\n\nThank you.`,
|
`Hello,\n\nI would like to request a copy of my personal data associated with my Songs2YT account (${email}).\n\nThank you.`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,55 +1,94 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { API_DOCS_URL } from "@/lib/plans";
|
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 = {
|
type Props = {
|
||||||
initialStatus: { configured: boolean; prefix: string | null };
|
initialStatus: ApiKeyStatus;
|
||||||
initialRateLimit: {
|
initialRateLimit: RateLimitStatus;
|
||||||
limit: number;
|
|
||||||
used: number;
|
|
||||||
remaining: number;
|
|
||||||
windowSeconds: number;
|
|
||||||
resetsInSeconds: number;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ApiKeySettings({ initialStatus, initialRateLimit }: Props) {
|
export function ApiKeySettings({ initialStatus, initialRateLimit }: Props) {
|
||||||
const [status, setStatus] = useState(initialStatus);
|
const [status, setStatus] = useState(initialStatus);
|
||||||
|
const [rateLimit, setRateLimit] = useState(initialRateLimit);
|
||||||
const [newKey, setNewKey] = useState<string | null>(null);
|
const [newKey, setNewKey] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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() {
|
async function generateKey() {
|
||||||
if (status.configured && !confirm("Replace your existing API key?")) return;
|
if (
|
||||||
|
status.configured &&
|
||||||
|
!confirm("This will replace your existing API key. Continue?")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
setNewKey(null);
|
setNewKey(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/account/api-key", { method: "POST" });
|
const res = await fetch("/api/account/api-key", { method: "POST" });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error || "Failed to generate API key");
|
if (!res.ok) throw new Error(data.error || "Failed to generate key");
|
||||||
setNewKey(data.apiKey);
|
setNewKey(data.apiKey);
|
||||||
setStatus({ configured: true, prefix: data.prefix });
|
setStatus({ configured: true, prefix: data.prefix });
|
||||||
} catch (err) {
|
setSuccess("API key generated. Copy it now — it will not be shown again.");
|
||||||
setError(err instanceof Error ? err.message : "Failed to generate API key");
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to generate key");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function revokeKey() {
|
async function revokeKey() {
|
||||||
if (!confirm("Revoke your API key?")) return;
|
if (!confirm("Revoke the current API key?")) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
setNewKey(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/account/api-key", { method: "DELETE" });
|
const res = await fetch("/api/account/api-key", { method: "DELETE" });
|
||||||
const data = await res.json();
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(data.error || "Failed to revoke API key");
|
if (!res.ok) throw new Error(data.error || "Failed to revoke key");
|
||||||
setStatus({ configured: false, prefix: null });
|
setStatus({ configured: false, prefix: null });
|
||||||
setNewKey(null);
|
setSuccess("API key revoked.");
|
||||||
} catch (err) {
|
} catch (e) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to revoke API key");
|
setError(e instanceof Error ? e.message : "Failed to revoke key");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -58,32 +97,55 @@ export function ApiKeySettings({ initialStatus, initialRateLimit }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
Use the REST API to upload files and create video jobs programmatically. There are no
|
Use the REST API to upload files and create batch video jobs programmatically.
|
||||||
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>
|
</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 && (
|
{status.configured && status.prefix && (
|
||||||
<p className="text-sm text-gray-300">
|
<p className="text-sm text-gray-300">
|
||||||
Active key: <span className="font-mono text-white">{status.prefix}…</span>
|
Active key: <span className="font-mono text-white">{status.prefix}…</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{newKey && (
|
{newKey && (
|
||||||
<div className="rounded border border-green-500/40 bg-green-500/10 p-4">
|
<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">Copy your new API key now</p>
|
<p className="mb-2 text-sm font-medium text-green-300">Your new API key (copy now)</p>
|
||||||
<code className="block break-all rounded bg-black/40 px-3 py-2 text-xs text-green-200">
|
<code className="block break-all rounded bg-black/40 px-3 py-2 text-xs text-green-200">
|
||||||
{newKey}
|
{newKey}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && <p className="text-sm text-red-300">{error}</p>}
|
|
||||||
<div className="flex flex-wrap gap-3">
|
{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">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={generateKey}
|
onClick={generateKey}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover disabled:opacity-50"
|
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"
|
||||||
>
|
>
|
||||||
{loading ? "Working…" : status.configured ? "Regenerate API key" : "Generate API key"}
|
{loading ? "Working…" : status.configured ? "Regenerate API key" : "Generate API key"}
|
||||||
</button>
|
</button>
|
||||||
@@ -92,19 +154,36 @@ export function ApiKeySettings({ initialStatus, initialRateLimit }: Props) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={revokeKey}
|
onClick={revokeKey}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="rounded border border-red-600/50 px-4 py-2 text-sm font-medium text-red-300 hover:bg-red-500/10"
|
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"
|
||||||
>
|
>
|
||||||
Revoke API key
|
Revoke API key
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<a
|
</div>
|
||||||
href={API_DOCS_URL}
|
|
||||||
target="_blank"
|
<div className="rounded border border-gray-800 bg-surface p-4 text-xs text-gray-400">
|
||||||
rel="noopener noreferrer"
|
<p className="mb-2 font-semibold uppercase tracking-wider text-gray-500">Endpoints</p>
|
||||||
className="rounded border border-gray-600 px-4 py-2 text-sm text-gray-300 hover:text-white"
|
<ul className="space-y-2 font-mono">
|
||||||
>
|
<li>POST /api/v1/upload: upload image or audio file</li>
|
||||||
API documentation
|
<li>POST /api/v1/jobs: create job from paths (recommended for large batches)</li>
|
||||||
</a>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,436 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,9 +20,7 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
export function JobProgress({ jobId }: Props) {
|
export function JobProgress({ jobId }: Props) {
|
||||||
const [job, setJob] = useState<JobResponse | null>(null);
|
const [job, setJob] = useState<JobResponse | null>(null);
|
||||||
const [quota, setQuota] = useState<{
|
const [videosProcessed, setVideosProcessed] = useState<number | null>(null);
|
||||||
used: number;
|
|
||||||
} | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -41,10 +39,7 @@ export function JobProgress({ jobId }: Props) {
|
|||||||
|
|
||||||
if (quotaRes.ok) {
|
if (quotaRes.ok) {
|
||||||
const quotaData = await quotaRes.json();
|
const quotaData = await quotaRes.json();
|
||||||
if (active)
|
if (active) setVideosProcessed(quotaData.used ?? 0);
|
||||||
setQuota({
|
|
||||||
used: quotaData.used ?? 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (active) setError(err instanceof Error ? err.message : "Error loading job");
|
if (active) setError(err instanceof Error ? err.message : "Error loading job");
|
||||||
@@ -81,9 +76,9 @@ export function JobProgress({ jobId }: Props) {
|
|||||||
Overall: <span className="text-white">{job.status}</span>
|
Overall: <span className="text-white">{job.status}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{quota && (
|
{videosProcessed !== null && (
|
||||||
<div className="rounded bg-surface-light px-4 py-2 text-sm text-gray-300">
|
<div className="rounded bg-surface-light px-4 py-2 text-sm text-gray-300">
|
||||||
{quota.used} videos created
|
Self-hosted · {videosProcessed} videos processed
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { getVideoAttributionText } from "@/lib/branding";
|
import { getVideoAttributionText } from "@/lib/branding";
|
||||||
import {
|
import {
|
||||||
CURATED_FONTS,
|
CURATED_FONTS,
|
||||||
SYSTEM_FONT,
|
googleFontsStylesheetUrl,
|
||||||
type CuratedFontKey,
|
type CuratedFontKey,
|
||||||
type WatermarkFontKey,
|
type WatermarkFontKey,
|
||||||
} from "@/lib/fonts";
|
} from "@/lib/fonts";
|
||||||
@@ -20,30 +20,18 @@ import {
|
|||||||
BLUR_OPACITY_DEFAULT,
|
BLUR_OPACITY_DEFAULT,
|
||||||
BLUR_OPACITY_MAX,
|
BLUR_OPACITY_MAX,
|
||||||
BLUR_OPACITY_MIN,
|
BLUR_OPACITY_MIN,
|
||||||
COMPOSITION_FAMILIES,
|
|
||||||
DEFAULT_LAYOUT,
|
DEFAULT_LAYOUT,
|
||||||
LAYOUT_TEMPLATE_LABELS,
|
LAYOUT_TEMPLATE_LABELS,
|
||||||
|
LAYOUT_TEMPLATES,
|
||||||
TEXT_OFFSET_MAX,
|
TEXT_OFFSET_MAX,
|
||||||
TEXT_OFFSET_MIN,
|
TEXT_OFFSET_MIN,
|
||||||
TEXT_PADDING_MAX,
|
TEXT_PADDING_MAX,
|
||||||
TEXT_PADDING_MIN,
|
TEXT_PADDING_MIN,
|
||||||
TITLE_ARTIST_GAP_MAX,
|
TITLE_ARTIST_GAP_MAX,
|
||||||
TITLE_ARTIST_GAP_MIN,
|
TITLE_ARTIST_GAP_MIN,
|
||||||
compositionFamilyForTemplate,
|
|
||||||
isLowerCornerTemplate,
|
|
||||||
lowerCornerCoverSide,
|
|
||||||
type LayoutSettings,
|
type LayoutSettings,
|
||||||
type LayoutTemplate,
|
type LayoutTemplate,
|
||||||
} from "@/lib/layout";
|
} from "@/lib/layout";
|
||||||
import {
|
|
||||||
WATERMARK_WIDTH_FRACTION,
|
|
||||||
artistFontSizeForWidth,
|
|
||||||
curatedFontApiUrl,
|
|
||||||
previewFontFamilyCss,
|
|
||||||
scaleFontToPreview,
|
|
||||||
titleFontSizeForWidth,
|
|
||||||
watermarkFontSizeForWidth,
|
|
||||||
} from "@/lib/preview-typography";
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_WATERMARK,
|
DEFAULT_WATERMARK,
|
||||||
WATERMARK_OFFSET_MAX,
|
WATERMARK_OFFSET_MAX,
|
||||||
@@ -56,25 +44,17 @@ import {
|
|||||||
} from "@/lib/watermark";
|
} from "@/lib/watermark";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
locked: boolean;
|
locked?: boolean;
|
||||||
previewImageUrl: string | null;
|
previewImageUrl: string | null;
|
||||||
/** Optional custom blur-fill for lower-corner layouts. */
|
title: string;
|
||||||
previewBackgroundUrl?: string | null;
|
|
||||||
/** On-video song title (art-track layouts). */
|
|
||||||
songTitle: string;
|
|
||||||
artist: string;
|
artist: string;
|
||||||
/** Encode width from selected resolution (e.g. 1280). */
|
|
||||||
encodeWidth?: number;
|
|
||||||
layout: LayoutSettings;
|
layout: LayoutSettings;
|
||||||
onLayoutChange: (next: LayoutSettings) => void;
|
onLayoutChange: (next: LayoutSettings) => void;
|
||||||
watermark: WatermarkSettings;
|
watermark: WatermarkSettings;
|
||||||
onWatermarkChange: (next: WatermarkSettings) => void;
|
onWatermarkChange: (next: WatermarkSettings) => void;
|
||||||
onUploadLogo: (file: File) => Promise<string>;
|
onUploadLogo: (file: File) => Promise<string>;
|
||||||
onUploadFont: (file: File) => Promise<string>;
|
onUploadFont: (file: File) => Promise<string>;
|
||||||
onUploadBackground?: (file: File) => Promise<string>;
|
|
||||||
onClearBackground?: () => void;
|
|
||||||
logoPreviewUrl?: string | null;
|
logoPreviewUrl?: string | null;
|
||||||
hasCustomBackground?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const WM_POSITION_LABELS: Record<WatermarkPosition, string> = {
|
const WM_POSITION_LABELS: Record<WatermarkPosition, string> = {
|
||||||
@@ -87,24 +67,16 @@ const WM_POSITION_LABELS: Record<WatermarkPosition, string> = {
|
|||||||
|
|
||||||
const CUSTOM_PREVIEW_FAMILY = "S2VIDCustomWm";
|
const CUSTOM_PREVIEW_FAMILY = "S2VIDCustomWm";
|
||||||
|
|
||||||
function previewFontFamily(fontKey: WatermarkFontKey | undefined): string {
|
|
||||||
return previewFontFamilyCss(fontKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
function watermarkOverlayStyle(
|
function watermarkOverlayStyle(
|
||||||
position: WatermarkPosition,
|
position: WatermarkPosition,
|
||||||
offsetX: number,
|
offsetX: number,
|
||||||
offsetY: number,
|
offsetY: number,
|
||||||
sizedBox = false,
|
|
||||||
): CSSProperties {
|
): CSSProperties {
|
||||||
const base: CSSProperties = {
|
const base: CSSProperties = {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
maxWidth: `${WATERMARK_WIDTH_FRACTION * 100}%`,
|
maxWidth: "32%",
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
zIndex: 5,
|
zIndex: 5,
|
||||||
...(sizedBox
|
|
||||||
? { width: `${WATERMARK_WIDTH_FRACTION * 100}%` }
|
|
||||||
: null),
|
|
||||||
};
|
};
|
||||||
const ox = `${offsetX}px`;
|
const ox = `${offsetX}px`;
|
||||||
const oy = `${offsetY}px`;
|
const oy = `${offsetY}px`;
|
||||||
@@ -128,15 +100,20 @@ 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({
|
function MiniThumb({
|
||||||
template,
|
template,
|
||||||
label,
|
|
||||||
active,
|
active,
|
||||||
onClick,
|
onClick,
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
template: LayoutTemplate | null;
|
template: LayoutTemplate | null;
|
||||||
label: string;
|
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
@@ -161,14 +138,20 @@ function MiniThumb({
|
|||||||
) : (
|
) : (
|
||||||
<div className="relative h-full w-full overflow-hidden bg-gray-700/50">
|
<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]" />
|
<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 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-[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" />
|
<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" && (
|
{template === "COVER_TOP_TEXT_BOTTOM" && (
|
||||||
<>
|
<>
|
||||||
<div className="absolute left-[8%] top-[6%] h-[52%] w-[84%] bg-gray-300" />
|
<div className="absolute left-[8%] top-[6%] h-[52%] w-[84%] bg-gray-300" />
|
||||||
@@ -183,18 +166,12 @@ function MiniThumb({
|
|||||||
<div className="absolute left-[34%] bottom-[12%] h-0.5 w-[32%] rounded bg-white/50" />
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="px-2 py-1.5 text-[10px] font-medium text-gray-300">{label}</p>
|
<p className="px-2 py-1.5 text-[10px] font-medium text-gray-300">
|
||||||
|
{isClassic ? "Classic letterbox" : LAYOUT_TEMPLATE_LABELS[template]}
|
||||||
|
</p>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -202,18 +179,8 @@ function MiniThumb({
|
|||||||
function previewCoverStyle(
|
function previewCoverStyle(
|
||||||
template: LayoutTemplate,
|
template: LayoutTemplate,
|
||||||
padPct: number,
|
padPct: number,
|
||||||
textPadding: number,
|
|
||||||
encodeWidth: number,
|
|
||||||
): CSSProperties {
|
): CSSProperties {
|
||||||
const p = `${padPct}%`;
|
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) {
|
switch (template) {
|
||||||
case "COVER_LEFT_TEXT_RIGHT":
|
case "COVER_LEFT_TEXT_RIGHT":
|
||||||
return {
|
return {
|
||||||
@@ -255,24 +222,6 @@ function previewCoverStyle(
|
|||||||
maxHeight: "38%",
|
maxHeight: "38%",
|
||||||
objectFit: "contain",
|
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",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,20 +231,11 @@ function previewTextStyle(
|
|||||||
textOffsetX: number,
|
textOffsetX: number,
|
||||||
textOffsetY: number,
|
textOffsetY: number,
|
||||||
titleArtistGap: number,
|
titleArtistGap: number,
|
||||||
textPadding: number,
|
|
||||||
encodeWidth: number,
|
|
||||||
): CSSProperties {
|
): CSSProperties {
|
||||||
const p = `${padPct}%`;
|
const p = `${padPct}%`;
|
||||||
const cornerEdgePx = scaleFontToPreview(textPadding, encodeWidth);
|
const shift = {
|
||||||
const coverTextGapPx = scaleFontToPreview(
|
transform: undefined as string | undefined,
|
||||||
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` };
|
const baseGap = { display: "flex", flexDirection: "column" as const, gap: `${titleArtistGap}px` };
|
||||||
|
|
||||||
@@ -342,72 +282,39 @@ function previewTextStyle(
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
alignItems: "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({
|
export function LayoutStudio({
|
||||||
locked,
|
locked = false,
|
||||||
previewImageUrl,
|
previewImageUrl,
|
||||||
previewBackgroundUrl = null,
|
title,
|
||||||
songTitle,
|
|
||||||
artist,
|
artist,
|
||||||
encodeWidth = 1280,
|
|
||||||
layout,
|
layout,
|
||||||
onLayoutChange,
|
onLayoutChange,
|
||||||
watermark,
|
watermark,
|
||||||
onWatermarkChange,
|
onWatermarkChange,
|
||||||
onUploadLogo,
|
onUploadLogo,
|
||||||
onUploadFont,
|
onUploadFont,
|
||||||
onUploadBackground,
|
|
||||||
onClearBackground,
|
|
||||||
logoPreviewUrl,
|
logoPreviewUrl,
|
||||||
hasCustomBackground = false,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [logoUploading, setLogoUploading] = useState(false);
|
const [logoUploading, setLogoUploading] = useState(false);
|
||||||
const [logoError, setLogoError] = useState<string | null>(null);
|
const [logoError, setLogoError] = useState<string | null>(null);
|
||||||
const [fontUploading, setFontUploading] = useState(false);
|
const [fontUploading, setFontUploading] = useState(false);
|
||||||
const [fontError, setFontError] = useState<string | null>(null);
|
const [fontError, setFontError] = useState<string | null>(null);
|
||||||
const [customFontObjectUrl, setCustomFontObjectUrl] = 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
|
// Always coalesce HMR / older session state may omit newly added fields
|
||||||
const L: LayoutSettings = {
|
const L: LayoutSettings = {
|
||||||
...DEFAULT_LAYOUT,
|
...DEFAULT_LAYOUT,
|
||||||
...layout,
|
...layout,
|
||||||
blurFill: layout.blurFill ?? DEFAULT_LAYOUT.blurFill,
|
|
||||||
blurAmount: layout.blurAmount ?? DEFAULT_LAYOUT.blurAmount,
|
blurAmount: layout.blurAmount ?? DEFAULT_LAYOUT.blurAmount,
|
||||||
blurOpacity: layout.blurOpacity ?? BLUR_OPACITY_DEFAULT,
|
blurOpacity: layout.blurOpacity ?? BLUR_OPACITY_DEFAULT,
|
||||||
textPadding: layout.textPadding ?? DEFAULT_LAYOUT.textPadding,
|
textPadding: layout.textPadding ?? DEFAULT_LAYOUT.textPadding,
|
||||||
titleArtistGap: layout.titleArtistGap ?? DEFAULT_LAYOUT.titleArtistGap,
|
titleArtistGap: layout.titleArtistGap ?? DEFAULT_LAYOUT.titleArtistGap,
|
||||||
textOffsetX: layout.textOffsetX ?? DEFAULT_LAYOUT.textOffsetX,
|
textOffsetX: layout.textOffsetX ?? DEFAULT_LAYOUT.textOffsetX,
|
||||||
textOffsetY: layout.textOffsetY ?? DEFAULT_LAYOUT.textOffsetY,
|
textOffsetY: layout.textOffsetY ?? DEFAULT_LAYOUT.textOffsetY,
|
||||||
titleBold: layout.titleBold ?? DEFAULT_LAYOUT.titleBold,
|
|
||||||
};
|
};
|
||||||
const W: WatermarkSettings = {
|
const W: WatermarkSettings = {
|
||||||
...DEFAULT_WATERMARK,
|
...DEFAULT_WATERMARK,
|
||||||
@@ -430,14 +337,8 @@ export function LayoutStudio({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const wmOverlay = useMemo(
|
const wmOverlay = useMemo(
|
||||||
() =>
|
() => watermarkOverlayStyle(W.position, W.offsetX, W.offsetY),
|
||||||
watermarkOverlayStyle(
|
[W.position, W.offsetX, W.offsetY],
|
||||||
W.position,
|
|
||||||
W.offsetX,
|
|
||||||
W.offsetY,
|
|
||||||
W.mode === "default" || W.mode === "logo",
|
|
||||||
),
|
|
||||||
[W.position, W.offsetX, W.offsetY, W.mode],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const textFontStyle = useMemo(
|
const textFontStyle = useMemo(
|
||||||
@@ -445,55 +346,14 @@ export function LayoutStudio({
|
|||||||
[W.fontKey],
|
[W.fontKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
const titlePreviewPx = useMemo(
|
|
||||||
() => scaleFontToPreview(titleFontSizeForWidth(encodeWidth), encodeWidth),
|
|
||||||
[encodeWidth],
|
|
||||||
);
|
|
||||||
const artistPreviewPx = useMemo(
|
|
||||||
() => scaleFontToPreview(artistFontSizeForWidth(encodeWidth), encodeWidth),
|
|
||||||
[encodeWidth],
|
|
||||||
);
|
|
||||||
const wmTextPreviewPx = useMemo(
|
|
||||||
() => scaleFontToPreview(watermarkFontSizeForWidth(encodeWidth), encodeWidth),
|
|
||||||
[encodeWidth],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const styleId = "s2vid-preview-curated-fonts";
|
const id = "s2vid-watermark-google-fonts";
|
||||||
let el = document.getElementById(styleId) as HTMLStyleElement | null;
|
if (document.getElementById(id)) return;
|
||||||
if (!el) {
|
const link = document.createElement("link");
|
||||||
el = document.createElement("style");
|
link.id = id;
|
||||||
el.id = styleId;
|
link.rel = "stylesheet";
|
||||||
document.head.appendChild(el);
|
link.href = googleFontsStylesheetUrl(CURATED_FONTS.map((f) => f.key));
|
||||||
}
|
document.head.appendChild(link);
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
@@ -556,7 +416,7 @@ export function LayoutStudio({
|
|||||||
if (customFontObjectUrl) URL.revokeObjectURL(customFontObjectUrl);
|
if (customFontObjectUrl) URL.revokeObjectURL(customFontObjectUrl);
|
||||||
setCustomFontObjectUrl(objectUrl);
|
setCustomFontObjectUrl(objectUrl);
|
||||||
const path = await onUploadFont(file);
|
const path = await onUploadFont(file);
|
||||||
patchWm({ fontKey: "custom", fontPath: path });
|
patchWm({ mode: "text", fontKey: "custom", fontPath: path });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setFontError(err instanceof Error ? err.message : "Font upload failed");
|
setFontError(err instanceof Error ? err.message : "Font upload failed");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -564,21 +424,6 @@ 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) {
|
function setFontKey(key: WatermarkFontKey) {
|
||||||
if (key === "custom") {
|
if (key === "custom") {
|
||||||
patchWm({ fontKey: "custom", fontPath: watermark.fontPath ?? null });
|
patchWm({ fontKey: "custom", fontPath: watermark.fontPath ?? null });
|
||||||
@@ -588,15 +433,9 @@ export function LayoutStudio({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const artTrack = Boolean(L.template);
|
const artTrack = Boolean(L.template);
|
||||||
const lowerCorner = isLowerCornerTemplate(L.template);
|
|
||||||
const blurPreviewUrl = previewBackgroundUrl || previewImageUrl;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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">
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
<h3 className="text-lg font-medium text-white">Video layout</h3>
|
<h3 className="text-lg font-medium text-white">Video layout</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -613,7 +452,7 @@ export function LayoutStudio({
|
|||||||
<div className="absolute inset-0 bg-black" />
|
<div className="absolute inset-0 bg-black" />
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={blurPreviewUrl!}
|
src={previewImageUrl}
|
||||||
alt=""
|
alt=""
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
style={{
|
style={{
|
||||||
@@ -626,12 +465,7 @@ export function LayoutStudio({
|
|||||||
<img
|
<img
|
||||||
src={previewImageUrl}
|
src={previewImageUrl}
|
||||||
alt=""
|
alt=""
|
||||||
style={previewCoverStyle(
|
style={previewCoverStyle(L.template!, padPct)}
|
||||||
L.template!,
|
|
||||||
padPct,
|
|
||||||
L.textPadding,
|
|
||||||
encodeWidth,
|
|
||||||
)}
|
|
||||||
className="pointer-events-none"
|
className="pointer-events-none"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@@ -641,90 +475,56 @@ export function LayoutStudio({
|
|||||||
L.textOffsetX,
|
L.textOffsetX,
|
||||||
L.textOffsetY,
|
L.textOffsetY,
|
||||||
L.titleArtistGap,
|
L.titleArtistGap,
|
||||||
L.textPadding,
|
|
||||||
encodeWidth,
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
className={`max-w-full truncate text-white drop-shadow ${
|
className="max-w-full truncate text-sm font-semibold text-white drop-shadow"
|
||||||
L.titleBold ? "font-semibold" : "font-normal"
|
style={
|
||||||
}`}
|
W.mode === "text" ? textFontStyle : undefined
|
||||||
style={{
|
}
|
||||||
...textFontStyle,
|
|
||||||
fontWeight: L.titleBold ? 600 : 400,
|
|
||||||
fontSize: `${titlePreviewPx}px`,
|
|
||||||
lineHeight: 1.15,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{songTitle.trim() || "Track title"}
|
{title.trim() || "Track title"}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
className="max-w-full truncate text-white/75 drop-shadow"
|
className="max-w-full truncate text-xs text-white/75 drop-shadow"
|
||||||
style={{
|
style={
|
||||||
...textFontStyle,
|
W.mode === "text" ? textFontStyle : undefined
|
||||||
fontSize: `${artistPreviewPx}px`,
|
}
|
||||||
lineHeight: 1.15,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{artist.trim() || "Artist"}
|
{artist.trim() || "Artist"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 bg-black">
|
<div className="absolute inset-0 flex items-center justify-center bg-black">
|
||||||
{L.blurFill && (
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
<img
|
||||||
<img
|
src={previewImageUrl}
|
||||||
src={previewImageUrl}
|
alt=""
|
||||||
alt=""
|
className="max-h-full max-w-full object-contain"
|
||||||
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{W.mode !== "none" && (
|
{W.mode !== "none" && (
|
||||||
<div style={wmOverlay}>
|
<div style={wmOverlay} className="rounded bg-black/40 px-2 py-1">
|
||||||
{W.mode === "default" ? (
|
{W.mode === "logo" && logoPreviewUrl ? (
|
||||||
// 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
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={logoPreviewUrl}
|
src={logoPreviewUrl}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-auto w-full object-contain"
|
className="max-h-14 w-auto object-contain"
|
||||||
/>
|
/>
|
||||||
) : W.mode === "text" ? (
|
) : (
|
||||||
<span
|
<span
|
||||||
className="font-medium text-white/90 drop-shadow"
|
className="text-[11px] font-medium text-white/90 drop-shadow"
|
||||||
style={{
|
style={W.mode === "text" ? textFontStyle : undefined}
|
||||||
...textFontStyle,
|
|
||||||
fontSize: `${wmTextPreviewPx}px`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{W.text?.trim()
|
{W.mode === "text" && W.text?.trim()
|
||||||
? W.text.trim().slice(0, WATERMARK_TEXT_MAX)
|
? W.text.trim().slice(0, WATERMARK_TEXT_MAX)
|
||||||
: getVideoAttributionText()}
|
: getVideoAttributionText()}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -738,114 +538,22 @@ export function LayoutStudio({
|
|||||||
{/* Art-track templates */}
|
{/* Art-track templates */}
|
||||||
<p className="mb-2 mt-5 text-sm font-medium text-gray-300">Composition</p>
|
<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">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
{COMPOSITION_FAMILIES.map((family) => {
|
<MiniThumb
|
||||||
const active =
|
template={null}
|
||||||
family.id === "classic"
|
active={L.template === null}
|
||||||
? L.template === null
|
disabled={locked}
|
||||||
: family.variants.includes(L.template as LayoutTemplate);
|
onClick={() => patchLayout({ template: null })}
|
||||||
return (
|
/>
|
||||||
<MiniThumb
|
{LAYOUT_TEMPLATES.map((t) => (
|
||||||
key={family.id}
|
<MiniThumb
|
||||||
template={family.thumb}
|
key={t}
|
||||||
label={family.label}
|
template={t}
|
||||||
active={active}
|
active={L.template === t}
|
||||||
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}
|
disabled={locked}
|
||||||
checked={L.blurFill}
|
onClick={() => patchLayout({ template: t })}
|
||||||
onChange={(e) => patchLayout({ blurFill: e.target.checked })}
|
|
||||||
/>
|
/>
|
||||||
<span>
|
))}
|
||||||
<span className="font-medium text-gray-200">Blurred cover background</span>
|
</div>
|
||||||
<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">
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
<label className="text-sm text-gray-400">
|
<label className="text-sm text-gray-400">
|
||||||
@@ -854,7 +562,7 @@ export function LayoutStudio({
|
|||||||
type="range"
|
type="range"
|
||||||
min={BLUR_AMOUNT_MIN}
|
min={BLUR_AMOUNT_MIN}
|
||||||
max={BLUR_AMOUNT_MAX}
|
max={BLUR_AMOUNT_MAX}
|
||||||
disabled={locked || !(artTrack || L.blurFill)}
|
disabled={locked || !artTrack}
|
||||||
value={L.blurAmount}
|
value={L.blurAmount}
|
||||||
onChange={(e) => patchLayout({ blurAmount: Number(e.target.value) })}
|
onChange={(e) => patchLayout({ blurAmount: Number(e.target.value) })}
|
||||||
className="mt-1 w-full disabled:opacity-40"
|
className="mt-1 w-full disabled:opacity-40"
|
||||||
@@ -866,7 +574,7 @@ export function LayoutStudio({
|
|||||||
type="range"
|
type="range"
|
||||||
min={BLUR_OPACITY_MIN}
|
min={BLUR_OPACITY_MIN}
|
||||||
max={BLUR_OPACITY_MAX}
|
max={BLUR_OPACITY_MAX}
|
||||||
disabled={locked || !(artTrack || L.blurFill)}
|
disabled={locked || !artTrack}
|
||||||
value={L.blurOpacity}
|
value={L.blurOpacity}
|
||||||
onChange={(e) => patchLayout({ blurOpacity: Number(e.target.value) })}
|
onChange={(e) => patchLayout({ blurOpacity: Number(e.target.value) })}
|
||||||
className="mt-1 w-full disabled:opacity-40"
|
className="mt-1 w-full disabled:opacity-40"
|
||||||
@@ -922,71 +630,6 @@ export function LayoutStudio({
|
|||||||
className="mt-1 w-full disabled:opacity-40"
|
className="mt-1 w-full disabled:opacity-40"
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Watermark section */}
|
{/* Watermark section */}
|
||||||
@@ -1031,6 +674,51 @@ export function LayoutStudio({
|
|||||||
placeholder="Your brand name"
|
placeholder="Your brand name"
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { DOCS_URL, GITEA_URL } from "@/lib/plans";
|
|
||||||
|
|
||||||
function LegalLink({
|
function LegalLink({
|
||||||
href,
|
href,
|
||||||
@@ -34,16 +33,7 @@ export function LegalFooter() {
|
|||||||
return (
|
return (
|
||||||
<footer className="mt-auto w-full border-t border-gray-800 bg-black/40 py-8 text-xs text-gray-500">
|
<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">
|
<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} Songs2VID. MIT licensed.</span>
|
<span>© {year} Songs2YT. All rights reserved.</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 aria-hidden="true" className="hidden sm:inline">
|
||||||
|
|
|
|
||||||
</span>
|
</span>
|
||||||
@@ -51,18 +41,16 @@ export function LegalFooter() {
|
|||||||
<span aria-hidden="true" className="hidden sm:inline">
|
<span aria-hidden="true" className="hidden sm:inline">
|
||||||
|
|
|
|
||||||
</span>
|
</span>
|
||||||
<LegalLink href="/terms">Terms of Use</LegalLink>
|
<LegalLink href="/terms">Terms of Service</LegalLink>
|
||||||
<span aria-hidden="true" className="hidden sm:inline">
|
<span aria-hidden="true" className="hidden sm:inline">
|
||||||
|
|
|
|
||||||
</span>
|
</span>
|
||||||
<LegalLink href={DOCS_URL} external>
|
<LegalLink href="/refund">Refund Policy</LegalLink>
|
||||||
Documentation
|
|
||||||
</LegalLink>
|
|
||||||
<span aria-hidden="true" className="hidden sm:inline">
|
<span aria-hidden="true" className="hidden sm:inline">
|
||||||
|
|
|
|
||||||
</span>
|
</span>
|
||||||
<LegalLink href={GITEA_URL} external>
|
<LegalLink href="https://status.atakanozban.com/status/2" external>
|
||||||
Source
|
Service Status
|
||||||
</LegalLink>
|
</LegalLink>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import Link from "next/link";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
import { LEGAL_LAST_UPDATED } from "@/lib/legal/constants";
|
import { LEGAL_LAST_UPDATED } from "@/lib/legal/constants";
|
||||||
import { DOCS_URL } from "@/lib/plans";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -45,13 +44,16 @@ export function LegalPageLayout({ title, description, children }: Props) {
|
|||||||
<Link href="/terms" className="hover:text-gray-300">
|
<Link href="/terms" className="hover:text-gray-300">
|
||||||
Terms
|
Terms
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/refund" className="hover:text-gray-300">
|
||||||
|
Refund
|
||||||
|
</Link>
|
||||||
<a
|
<a
|
||||||
href={DOCS_URL}
|
href="https://status.atakanozban.com/status/2"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="hover:text-gray-300"
|
className="hover:text-gray-300"
|
||||||
>
|
>
|
||||||
Documentation
|
Service Status
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,33 +1,30 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { BRAND_NAME } from "@/lib/branding";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
href?: string;
|
href?: string;
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md";
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Logo({ href = "/", size = "md" }: Props) {
|
export function Logo({ href = "/", size = "md" }: Props) {
|
||||||
const textSize = size === "sm" ? "text-xl" : size === "lg" ? "text-3xl" : "text-2xl";
|
const textSize = size === "sm" ? "text-xl" : "text-2xl";
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<span className={`relative inline-flex items-baseline font-bold tracking-tight ${textSize}`}>
|
<span className={`inline-flex items-baseline font-bold tracking-tight ${textSize}`}>
|
||||||
<span className="text-white">Songs</span>
|
<span className="text-white">Songs</span>
|
||||||
<span className="text-red-400">2VID</span>
|
<span className="text-red-400">2YT</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
const label = BRAND_NAME;
|
|
||||||
|
|
||||||
if (href) {
|
if (href) {
|
||||||
return (
|
return (
|
||||||
<Link href={href} className="inline-flex items-center" aria-label={label}>
|
<Link href={href} className="inline-flex items-center" aria-label="Songs2YT">
|
||||||
{content}
|
{content}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center" aria-label={label}>
|
<span className="inline-flex items-center" aria-label="Songs2YT">
|
||||||
{content}
|
{content}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ export function PlaylistSelect({ value, onChange, enabled }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!enabled) { return null; }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -131,7 +133,7 @@ export function PlaylistSelect({ value, onChange, enabled }: Props) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="input-field w-full"
|
className="input-field w-full"
|
||||||
placeholder="My Songs2VID uploads"
|
placeholder="My Songs2YT uploads"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Plan } from "@prisma/client";
|
||||||
import { getResolutionsForPlan } from "@/lib/plans";
|
import { getResolutionsForPlan } from "@/lib/plans";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
plan?: Plan;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ResolutionSelect({ value, onChange, disabled }: Props) {
|
export function ResolutionSelect({ value, onChange, disabled, plan = "FREE" }: Props) {
|
||||||
const resolutions = getResolutionsForPlan();
|
const resolutions = getResolutionsForPlan(plan);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,20 +2,18 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Privacy } from "@prisma/client";
|
import { Plan, Privacy } from "@prisma/client";
|
||||||
import { filenameWithoutExtension } from "@/lib/constants";
|
import { filenameWithoutExtension } from "@/lib/constants";
|
||||||
|
import { getVideoAttributionText } from "@/lib/branding";
|
||||||
import { audioTagsToMetadata } from "@/lib/audio-tags";
|
import { audioTagsToMetadata } from "@/lib/audio-tags";
|
||||||
import { resolveYouTubeTitle } from "@/lib/titles";
|
|
||||||
import { SONG_TITLE_MAX } from "@/lib/layout";
|
|
||||||
import type { ItemMetadata } from "@/lib/types";
|
import type { ItemMetadata } from "@/lib/types";
|
||||||
import { DEFAULT_LAYOUT, isLowerCornerTemplate, type LayoutSettings } from "@/lib/layout";
|
import { DEFAULT_LAYOUT, type LayoutSettings } from "@/lib/layout";
|
||||||
import { DEFAULT_WATERMARK, type WatermarkSettings } from "@/lib/watermark";
|
import { DEFAULT_WATERMARK, type WatermarkSettings } from "@/lib/watermark";
|
||||||
import { CategorySelect } from "./CategorySelect";
|
import { CategorySelect } from "./CategorySelect";
|
||||||
import { LayoutStudio } from "./LayoutStudio";
|
import { LayoutStudio } from "./LayoutStudio";
|
||||||
import { PlaylistSelect } from "./PlaylistSelect";
|
import { PlaylistSelect } from "./PlaylistSelect";
|
||||||
import { PrivacyToggle } from "./PrivacyToggle";
|
import { PrivacyToggle } from "./PrivacyToggle";
|
||||||
import { ResolutionSelect } from "./ResolutionSelect";
|
import { ResolutionSelect } from "./ResolutionSelect";
|
||||||
|
|
||||||
const UPLOAD_CONCURRENCY = 4;
|
const UPLOAD_CONCURRENCY = 4;
|
||||||
|
|
||||||
type AudioItem = {
|
type AudioItem = {
|
||||||
@@ -45,7 +43,6 @@ function defaultMetadata(title = ""): ItemMetadata {
|
|||||||
playlistId: null,
|
playlistId: null,
|
||||||
imagePath: null,
|
imagePath: null,
|
||||||
artist: null,
|
artist: null,
|
||||||
songTitle: null,
|
|
||||||
watermark: { ...DEFAULT_WATERMARK },
|
watermark: { ...DEFAULT_WATERMARK },
|
||||||
layout: { ...DEFAULT_LAYOUT },
|
layout: { ...DEFAULT_LAYOUT },
|
||||||
};
|
};
|
||||||
@@ -65,9 +62,8 @@ export function UploadForm() {
|
|||||||
const [jobWatermark, setJobWatermark] = useState<WatermarkSettings>({ ...DEFAULT_WATERMARK });
|
const [jobWatermark, setJobWatermark] = useState<WatermarkSettings>({ ...DEFAULT_WATERMARK });
|
||||||
const [jobLayout, setJobLayout] = useState<LayoutSettings>({ ...DEFAULT_LAYOUT });
|
const [jobLayout, setJobLayout] = useState<LayoutSettings>({ ...DEFAULT_LAYOUT });
|
||||||
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
|
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<{
|
const [quota, setQuota] = useState<{
|
||||||
|
plan: Plan;
|
||||||
maxBatchSize: number;
|
maxBatchSize: number;
|
||||||
used: number;
|
used: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
@@ -77,6 +73,7 @@ export function UploadForm() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setQuota({
|
setQuota({
|
||||||
|
plan: data.plan,
|
||||||
maxBatchSize: data.maxBatchSize,
|
maxBatchSize: data.maxBatchSize,
|
||||||
used: data.used ?? 0,
|
used: data.used ?? 0,
|
||||||
});
|
});
|
||||||
@@ -91,7 +88,6 @@ export function UploadForm() {
|
|||||||
return () => {
|
return () => {
|
||||||
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
|
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
|
||||||
if (logoPreviewUrl) URL.revokeObjectURL(logoPreviewUrl);
|
if (logoPreviewUrl) URL.revokeObjectURL(logoPreviewUrl);
|
||||||
if (backgroundPreviewUrl) URL.revokeObjectURL(backgroundPreviewUrl);
|
|
||||||
audioItems.forEach((a) => {
|
audioItems.forEach((a) => {
|
||||||
if (a.itemImagePreview) URL.revokeObjectURL(a.itemImagePreview);
|
if (a.itemImagePreview) URL.revokeObjectURL(a.itemImagePreview);
|
||||||
});
|
});
|
||||||
@@ -202,6 +198,10 @@ export function UploadForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleItemImageChange(itemId: string, file: File | null) {
|
async function handleItemImageChange(itemId: string, file: File | null) {
|
||||||
|
if (!true) {
|
||||||
|
setError("Matching a unique image per audio requires Pro.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
const preview = URL.createObjectURL(file);
|
const preview = URL.createObjectURL(file);
|
||||||
@@ -261,21 +261,6 @@ export function UploadForm() {
|
|||||||
return uploaded.path;
|
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) {
|
function applyWatermarkToAll(next: WatermarkSettings) {
|
||||||
const normalized = { ...DEFAULT_WATERMARK, ...next };
|
const normalized = { ...DEFAULT_WATERMARK, ...next };
|
||||||
setJobWatermark(normalized);
|
setJobWatermark(normalized);
|
||||||
@@ -294,9 +279,6 @@ export function UploadForm() {
|
|||||||
function applyLayoutToAll(next: LayoutSettings) {
|
function applyLayoutToAll(next: LayoutSettings) {
|
||||||
const normalized = { ...DEFAULT_LAYOUT, ...next };
|
const normalized = { ...DEFAULT_LAYOUT, ...next };
|
||||||
setJobLayout(normalized);
|
setJobLayout(normalized);
|
||||||
if (!isLowerCornerTemplate(normalized.template)) {
|
|
||||||
clearBackgroundImage();
|
|
||||||
}
|
|
||||||
setAudioItems((prev) =>
|
setAudioItems((prev) =>
|
||||||
prev.map((item) => ({
|
prev.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
@@ -313,9 +295,9 @@ export function UploadForm() {
|
|||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const maxBatch = quota?.maxBatchSize ?? 100;
|
const maxBatch = quota?.maxBatchSize ?? 3;
|
||||||
if (audioItems.length + files.length > maxBatch) {
|
if (audioItems.length + files.length > maxBatch) {
|
||||||
setError(`You can upload up to ${maxBatch} audio files per batch.`);
|
setError(`Your plan allows up to ${maxBatch} audio files per batch.`);
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -356,28 +338,17 @@ export function UploadForm() {
|
|||||||
imagePath &&
|
imagePath &&
|
||||||
!imageUploading &&
|
!imageUploading &&
|
||||||
readyAudios.length > 0 &&
|
readyAudios.length > 0 &&
|
||||||
readyAudios.every((a) =>
|
readyAudios.every((a) => a.metadata.title.trim()) &&
|
||||||
Boolean(
|
|
||||||
resolveYouTubeTitle(
|
|
||||||
{
|
|
||||||
title: a.metadata.title,
|
|
||||||
songTitle: a.metadata.songTitle,
|
|
||||||
artist: a.metadata.artist,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
) &&
|
|
||||||
!submitting;
|
!submitting;
|
||||||
|
|
||||||
async function submitJob() {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
if (!canSubmit || !imagePath) return;
|
if (!canSubmit || !imagePath) return;
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const effectiveWatermark: WatermarkSettings = jobWatermark;
|
|
||||||
|
|
||||||
const res = await fetch("/api/jobs", {
|
const res = await fetch("/api/jobs", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -388,14 +359,17 @@ export function UploadForm() {
|
|||||||
audioFilename: item.file.name,
|
audioFilename: item.file.name,
|
||||||
metadata: {
|
metadata: {
|
||||||
...item.metadata,
|
...item.metadata,
|
||||||
imagePath: item.itemImagePath || item.metadata.imagePath || null,
|
imagePath: true ? item.itemImagePath || item.metadata.imagePath || null : null,
|
||||||
includeWatermark: effectiveWatermark.mode !== "none",
|
includeWatermark: jobWatermark.mode !== "none",
|
||||||
watermark: effectiveWatermark,
|
watermark: true
|
||||||
|
? jobWatermark
|
||||||
|
: {
|
||||||
|
mode: jobWatermark.mode === "none" ? "none" : "default",
|
||||||
|
position: "bottom-right",
|
||||||
|
offsetX: 20,
|
||||||
|
offsetY: 20,
|
||||||
|
},
|
||||||
layout: jobLayout,
|
layout: jobLayout,
|
||||||
backgroundImagePath:
|
|
||||||
isLowerCornerTemplate(jobLayout.template) && backgroundImagePath
|
|
||||||
? backgroundImagePath
|
|
||||||
: null,
|
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
@@ -403,6 +377,9 @@ export function UploadForm() {
|
|||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
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");
|
throw new Error(data.error || "Failed to create job");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,18 +390,11 @@ export function UploadForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!canSubmit || !imagePath) return;
|
|
||||||
|
|
||||||
await submitJob();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{quota && (
|
{quota && (
|
||||||
<div className="rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm text-gray-300">
|
<div className="rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm text-gray-300">
|
||||||
Self-hosted · {quota.used} videos created · up to {quota.maxBatchSize} files per batch
|
Self-hosted · {quota.used} videos processed · up to {quota.maxBatchSize} files per batch
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -453,7 +423,7 @@ export function UploadForm() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-gray-500">
|
<p className="mt-2 text-xs text-gray-500">
|
||||||
Shared cover for all tracks. You can override it per audio below.
|
Shared cover for all tracks. Optionally override per audio below.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -482,15 +452,14 @@ export function UploadForm() {
|
|||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-lg font-medium text-white">Video details (per audio)</h2>
|
<h2 className="text-lg font-medium text-white">Video details (per audio)</h2>
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
Each audio file gets its own metadata. Video title is used for YouTube; song title and
|
Each audio file gets its own metadata. Title is auto-filled from the filename.
|
||||||
artist appear in art-track layouts.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="rounded-lg border border-gray-700 bg-surface p-6">
|
<div className="rounded-lg border border-gray-700 bg-surface p-6">
|
||||||
<PlaylistSelect
|
<PlaylistSelect
|
||||||
value={playlistId}
|
value={playlistId}
|
||||||
onChange={handlePlaylistChange}
|
onChange={handlePlaylistChange}
|
||||||
enabled
|
enabled={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -518,40 +487,29 @@ export function UploadForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<Field label="Video title (YouTube)">
|
<Field label="Title">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={item.metadata.title}
|
value={item.metadata.title}
|
||||||
onChange={(e) => updateItemMetadata(item.id, { title: e.target.value })}
|
onChange={(e) => updateItemMetadata(item.id, { title: e.target.value })}
|
||||||
className="input-field"
|
className="input-field"
|
||||||
required
|
required
|
||||||
placeholder="Title shown on YouTube"
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Song title (on-video)">
|
<Field label="Artist (art-track layouts)">
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={item.metadata.songTitle ?? ""}
|
|
||||||
onChange={(e) => updateItemMetadata(item.id, { songTitle: e.target.value })}
|
|
||||||
className="input-field"
|
|
||||||
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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={item.metadata.artist ?? ""}
|
value={item.metadata.artist ?? ""}
|
||||||
onChange={(e) => updateItemMetadata(item.id, { artist: e.target.value })}
|
onChange={(e) => updateItemMetadata(item.id, { artist: e.target.value })}
|
||||||
className="input-field"
|
className="input-field"
|
||||||
placeholder="Shown in art-track layout"
|
placeholder="Optional shown in layout templates"
|
||||||
maxLength={80}
|
maxLength={80}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<Field label="Category">
|
<Field label="Category">
|
||||||
<CategorySelect
|
<CategorySelect
|
||||||
value={item.metadata.categoryId}
|
value={item.metadata.categoryId}
|
||||||
@@ -590,14 +548,19 @@ export function UploadForm() {
|
|||||||
<ResolutionSelect
|
<ResolutionSelect
|
||||||
value={item.metadata.resolution}
|
value={item.metadata.resolution}
|
||||||
onChange={(v) => updateItemMetadata(item.id, { resolution: v })}
|
onChange={(v) => updateItemMetadata(item.id, { resolution: v })}
|
||||||
|
plan={quota?.plan}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div className="relative rounded border border-gray-800 bg-surface-light/40 p-3">
|
<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
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
|
disabled={false}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const f = e.target.files?.[0] ?? null;
|
const f = e.target.files?.[0] ?? null;
|
||||||
void handleItemImageChange(item.id, f);
|
void handleItemImageChange(item.id, f);
|
||||||
@@ -611,6 +574,7 @@ export function UploadForm() {
|
|||||||
{item.itemImagePath ? `Using ${item.itemImageName}` : `Uploading ${item.itemImageName}…`}
|
{item.itemImagePath ? `Using ${item.itemImageName}` : `Uploading ${item.itemImageName}…`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
@@ -634,6 +598,7 @@ export function UploadForm() {
|
|||||||
checked={item.metadata.creativeCommons}
|
checked={item.metadata.creativeCommons}
|
||||||
onChange={(v) => updateItemMetadata(item.id, { creativeCommons: v })}
|
onChange={(v) => updateItemMetadata(item.id, { creativeCommons: v })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -645,26 +610,19 @@ export function UploadForm() {
|
|||||||
previewImageUrl={
|
previewImageUrl={
|
||||||
audioItems.find((a) => a.itemImagePreview)?.itemImagePreview || imagePreviewUrl
|
audioItems.find((a) => a.itemImagePreview)?.itemImagePreview || imagePreviewUrl
|
||||||
}
|
}
|
||||||
previewBackgroundUrl={backgroundPreviewUrl}
|
title={audioItems[0]?.metadata.title || "Track title"}
|
||||||
songTitle={
|
|
||||||
audioItems[0]?.metadata.songTitle ||
|
|
||||||
audioItems[0]?.metadata.title ||
|
|
||||||
"Track title"
|
|
||||||
}
|
|
||||||
artist={audioItems[0]?.metadata.artist || ""}
|
artist={audioItems[0]?.metadata.artist || ""}
|
||||||
encodeWidth={parseInt(readyAudios[0]?.metadata.resolution?.split("x")[0] || "1280", 10)}
|
|
||||||
layout={jobLayout}
|
layout={jobLayout}
|
||||||
onLayoutChange={applyLayoutToAll}
|
onLayoutChange={applyLayoutToAll}
|
||||||
watermark={jobWatermark}
|
watermark={jobWatermark}
|
||||||
onWatermarkChange={applyWatermarkToAll}
|
onWatermarkChange={applyWatermarkToAll}
|
||||||
onUploadLogo={handleLogoUpload}
|
onUploadLogo={handleLogoUpload}
|
||||||
onUploadFont={handleFontUpload}
|
onUploadFont={handleFontUpload}
|
||||||
onUploadBackground={handleBackgroundUpload}
|
|
||||||
onClearBackground={clearBackgroundImage}
|
|
||||||
hasCustomBackground={Boolean(backgroundImagePath)}
|
|
||||||
logoPreviewUrl={logoPreviewUrl}
|
logoPreviewUrl={logoPreviewUrl}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
|
|||||||
@@ -11,14 +11,9 @@ import { getVideoAttributionText } from "@/lib/branding";
|
|||||||
import {
|
import {
|
||||||
CURATED_FONTS,
|
CURATED_FONTS,
|
||||||
googleFontsStylesheetUrl,
|
googleFontsStylesheetUrl,
|
||||||
SYSTEM_FONT,
|
|
||||||
type CuratedFontKey,
|
type CuratedFontKey,
|
||||||
type WatermarkFontKey,
|
type WatermarkFontKey,
|
||||||
} from "@/lib/fonts";
|
} from "@/lib/fonts";
|
||||||
import {
|
|
||||||
curatedFontApiUrl,
|
|
||||||
previewFontFamilyCss,
|
|
||||||
} from "@/lib/preview-typography";
|
|
||||||
import {
|
import {
|
||||||
WATERMARK_OFFSET_MAX,
|
WATERMARK_OFFSET_MAX,
|
||||||
WATERMARK_OFFSET_MIN,
|
WATERMARK_OFFSET_MIN,
|
||||||
@@ -31,7 +26,7 @@ import {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
locked: boolean;
|
locked?: boolean;
|
||||||
previewImageUrl: string | null;
|
previewImageUrl: string | null;
|
||||||
value: WatermarkSettings;
|
value: WatermarkSettings;
|
||||||
onChange: (next: WatermarkSettings) => void;
|
onChange: (next: WatermarkSettings) => void;
|
||||||
@@ -83,12 +78,15 @@ function previewStyle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function previewFontFamily(fontKey: WatermarkFontKey | undefined): string {
|
function previewFontFamily(fontKey: WatermarkFontKey | undefined): string {
|
||||||
return previewFontFamilyCss(fontKey);
|
if (!fontKey || fontKey === "system") return "ui-sans-serif, system-ui, sans-serif";
|
||||||
|
if (fontKey === "custom") return `'${CUSTOM_PREVIEW_FAMILY}', sans-serif`;
|
||||||
|
const meta = CURATED_FONTS.find((f) => f.key === fontKey);
|
||||||
|
return meta ? `'${meta.cssFamily}', sans-serif` : "sans-serif";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WatermarkPreview({
|
export function WatermarkPreview({
|
||||||
enabled,
|
enabled,
|
||||||
locked,
|
locked = false,
|
||||||
previewImageUrl,
|
previewImageUrl,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -112,32 +110,7 @@ export function WatermarkPreview({
|
|||||||
[value.fontKey],
|
[value.fontKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load bundled + curated fonts for live canvas preview (same files as FFmpeg)
|
// Load curated Google Fonts for live canvas preview
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
const id = "s2vid-watermark-google-fonts";
|
const id = "s2vid-watermark-google-fonts";
|
||||||
if (document.getElementById(id)) return;
|
if (document.getElementById(id)) return;
|
||||||
@@ -225,10 +198,9 @@ export function WatermarkPreview({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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">
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
<h3 className="text-lg font-medium text-white">Watermark layout</h3>
|
<h3 className="text-lg font-medium text-white">Watermark layout</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -314,7 +286,7 @@ export function WatermarkPreview({
|
|||||||
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
|
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
|
||||||
className="input-field mt-1"
|
className="input-field mt-1"
|
||||||
>
|
>
|
||||||
<option value="system">{SYSTEM_FONT.label}</option>
|
<option value="system">System default</option>
|
||||||
{CURATED_FONTS.map((f) => (
|
{CURATED_FONTS.map((f) => (
|
||||||
<option key={f.key} value={f.key} style={{ fontFamily: f.cssFamily }}>
|
<option key={f.key} value={f.key} style={{ fontFamily: f.cssFamily }}>
|
||||||
{f.label}
|
{f.label}
|
||||||
|
|||||||
@@ -1,36 +1,25 @@
|
|||||||
# Local infra only (Postgres + Redis). Use root docker-compose.yml for full stack.
|
# 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:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: songs2vid
|
POSTGRES_USER: s2yt
|
||||||
POSTGRES_PASSWORD: songs2vid
|
POSTGRES_PASSWORD: s2yt
|
||||||
POSTGRES_DB: songs2vid
|
POSTGRES_DB: s2yt
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:5433:5432"
|
- "127.0.0.1:5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U songs2vid -d songs2vid"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:6380:6379"
|
- "127.0.0.1:6379:6379"
|
||||||
command: ["redis-server", "--appendonly", "yes"]
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ services:
|
|||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: songs2vid
|
POSTGRES_USER: s2yt
|
||||||
POSTGRES_PASSWORD: songs2vid
|
POSTGRES_PASSWORD: s2yt
|
||||||
POSTGRES_DB: songs2vid
|
POSTGRES_DB: s2yt
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U songs2vid -d songs2vid"]
|
test: ["CMD-SHELL", "pg_isready -U s2yt -d s2yt"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
@@ -27,15 +27,15 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: atakanozban/songs2vid:${S2VID_IMAGE_TAG:-latest}
|
|
||||||
build: .
|
build: .
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${S2VID_PORT:-3000}:3000"
|
- "${S2YT_PORT:-3000}:3000"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://songs2vid:songs2vid@postgres:5432/songs2vid
|
S2VID_EDITION: selfhosted
|
||||||
|
DATABASE_URL: postgresql://s2yt:s2yt@postgres:5432/s2yt
|
||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://redis:6379
|
||||||
UPLOAD_DIR: /app/uploads
|
UPLOAD_DIR: /app/uploads
|
||||||
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
|
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
|
||||||
@@ -50,13 +50,13 @@ services:
|
|||||||
sh -c "npx prisma db push && node server.js"
|
sh -c "npx prisma db push && node server.js"
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
image: atakanozban/songs2vid:${S2VID_IMAGE_TAG:-latest}
|
|
||||||
build: .
|
build: .
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://songs2vid:songs2vid@postgres:5432/songs2vid
|
S2VID_EDITION: selfhosted
|
||||||
|
DATABASE_URL: postgresql://s2yt:s2yt@postgres:5432/s2yt
|
||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://redis:6379
|
||||||
UPLOAD_DIR: /app/uploads
|
UPLOAD_DIR: /app/uploads
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -1,380 +0,0 @@
|
|||||||
# 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` | 0–100 | 55 | Background `boxblur` intensity |
|
|
||||||
| `blurOpacity` / `blur_opacity` | 0–100 | 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` | 16–120 | 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` | 0–64 | 10 | Space between title and artist |
|
|
||||||
| `titleBold` / `title_bold` | boolean | `true` | Bold song title (preview + FFmpeg) |
|
|
||||||
| `textOffsetX` / `text_offset_x` | −120–120 | 0 | Shift text block horizontally |
|
|
||||||
| `textOffsetY` / `text_offset_y` | −120–120 | 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.
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# 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 Google’s [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).
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# 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/).
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
*.tsbuildinfo
|
|
||||||
.DS_Store
|
|
||||||
*.tgz
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
"use strict";
|
|
||||||
module.exports = {};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 355 B |
@@ -1,56 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { findUserByApiKey } from "./api-keys";
|
import { findUserByApiKey } from "./api-keys";
|
||||||
import { checkApiRateLimit } from "./api-rate-limit";
|
import { checkApiRateLimit } from "./api-rate-limit";
|
||||||
|
import { hasProFeatures } from "./edition";
|
||||||
import { getSessionUser } from "./session";
|
import { getSessionUser } from "./session";
|
||||||
|
|
||||||
function extractBearerToken(req: NextRequest) {
|
function extractBearerToken(req: NextRequest) {
|
||||||
@@ -9,7 +10,7 @@ function extractBearerToken(req: NextRequest) {
|
|||||||
return auth.slice(7).trim();
|
return auth.slice(7).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireApiUser(req: NextRequest) {
|
export async function requirePaidApiUser(req: NextRequest) {
|
||||||
const token = extractBearerToken(req);
|
const token = extractBearerToken(req);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return {
|
return {
|
||||||
@@ -29,6 +30,16 @@ export async function requireApiUser(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) {
|
if (!user.youtubeConnection) {
|
||||||
return {
|
return {
|
||||||
error: NextResponse.json(
|
error: NextResponse.json(
|
||||||
@@ -59,8 +70,8 @@ export async function requireApiUser(req: NextRequest) {
|
|||||||
return { error: null, user };
|
return { error: null, user };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireUserFromSessionOrApi(req: NextRequest) {
|
export async function requirePaidUserFromSessionOrApi(req: NextRequest) {
|
||||||
const apiResult = await requireApiUser(req);
|
const apiResult = await requirePaidApiUser(req);
|
||||||
if (apiResult.user) return apiResult;
|
if (apiResult.user) return apiResult;
|
||||||
|
|
||||||
const sessionUser = await getSessionUser();
|
const sessionUser = await getSessionUser();
|
||||||
@@ -73,6 +84,16 @@ export async function requireUserFromSessionOrApi(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) {
|
if (!sessionUser.youtubeConnection) {
|
||||||
return {
|
return {
|
||||||
error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }),
|
error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }),
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import IORedis from "ioredis";
|
import IORedis from "ioredis";
|
||||||
export const DEFAULT_API_RATE_LIMIT = 100_000;
|
import { prisma } from "./db";
|
||||||
|
import { isSelfHostedEdition } from "./edition";
|
||||||
|
|
||||||
|
export const DEFAULT_API_RATE_LIMIT = 60;
|
||||||
export const API_RATE_WINDOW_SECONDS = 60;
|
export const API_RATE_WINDOW_SECONDS = 60;
|
||||||
|
export const MAX_ADMIN_API_RATE_BONUS = 120;
|
||||||
|
const SELFHOSTED_API_RATE_LIMIT = 100_000;
|
||||||
|
|
||||||
type MemoryBucket = {
|
type MemoryBucket = {
|
||||||
count: number;
|
count: number;
|
||||||
@@ -31,8 +36,13 @@ function rateKey(userId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getEffectiveApiRateLimit(userId: string) {
|
export async function getEffectiveApiRateLimit(userId: string) {
|
||||||
void userId;
|
if (isSelfHostedEdition()) return SELFHOSTED_API_RATE_LIMIT;
|
||||||
return DEFAULT_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);
|
||||||
}
|
}
|
||||||
|
|
||||||
function memoryStatus(userId: string, limit: number) {
|
function memoryStatus(userId: string, limit: number) {
|
||||||
@@ -110,6 +120,9 @@ function checkMemoryRateLimit(userId: string, limit: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function checkApiRateLimit(userId: string) {
|
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 limit = await getEffectiveApiRateLimit(userId);
|
||||||
const client = getRedis();
|
const client = getRedis();
|
||||||
if (!client) return checkMemoryRateLimit(userId, limit);
|
if (!client) return checkMemoryRateLimit(userId, limit);
|
||||||
|
|||||||
@@ -31,21 +31,17 @@ export async function readAudioTags(filePath: string): Promise<AudioTagMetadata
|
|||||||
export function audioTagsToMetadata(
|
export function audioTagsToMetadata(
|
||||||
tags: AudioTagMetadata,
|
tags: AudioTagMetadata,
|
||||||
fallbackTitle: string,
|
fallbackTitle: string,
|
||||||
): Pick<ItemMetadata, "title" | "description" | "tags" | "artist" | "songTitle"> {
|
): Pick<ItemMetadata, "title" | "description" | "tags"> {
|
||||||
const songTitle = tags.title || fallbackTitle;
|
const title =
|
||||||
const videoTitle =
|
tags.title ||
|
||||||
tags.artist && tags.title
|
(tags.artist ? `${tags.artist}${tags.album ? ` - ${tags.album}` : ""}` : fallbackTitle);
|
||||||
? `${tags.artist} - ${tags.title}`
|
|
||||||
: tags.title || fallbackTitle;
|
|
||||||
|
|
||||||
const description = [tags.artist, tags.album, tags.year].filter(Boolean).join(" · ");
|
const description = [tags.artist, tags.album, tags.year].filter(Boolean).join(" · ");
|
||||||
const tagParts = [tags.genre, tags.artist].filter(Boolean);
|
const tagParts = [tags.genre, tags.artist].filter(Boolean);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: videoTitle,
|
title,
|
||||||
songTitle,
|
|
||||||
description,
|
description,
|
||||||
tags: tagParts.join(", "),
|
tags: tagParts.join(", "),
|
||||||
artist: tags.artist || null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextAuthOptions } from "next-auth";
|
|||||||
import GoogleProvider from "next-auth/providers/google";
|
import GoogleProvider from "next-auth/providers/google";
|
||||||
import { encryptSecret } from "./crypto/secrets";
|
import { encryptSecret } from "./crypto/secrets";
|
||||||
import { prisma } from "./db";
|
import { prisma } from "./db";
|
||||||
|
import { getInitialQuotaResetAt } from "./quota";
|
||||||
import { fetchYouTubeChannel } from "./youtube/upload";
|
import { fetchYouTubeChannel } from "./youtube/upload";
|
||||||
|
|
||||||
export const authOptions: NextAuthOptions = {
|
export const authOptions: NextAuthOptions = {
|
||||||
@@ -35,6 +36,7 @@ export const authOptions: NextAuthOptions = {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
image: user.image,
|
image: user.image,
|
||||||
|
quotaResetAt: getInitialQuotaResetAt(),
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
name: user.name,
|
name: user.name,
|
||||||
@@ -85,6 +87,7 @@ export const authOptions: NextAuthOptions = {
|
|||||||
});
|
});
|
||||||
if (dbUser) {
|
if (dbUser) {
|
||||||
session.user.id = dbUser.id;
|
session.user.id = dbUser.id;
|
||||||
|
session.user.plan = dbUser.plan;
|
||||||
session.user.youtubeConnected = !!dbUser.youtubeConnection;
|
session.user.youtubeConnected = !!dbUser.youtubeConnection;
|
||||||
session.user.channelTitle = dbUser.youtubeConnection?.channelTitle;
|
session.user.channelTitle = dbUser.youtubeConnection?.channelTitle;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const BRAND_NAME = "Songs2VID";
|
export const BRAND_NAME = "Songs2VID";
|
||||||
export const BRAND_DOMAIN = "songs2vid.com";
|
export const BRAND_DOMAIN = `${BRAND_NAME}.com`;
|
||||||
export const VIDEO_ATTRIBUTION_PREFIX = "Uploaded through";
|
export const VIDEO_ATTRIBUTION_PREFIX = "Uploaded through";
|
||||||
|
|
||||||
export function getVideoAttributionText() {
|
export function getVideoAttributionText() {
|
||||||
return `${VIDEO_ATTRIBUTION_PREFIX} ${BRAND_NAME}.com`;
|
return `${VIDEO_ATTRIBUTION_PREFIX} ${BRAND_DOMAIN}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
export const FREE_PLAN = {
|
||||||
|
monthlyQuota: 14,
|
||||||
|
maxFileSizeBytes: 30 * 1024 * 1024,
|
||||||
|
maxBatchSize: 3,
|
||||||
|
watermarkOptional: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const RESOLUTIONS = [
|
export const RESOLUTIONS = [
|
||||||
{ value: "1920x1080", label: "1920x1080 (16:9)", width: 1920, height: 1080 },
|
{ value: "1920x1080", label: "1920x1080 (16:9)", width: 1920, height: 1080 },
|
||||||
{ value: "1280x720", label: "1280x720 (16:9)", width: 1280, height: 720 },
|
{ value: "1280x720", label: "1280x720 (16:9)", width: 1280, height: 720 },
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
|
import { Plan } from "@prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-hosted / OSS edition. Prefer S2VID_EDITION; S2YT_EDITION kept for older compose files.
|
||||||
|
*/
|
||||||
export function isSelfHostedEdition(): boolean {
|
export function isSelfHostedEdition(): boolean {
|
||||||
return true;
|
const edition =
|
||||||
|
process.env.S2VID_EDITION?.trim() || process.env.S2YT_EDITION?.trim() || "";
|
||||||
|
// Default to self-hosted for this OSS package when unset
|
||||||
|
if (!edition) return true;
|
||||||
|
return edition === "selfhosted";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasProFeatures(): boolean {
|
/** All Pro features are unlocked in the OSS / self-hosted edition. */
|
||||||
|
export function hasProFeatures(_plan?: Plan): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,41 @@
|
|||||||
|
import { Plan } from "@prisma/client";
|
||||||
import type { LayoutSettings } from "./layout";
|
import type { LayoutSettings } from "./layout";
|
||||||
import type { WatermarkSettings } from "./watermark";
|
import {
|
||||||
|
PREMIUM_REQUIRED_CODE,
|
||||||
|
type WatermarkSettings,
|
||||||
|
} from "./watermark";
|
||||||
|
|
||||||
export function assertCustomWatermarkAllowed(_settings: WatermarkSettings) {}
|
export class PremiumRequiredError extends Error {
|
||||||
|
readonly code = PREMIUM_REQUIRED_CODE;
|
||||||
|
readonly status = 403;
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "PremiumRequiredError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function assertArtTrackLayoutAllowed(_settings: LayoutSettings) {}
|
/** OSS: all watermark / typography features unlocked. */
|
||||||
|
export function assertCustomWatermarkAllowed(_plan: Plan, _settings: WatermarkSettings) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OSS: all art-track layout features unlocked. */
|
||||||
|
export function assertArtTrackLayoutAllowed(_plan: Plan, _settings: LayoutSettings) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OSS: per-track cover images always allowed. */
|
||||||
export function assertPerItemImagesAllowed(
|
export function assertPerItemImagesAllowed(
|
||||||
|
_plan: Plan,
|
||||||
_sharedImagePath: string,
|
_sharedImagePath: string,
|
||||||
_itemImagePaths: Array<string | null | undefined>,
|
_itemImagePaths: Array<string | null | undefined>,
|
||||||
) {}
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function premiumRequiredResponse(message: string) {
|
||||||
|
return {
|
||||||
|
error: message,
|
||||||
|
code: PREMIUM_REQUIRED_CODE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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}]`,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -8,18 +8,11 @@ import {
|
|||||||
isCuratedFontKey,
|
isCuratedFontKey,
|
||||||
sanitizeFontfileForFilter,
|
sanitizeFontfileForFilter,
|
||||||
} from "../fonts";
|
} from "../fonts";
|
||||||
import { resolveCuratedFontPath, resolveSystemFontPath, assertFontFile } from "../fonts-server";
|
import { resolveCuratedFontPath } from "../fonts-server";
|
||||||
import {
|
import {
|
||||||
buildArtTrackFilterComplex,
|
buildArtTrackFilterComplex,
|
||||||
buildClassicBlurFillFilterComplex,
|
|
||||||
isLowerCornerTemplate,
|
|
||||||
normalizeLayoutSettings,
|
|
||||||
type LayoutSettings,
|
type LayoutSettings,
|
||||||
} from "../layout";
|
} from "../layout";
|
||||||
import {
|
|
||||||
WATERMARK_WIDTH_FRACTION,
|
|
||||||
watermarkFontSizeForWidth,
|
|
||||||
} from "../preview-typography";
|
|
||||||
import { getWatermarkPath } from "../storage";
|
import { getWatermarkPath } from "../storage";
|
||||||
import {
|
import {
|
||||||
buildDrawtextFilter,
|
buildDrawtextFilter,
|
||||||
@@ -28,7 +21,6 @@ import {
|
|||||||
sanitizeDrawtext,
|
sanitizeDrawtext,
|
||||||
type WatermarkSettings,
|
type WatermarkSettings,
|
||||||
} from "../watermark";
|
} from "../watermark";
|
||||||
import { buildBrandWatermarkOverlayFilters } from "./brand-watermark";
|
|
||||||
|
|
||||||
function getFfmpegPath(): string {
|
function getFfmpegPath(): string {
|
||||||
if (process.env.FFMPEG_PATH) return process.env.FFMPEG_PATH;
|
if (process.env.FFMPEG_PATH) return process.env.FFMPEG_PATH;
|
||||||
@@ -64,26 +56,9 @@ export async function assertPngFile(filePath: string): Promise<void> {
|
|||||||
|
|
||||||
async function resolveFontfileEscaped(
|
async function resolveFontfileEscaped(
|
||||||
settings: WatermarkSettings,
|
settings: WatermarkSettings,
|
||||||
weight: "regular" | "bold" = "regular",
|
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const key = settings.fontKey ?? "system";
|
const key = settings.fontKey ?? "system";
|
||||||
if (key === "system") {
|
if (key === "system") return null;
|
||||||
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 (key === "custom") {
|
||||||
if (!settings.fontPath) return null;
|
if (!settings.fontPath) return null;
|
||||||
@@ -94,19 +69,12 @@ async function resolveFontfileEscaped(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isCuratedFontKey(key)) {
|
if (isCuratedFontKey(key)) {
|
||||||
const preferred = resolveCuratedFontPath(key, weight);
|
const fontPath = resolveCuratedFontPath(key);
|
||||||
const fallback = weight === "bold" ? resolveCuratedFontPath(key, "regular") : preferred;
|
if (!(await fileExists(fontPath))) {
|
||||||
for (const fontPath of weight === "bold" && preferred !== fallback ? [preferred, fallback] : [preferred]) {
|
console.warn(`[ffmpeg] curated font missing: ${key} at ${fontPath}; using system font`);
|
||||||
if (!(await fileExists(fontPath))) continue;
|
return null;
|
||||||
try {
|
|
||||||
await assertFontFile(fontPath);
|
|
||||||
return sanitizeFontfileForFilter(fontPath);
|
|
||||||
} catch {
|
|
||||||
/* try next */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
console.warn(`[ffmpeg] curated font missing: ${key}; using system font`);
|
return sanitizeFontfileForFilter(fontPath);
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -116,38 +84,6 @@ 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`;
|
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].
|
* Art-track filter that ends at `outLabel` instead of hardcoded [laid].
|
||||||
*/
|
*/
|
||||||
@@ -158,13 +94,6 @@ function artTrackFilterEndingAt(
|
|||||||
return buildArtTrackFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`);
|
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: {
|
export async function encodeVideo(options: {
|
||||||
imagePath: string;
|
imagePath: string;
|
||||||
audioPath: string;
|
audioPath: string;
|
||||||
@@ -173,63 +102,26 @@ export async function encodeVideo(options: {
|
|||||||
includeWatermark: boolean;
|
includeWatermark: boolean;
|
||||||
watermark?: Partial<WatermarkSettings> | null;
|
watermark?: Partial<WatermarkSettings> | null;
|
||||||
layout?: LayoutSettings | null;
|
layout?: LayoutSettings | null;
|
||||||
/** On-video song title (art-track layouts). */
|
title?: string;
|
||||||
songTitle?: string;
|
|
||||||
artist?: string | null;
|
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> {
|
}): Promise<void> {
|
||||||
const res = getResolution(options.resolution);
|
const res = getResolution(options.resolution);
|
||||||
if (!res) throw new Error(`Invalid resolution: ${options.resolution}`);
|
if (!res) throw new Error(`Invalid resolution: ${options.resolution}`);
|
||||||
|
|
||||||
const audioEncode: AudioEncodeOptions = {
|
|
||||||
bitrateKbps: options.audioBitrateKbps ?? 320,
|
|
||||||
copyWhenSafe: options.audioCopyWhenSafe ?? true,
|
|
||||||
audioPath: options.audioPath,
|
|
||||||
};
|
|
||||||
|
|
||||||
await fs.mkdir(path.dirname(options.outputPath), { recursive: true });
|
await fs.mkdir(path.dirname(options.outputPath), { recursive: true });
|
||||||
|
|
||||||
const settings = normalizeWatermarkSettings(options.watermark, options.includeWatermark);
|
const settings = normalizeWatermarkSettings(options.watermark, options.includeWatermark);
|
||||||
const layoutSettings = options.layout
|
const layout = options.layout?.template ? options.layout : null;
|
||||||
? normalizeLayoutSettings(options.layout)
|
const watermarkWidth = Math.max(1, Math.round(res.width * 0.32));
|
||||||
: null;
|
const fontSize = Math.max(16, Math.round(res.width * 0.018));
|
||||||
const artTrack = Boolean(layoutSettings?.template);
|
const fontfile = await resolveFontfileEscaped(settings);
|
||||||
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];
|
const args = ["-y", "-loop", "1", "-r", "1", "-i", options.imagePath, "-i", options.audioPath];
|
||||||
|
|
||||||
let nextInput = 2;
|
let nextInput = 2;
|
||||||
let separateBackgroundInputIndex: number | null = null;
|
|
||||||
let logoInputIndex: number | null = null;
|
let logoInputIndex: number | null = null;
|
||||||
let defaultWmInputIndex: 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 needsLogo = settings.mode === "logo" && Boolean(settings.logoPath);
|
||||||
const defaultPath = getWatermarkPath();
|
const defaultPath = getWatermarkPath();
|
||||||
const useDefaultPng =
|
const useDefaultPng =
|
||||||
@@ -258,17 +150,16 @@ export async function encodeVideo(options: {
|
|||||||
? ("default-text" as const)
|
? ("default-text" as const)
|
||||||
: ("none" as const);
|
: ("none" as const);
|
||||||
|
|
||||||
// Fast path: classic letterbox (black bars), no watermark, no blur fill
|
// Fast path: classic letterbox, no watermark
|
||||||
if (!layout && !classicBlurFill && applyWm === "none") {
|
if (!layout && applyWm === "none") {
|
||||||
args.push("-vf", classicScaleFilter(res.width, res.height));
|
args.push("-vf", classicScaleFilter(res.width, res.height));
|
||||||
args.push(
|
args.push(
|
||||||
"-c:v",
|
"-c:v",
|
||||||
"libx264",
|
"libx264",
|
||||||
"-tune",
|
"-tune",
|
||||||
"stillimage",
|
"stillimage",
|
||||||
);
|
"-c:a",
|
||||||
pushAudioEncodeArgs(args, audioEncode);
|
"copy",
|
||||||
args.push(
|
|
||||||
"-shortest",
|
"-shortest",
|
||||||
"-pix_fmt",
|
"-pix_fmt",
|
||||||
"yuv420p",
|
"yuv420p",
|
||||||
@@ -283,7 +174,7 @@ export async function encodeVideo(options: {
|
|||||||
const filterParts: string[] = [];
|
const filterParts: string[] = [];
|
||||||
|
|
||||||
if (layout) {
|
if (layout) {
|
||||||
const titleEscaped = sanitizeDrawtext(options.songTitle?.trim() || "Untitled");
|
const titleEscaped = sanitizeDrawtext(options.title?.trim() || "Untitled");
|
||||||
const artistRaw = options.artist?.trim();
|
const artistRaw = options.artist?.trim();
|
||||||
const artistEscaped = artistRaw ? sanitizeDrawtext(artistRaw) : null;
|
const artistEscaped = artistRaw ? sanitizeDrawtext(artistRaw) : null;
|
||||||
filterParts.push(
|
filterParts.push(
|
||||||
@@ -295,20 +186,6 @@ export async function encodeVideo(options: {
|
|||||||
titleEscaped,
|
titleEscaped,
|
||||||
artistEscaped,
|
artistEscaped,
|
||||||
fontfileEscaped: fontfile,
|
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,
|
baseLabel,
|
||||||
),
|
),
|
||||||
@@ -335,14 +212,10 @@ export async function encodeVideo(options: {
|
|||||||
});
|
});
|
||||||
filterParts.push(`[${baseLabel}]${draw}[vout]`);
|
filterParts.push(`[${baseLabel}]${draw}[vout]`);
|
||||||
} else if (applyWm === "default-png" && defaultWmInputIndex !== null) {
|
} else if (applyWm === "default-png" && defaultWmInputIndex !== null) {
|
||||||
|
const { x, y } = overlayXy(settings.position, settings.offsetX, settings.offsetY);
|
||||||
filterParts.push(
|
filterParts.push(
|
||||||
...buildBrandWatermarkOverlayFilters({
|
`[${defaultWmInputIndex}:v]scale=${watermarkWidth}:-1[wm]`,
|
||||||
baseLabel,
|
`[${baseLabel}][wm]overlay=${x}:${y}[vout]`,
|
||||||
watermarkInputIndex: defaultWmInputIndex,
|
|
||||||
watermarkWidthPx: watermarkWidth,
|
|
||||||
offsetX: settings.offsetX,
|
|
||||||
offsetY: settings.offsetY,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
} else if (applyWm === "default-text") {
|
} else if (applyWm === "default-text") {
|
||||||
const draw = buildDrawtextFilter({
|
const draw = buildDrawtextFilter({
|
||||||
@@ -352,7 +225,7 @@ export async function encodeVideo(options: {
|
|||||||
position: settings.position,
|
position: settings.position,
|
||||||
offsetX: settings.offsetX,
|
offsetX: settings.offsetX,
|
||||||
offsetY: settings.offsetY,
|
offsetY: settings.offsetY,
|
||||||
fontfileEscaped: fontfile,
|
fontfileEscaped: null,
|
||||||
});
|
});
|
||||||
filterParts.push(`[${baseLabel}]${draw}[vout]`);
|
filterParts.push(`[${baseLabel}]${draw}[vout]`);
|
||||||
}
|
}
|
||||||
@@ -363,9 +236,8 @@ export async function encodeVideo(options: {
|
|||||||
"libx264",
|
"libx264",
|
||||||
"-tune",
|
"-tune",
|
||||||
"stillimage",
|
"stillimage",
|
||||||
);
|
"-c:a",
|
||||||
pushAudioEncodeArgs(args, audioEncode);
|
"copy",
|
||||||
args.push(
|
|
||||||
"-shortest",
|
"-shortest",
|
||||||
"-pix_fmt",
|
"-pix_fmt",
|
||||||
"yuv420p",
|
"yuv420p",
|
||||||
|
|||||||
@@ -2,32 +2,19 @@
|
|||||||
|
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { CURATED_FONTS, SYSTEM_FONT, type CuratedFontKey } from "./fonts";
|
import { CURATED_FONTS, type CuratedFontKey } from "./fonts";
|
||||||
|
|
||||||
export function getFontsDir(): string {
|
export function getFontsDir(): string {
|
||||||
return path.join(process.cwd(), "assets", "fonts");
|
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). */
|
/** Resolve a curated font file on disk (must exist under assets/fonts). */
|
||||||
export function resolveCuratedFontPath(
|
export function resolveCuratedFontPath(key: CuratedFontKey): string {
|
||||||
key: CuratedFontKey,
|
|
||||||
weight: "regular" | "bold" = "regular",
|
|
||||||
): string {
|
|
||||||
const meta = CURATED_FONTS.find((f) => f.key === key);
|
const meta = CURATED_FONTS.find((f) => f.key === key);
|
||||||
if (!meta) throw new Error("Unknown font");
|
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
|
// Whitelist filename only never accept user-controlled path segments
|
||||||
const safe = file.replace(/[^a-zA-Z0-9._-]/g, "");
|
const safe = meta.file.replace(/[^a-zA-Z0-9._-]/g, "");
|
||||||
if (safe !== file) throw new Error("Invalid font asset name");
|
if (safe !== meta.file) throw new Error("Invalid font asset name");
|
||||||
return path.join(getFontsDir(), safe);
|
return path.join(getFontsDir(), safe);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,6 @@
|
|||||||
/** Max custom font upload size (10 MB). */
|
/** Max custom font upload size (10 MB). */
|
||||||
export const FONT_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
|
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 = [
|
export const CURATED_FONTS = [
|
||||||
{
|
{
|
||||||
key: "inter",
|
key: "inter",
|
||||||
@@ -19,7 +10,6 @@ export const CURATED_FONTS = [
|
|||||||
cssFamily: "Inter",
|
cssFamily: "Inter",
|
||||||
googleCss: "Inter:wght@400;600",
|
googleCss: "Inter:wght@400;600",
|
||||||
file: "Inter-Regular.ttf",
|
file: "Inter-Regular.ttf",
|
||||||
boldFile: null as string | null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "montserrat",
|
key: "montserrat",
|
||||||
@@ -27,7 +17,6 @@ export const CURATED_FONTS = [
|
|||||||
cssFamily: "Montserrat",
|
cssFamily: "Montserrat",
|
||||||
googleCss: "Montserrat:wght@400;600",
|
googleCss: "Montserrat:wght@400;600",
|
||||||
file: "Montserrat-Regular.ttf",
|
file: "Montserrat-Regular.ttf",
|
||||||
boldFile: null as string | null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "roboto",
|
key: "roboto",
|
||||||
@@ -35,7 +24,6 @@ export const CURATED_FONTS = [
|
|||||||
cssFamily: "Roboto",
|
cssFamily: "Roboto",
|
||||||
googleCss: "Roboto:wght@400;500",
|
googleCss: "Roboto:wght@400;500",
|
||||||
file: "Roboto-Regular.ttf",
|
file: "Roboto-Regular.ttf",
|
||||||
boldFile: null as string | null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "oswald",
|
key: "oswald",
|
||||||
@@ -43,7 +31,6 @@ export const CURATED_FONTS = [
|
|||||||
cssFamily: "Oswald",
|
cssFamily: "Oswald",
|
||||||
googleCss: "Oswald:wght@400;500",
|
googleCss: "Oswald:wght@400;500",
|
||||||
file: "Oswald-Regular.ttf",
|
file: "Oswald-Regular.ttf",
|
||||||
boldFile: null as string | null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "playfair",
|
key: "playfair",
|
||||||
@@ -51,7 +38,6 @@ export const CURATED_FONTS = [
|
|||||||
cssFamily: "Playfair Display",
|
cssFamily: "Playfair Display",
|
||||||
googleCss: "Playfair+Display:wght@400;600",
|
googleCss: "Playfair+Display:wght@400;600",
|
||||||
file: "PlayfairDisplay-Regular.ttf",
|
file: "PlayfairDisplay-Regular.ttf",
|
||||||
boldFile: null as string | null,
|
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { Privacy } from "@prisma/client";
|
import { Plan, Privacy } from "@prisma/client";
|
||||||
import { readAudioTags } from "../audio-tags";
|
import { readAudioTags } from "../audio-tags";
|
||||||
import { filenameWithoutExtension, isAllowedResolution } from "../constants";
|
import { isAllowedResolution } from "../constants";
|
||||||
import { prisma } from "../db";
|
import { prisma } from "../db";
|
||||||
|
import { hasProFeatures } from "../edition";
|
||||||
import {
|
import {
|
||||||
assertArtTrackLayoutAllowed,
|
assertArtTrackLayoutAllowed,
|
||||||
assertCustomWatermarkAllowed,
|
assertCustomWatermarkAllowed,
|
||||||
assertPerItemImagesAllowed,
|
assertPerItemImagesAllowed,
|
||||||
|
PremiumRequiredError,
|
||||||
} from "../entitlements";
|
} from "../entitlements";
|
||||||
import { FONT_UPLOAD_MAX_BYTES } from "../fonts";
|
import { FONT_UPLOAD_MAX_BYTES } from "../fonts";
|
||||||
import { assertFontFile } from "../fonts-server";
|
import { assertFontFile } from "../fonts-server";
|
||||||
@@ -16,9 +18,7 @@ import { moveFile, writeUploadedFile } from "../fs-utils";
|
|||||||
import {
|
import {
|
||||||
ARTIST_MAX,
|
ARTIST_MAX,
|
||||||
INVALID_LAYOUT_TEMPLATE_MESSAGE,
|
INVALID_LAYOUT_TEMPLATE_MESSAGE,
|
||||||
isLowerCornerTemplate,
|
|
||||||
normalizeLayoutSettings,
|
normalizeLayoutSettings,
|
||||||
SONG_TITLE_MAX,
|
|
||||||
type LayoutSettings,
|
type LayoutSettings,
|
||||||
} from "../layout";
|
} from "../layout";
|
||||||
import { enqueueVideoJob } from "../queue/client";
|
import { enqueueVideoJob } from "../queue/client";
|
||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
isAudioExtensionAllowed,
|
isAudioExtensionAllowed,
|
||||||
isResolutionAllowedForPlan,
|
isResolutionAllowedForPlan,
|
||||||
} from "../plans";
|
} from "../plans";
|
||||||
import { reserveQuota } from "../quota";
|
import { releaseReservationSplit, reserveQuota } from "../quota";
|
||||||
import { getJobDir } from "../storage";
|
import { getJobDir } from "../storage";
|
||||||
import {
|
import {
|
||||||
assertPathInUserUploads,
|
assertPathInUserUploads,
|
||||||
@@ -35,12 +35,10 @@ import {
|
|||||||
sanitizeUploadSessionKey,
|
sanitizeUploadSessionKey,
|
||||||
} from "../upload-paths";
|
} from "../upload-paths";
|
||||||
import type { CreateJobPayload, ItemMetadata } from "../types";
|
import type { CreateJobPayload, ItemMetadata } from "../types";
|
||||||
import { resolveBurnedSongTitle, resolveYouTubeTitle, validateYouTubeTitle } from "../titles";
|
|
||||||
import {
|
import {
|
||||||
normalizeWatermarkSettings,
|
normalizeWatermarkSettings,
|
||||||
WATERMARK_TEXT_MAX,
|
WATERMARK_TEXT_MAX,
|
||||||
} from "../watermark";
|
} from "../watermark";
|
||||||
import { applyBrandWatermarkPolicy } from "../watermark-policy";
|
|
||||||
|
|
||||||
/** Merge nested + flat layout fields, then normalize (throws on bad template / coords). */
|
/** Merge nested + flat layout fields, then normalize (throws on bad template / coords). */
|
||||||
export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSettings {
|
export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSettings {
|
||||||
@@ -57,11 +55,6 @@ export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSetting
|
|||||||
metadata.layout?.blur_amount ??
|
metadata.layout?.blur_amount ??
|
||||||
metadata.blurAmount ??
|
metadata.blurAmount ??
|
||||||
metadata.blur_amount,
|
metadata.blur_amount,
|
||||||
blurFill:
|
|
||||||
metadata.layout?.blurFill ??
|
|
||||||
(metadata.layout as { blur_fill?: boolean } | null | undefined)?.blur_fill ??
|
|
||||||
metadata.blurFill ??
|
|
||||||
metadata.blur_fill,
|
|
||||||
blurOpacity:
|
blurOpacity:
|
||||||
metadata.layout?.blurOpacity ??
|
metadata.layout?.blurOpacity ??
|
||||||
(metadata.layout as { blur_opacity?: number } | null | undefined)?.blur_opacity ??
|
(metadata.layout as { blur_opacity?: number } | null | undefined)?.blur_opacity ??
|
||||||
@@ -87,19 +80,11 @@ export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSetting
|
|||||||
(metadata.layout as { text_offset_y?: number } | null | undefined)?.text_offset_y ??
|
(metadata.layout as { text_offset_y?: number } | null | undefined)?.text_offset_y ??
|
||||||
metadata.textOffsetY ??
|
metadata.textOffsetY ??
|
||||||
metadata.text_offset_y,
|
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(
|
export function validateItemMetadata(metadata: CreateJobPayload["items"][0]["metadata"]) {
|
||||||
metadata: CreateJobPayload["items"][0]["metadata"],
|
if (!metadata.title?.trim()) return "Each video must have a title";
|
||||||
) {
|
|
||||||
const titleErr = validateYouTubeTitle(metadata);
|
|
||||||
if (titleErr) return titleErr;
|
|
||||||
if (!isAllowedResolution(metadata.resolution)) return "Invalid resolution";
|
if (!isAllowedResolution(metadata.resolution)) return "Invalid resolution";
|
||||||
if (!["PUBLIC", "PRIVATE", "UNLISTED"].includes(metadata.privacy)) {
|
if (!["PUBLIC", "PRIVATE", "UNLISTED"].includes(metadata.privacy)) {
|
||||||
return "Invalid privacy setting";
|
return "Invalid privacy setting";
|
||||||
@@ -110,9 +95,6 @@ export function validateItemMetadata(
|
|||||||
if (metadata.artist && metadata.artist.length > ARTIST_MAX) {
|
if (metadata.artist && metadata.artist.length > ARTIST_MAX) {
|
||||||
return `Artist must be at most ${ARTIST_MAX} characters`;
|
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 {
|
try {
|
||||||
resolveLayoutFromMetadata(metadata);
|
resolveLayoutFromMetadata(metadata);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -124,7 +106,7 @@ export function validateItemMetadata(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateJobPayload(user: { id: string }, body: CreateJobPayload) {
|
export async function validateJobPayload(user: { id: string; plan: Plan }, body: CreateJobPayload) {
|
||||||
if (!body.imagePath || !body.items?.length) {
|
if (!body.imagePath || !body.items?.length) {
|
||||||
return "Image and at least one audio file required";
|
return "Image and at least one audio file required";
|
||||||
}
|
}
|
||||||
@@ -134,8 +116,11 @@ export async function validateJobPayload(user: { id: string }, body: CreateJobPa
|
|||||||
for (const item of body.items) {
|
for (const item of body.items) {
|
||||||
const metaError = validateItemMetadata(item.metadata);
|
const metaError = validateItemMetadata(item.metadata);
|
||||||
if (metaError) return metaError;
|
if (metaError) return metaError;
|
||||||
if (!isResolutionAllowedForPlan(item.metadata.resolution)) {
|
if (!isResolutionAllowedForPlan(item.metadata.resolution, user.plan)) {
|
||||||
return `Resolution ${item.metadata.resolution} is not available`;
|
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";
|
||||||
}
|
}
|
||||||
|
|
||||||
const wm = normalizeWatermarkSettings(
|
const wm = normalizeWatermarkSettings(
|
||||||
@@ -143,9 +128,10 @@ export async function validateJobPayload(user: { id: string }, body: CreateJobPa
|
|||||||
item.metadata.includeWatermark,
|
item.metadata.includeWatermark,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
assertCustomWatermarkAllowed(wm);
|
assertCustomWatermarkAllowed(user.plan, wm);
|
||||||
assertArtTrackLayoutAllowed(resolveLayoutFromMetadata(item.metadata));
|
assertArtTrackLayoutAllowed(user.plan, resolveLayoutFromMetadata(item.metadata));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof PremiumRequiredError) return err.message;
|
||||||
if (err instanceof Error && err.message === INVALID_LAYOUT_TEMPLATE_MESSAGE) {
|
if (err instanceof Error && err.message === INVALID_LAYOUT_TEMPLATE_MESSAGE) {
|
||||||
return INVALID_LAYOUT_TEMPLATE_MESSAGE;
|
return INVALID_LAYOUT_TEMPLATE_MESSAGE;
|
||||||
}
|
}
|
||||||
@@ -156,12 +142,13 @@ export async function validateJobPayload(user: { id: string }, body: CreateJobPa
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
assertPerItemImagesAllowed(body.imagePath, itemImagePaths);
|
assertPerItemImagesAllowed(user.plan, body.imagePath, itemImagePaths);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof PremiumRequiredError) return err.message;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const limits = getPlanLimits();
|
const limits = getPlanLimits(user.plan);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
|
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
|
||||||
@@ -172,8 +159,8 @@ export async function validateJobPayload(user: { id: string }, body: CreateJobPa
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const item of body.items) {
|
for (const item of body.items) {
|
||||||
if (!isAudioExtensionAllowed(item.audioFilename)) {
|
if (!isAudioExtensionAllowed(item.audioFilename, user.plan)) {
|
||||||
return `Audio file ${item.audioFilename} is not supported`;
|
return `Audio file ${item.audioFilename} is not supported on your plan`;
|
||||||
}
|
}
|
||||||
const audioPath = assertPathInUserUploads(user.id, item.audioPath);
|
const audioPath = assertPathInUserUploads(user.id, item.audioPath);
|
||||||
await fs.access(audioPath);
|
await fs.access(audioPath);
|
||||||
@@ -191,18 +178,6 @@ export async function validateJobPayload(user: { id: string }, body: CreateJobPa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
const wm = normalizeWatermarkSettings(
|
||||||
item.metadata.watermark,
|
item.metadata.watermark,
|
||||||
item.metadata.includeWatermark,
|
item.metadata.includeWatermark,
|
||||||
@@ -219,7 +194,7 @@ export async function validateJobPayload(user: { id: string }, body: CreateJobPa
|
|||||||
if (wm.mode === "text" && !wm.text?.trim()) {
|
if (wm.mode === "text" && !wm.text?.trim()) {
|
||||||
return "Watermark text mode requires non-empty text";
|
return "Watermark text mode requires non-empty text";
|
||||||
}
|
}
|
||||||
if (wm.fontKey === "custom") {
|
if (wm.mode === "text" && wm.fontKey === "custom") {
|
||||||
if (!wm.fontPath) {
|
if (!wm.fontPath) {
|
||||||
return "Custom font selected but no font file uploaded";
|
return "Custom font selected but no font file uploaded";
|
||||||
}
|
}
|
||||||
@@ -233,6 +208,7 @@ export async function validateJobPayload(user: { id: string }, body: CreateJobPa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof PremiumRequiredError) return err.message;
|
||||||
if (err instanceof Error && err.message === "Invalid upload path") {
|
if (err instanceof Error && err.message === "Invalid upload path") {
|
||||||
return "Invalid upload path";
|
return "Invalid upload path";
|
||||||
}
|
}
|
||||||
@@ -253,7 +229,7 @@ export async function validateJobPayload(user: { id: string }, body: CreateJobPa
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createVideoJob(
|
export async function createVideoJob(
|
||||||
user: { id: string },
|
user: { id: string; plan: Plan },
|
||||||
body: CreateJobPayload,
|
body: CreateJobPayload,
|
||||||
) {
|
) {
|
||||||
const validationError = await validateJobPayload(user, body);
|
const validationError = await validateJobPayload(user, body);
|
||||||
@@ -261,58 +237,41 @@ export async function createVideoJob(
|
|||||||
if (validationError === INVALID_LAYOUT_TEMPLATE_MESSAGE) {
|
if (validationError === INVALID_LAYOUT_TEMPLATE_MESSAGE) {
|
||||||
throw new Error(validationError);
|
throw new Error(validationError);
|
||||||
}
|
}
|
||||||
throw new Error(validationError);
|
const isPremium =
|
||||||
|
validationError.includes("requires Pro") ||
|
||||||
|
validationError.includes("Pro plan");
|
||||||
|
const err = isPremium
|
||||||
|
? new PremiumRequiredError(validationError)
|
||||||
|
: new Error(validationError);
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
|
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
|
||||||
const items = body.items.map((item) => {
|
const items = body.items.map((item) => ({
|
||||||
const layout = resolveLayoutFromMetadata(item.metadata);
|
...item,
|
||||||
const bgRaw =
|
audioPath: assertPathInUserUploads(user.id, item.audioPath),
|
||||||
item.metadata.backgroundImagePath ?? item.metadata.background_image_path ?? null;
|
itemImagePath: item.metadata.imagePath
|
||||||
const backgroundImagePath =
|
? assertPathInUserUploads(user.id, item.metadata.imagePath)
|
||||||
bgRaw && isLowerCornerTemplate(layout.template)
|
: null,
|
||||||
? assertPathInUserUploads(user.id, bgRaw)
|
watermarkLogoPath: item.metadata.watermark?.logoPath
|
||||||
: null;
|
? assertPathInUserUploads(user.id, item.metadata.watermark.logoPath)
|
||||||
return {
|
: null,
|
||||||
...item,
|
watermarkFontPath:
|
||||||
audioPath: assertPathInUserUploads(user.id, item.audioPath),
|
item.metadata.watermark?.fontKey === "custom" && item.metadata.watermark?.fontPath
|
||||||
itemImagePath: item.metadata.imagePath
|
? assertPathInUserUploads(user.id, item.metadata.watermark.fontPath)
|
||||||
? assertPathInUserUploads(user.id, item.metadata.imagePath)
|
|
||||||
: null,
|
: 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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
await reserveQuota(user.id, items.length);
|
const reservation = 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 {
|
try {
|
||||||
const job = await prisma.job.create({
|
const job = await prisma.job.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
imagePath,
|
imagePath,
|
||||||
webhookUrl: webhookRaw,
|
|
||||||
items: {
|
items: {
|
||||||
create: items.map((item, index) => {
|
create: items.map((item) => {
|
||||||
const wm = applyBrandWatermarkPolicy(
|
const wm = normalizeWatermarkSettings(
|
||||||
item.metadata.watermark
|
item.metadata.watermark
|
||||||
? {
|
? {
|
||||||
...item.metadata.watermark,
|
...item.metadata.watermark,
|
||||||
@@ -323,16 +282,10 @@ export async function createVideoJob(
|
|||||||
item.metadata.includeWatermark,
|
item.metadata.includeWatermark,
|
||||||
);
|
);
|
||||||
const layout = resolveLayoutFromMetadata(item.metadata);
|
const layout = resolveLayoutFromMetadata(item.metadata);
|
||||||
const youtubeTitle = resolveYouTubeTitle(item.metadata);
|
|
||||||
const burnedTitle = resolveBurnedSongTitle(
|
|
||||||
item.metadata,
|
|
||||||
filenameWithoutExtension(item.audioFilename),
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
audioPath: item.audioPath,
|
audioPath: item.audioPath,
|
||||||
audioFilename: item.audioFilename,
|
audioFilename: item.audioFilename,
|
||||||
title: youtubeTitle,
|
title: item.metadata.title.trim(),
|
||||||
songTitle: burnedTitle,
|
|
||||||
description: item.metadata.description || "",
|
description: item.metadata.description || "",
|
||||||
tags: item.metadata.tags || "",
|
tags: item.metadata.tags || "",
|
||||||
privacy: item.metadata.privacy as Privacy,
|
privacy: item.metadata.privacy as Privacy,
|
||||||
@@ -344,27 +297,25 @@ export async function createVideoJob(
|
|||||||
creativeCommons: item.metadata.creativeCommons,
|
creativeCommons: item.metadata.creativeCommons,
|
||||||
includeWatermark: wm.mode !== "none",
|
includeWatermark: wm.mode !== "none",
|
||||||
itemImagePath: item.itemImagePath,
|
itemImagePath: item.itemImagePath,
|
||||||
backgroundImagePath: item.backgroundImagePath,
|
|
||||||
watermarkMode: wm.mode,
|
watermarkMode: wm.mode,
|
||||||
watermarkText: wm.mode === "text" ? wm.text?.trim() || null : null,
|
watermarkText: wm.mode === "text" ? wm.text?.trim() || null : null,
|
||||||
watermarkLogoPath: wm.mode === "logo" ? item.watermarkLogoPath : null,
|
watermarkLogoPath: wm.mode === "logo" ? item.watermarkLogoPath : null,
|
||||||
watermarkFontKey: wm.fontKey ?? "system",
|
watermarkFontKey: wm.mode === "text" ? wm.fontKey ?? "system" : null,
|
||||||
watermarkFontPath:
|
watermarkFontPath:
|
||||||
wm.fontKey === "custom" ? item.watermarkFontPath : null,
|
wm.mode === "text" && wm.fontKey === "custom" ? item.watermarkFontPath : null,
|
||||||
watermarkPosition: wm.position,
|
watermarkPosition: wm.position,
|
||||||
watermarkOffsetX: wm.offsetX,
|
watermarkOffsetX: wm.offsetX,
|
||||||
watermarkOffsetY: wm.offsetY,
|
watermarkOffsetY: wm.offsetY,
|
||||||
artist: item.metadata.artist?.trim().slice(0, ARTIST_MAX) || null,
|
artist: item.metadata.artist?.trim().slice(0, ARTIST_MAX) || null,
|
||||||
layoutTemplate: layout.template,
|
layoutTemplate: layout.template,
|
||||||
blurFill: layout.blurFill,
|
|
||||||
blurAmount: layout.blurAmount,
|
blurAmount: layout.blurAmount,
|
||||||
blurOpacity: layout.blurOpacity,
|
blurOpacity: layout.blurOpacity,
|
||||||
textPadding: layout.textPadding,
|
textPadding: layout.textPadding,
|
||||||
titleArtistGap: layout.titleArtistGap,
|
titleArtistGap: layout.titleArtistGap,
|
||||||
titleBold: layout.titleBold,
|
|
||||||
textOffsetX: layout.textOffsetX,
|
textOffsetX: layout.textOffsetX,
|
||||||
textOffsetY: layout.textOffsetY,
|
textOffsetY: layout.textOffsetY,
|
||||||
playlistId: item.metadata.playlistId?.trim() || null,
|
playlistId: item.metadata.playlistId?.trim() || null,
|
||||||
|
billingSource: "QUOTA",
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -402,19 +353,6 @@ 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;
|
let newLogo: string | null = null;
|
||||||
if (item.watermarkLogoPath) {
|
if (item.watermarkLogoPath) {
|
||||||
const src = item.watermarkLogoPath;
|
const src = item.watermarkLogoPath;
|
||||||
@@ -445,7 +383,6 @@ export async function createVideoJob(
|
|||||||
data: {
|
data: {
|
||||||
audioPath: newAudioPath,
|
audioPath: newAudioPath,
|
||||||
itemImagePath: newItemImage,
|
itemImagePath: newItemImage,
|
||||||
backgroundImagePath: newBackgroundImage,
|
|
||||||
watermarkLogoPath: newLogo,
|
watermarkLogoPath: newLogo,
|
||||||
watermarkFontPath: newFont,
|
watermarkFontPath: newFont,
|
||||||
},
|
},
|
||||||
@@ -461,6 +398,7 @@ export async function createVideoJob(
|
|||||||
|
|
||||||
return job;
|
return job;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
await releaseReservationSplit(user.id, reservation).catch(() => {});
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -471,11 +409,15 @@ export async function saveUploadedFile(
|
|||||||
userId: string,
|
userId: string,
|
||||||
file: File,
|
file: File,
|
||||||
type: UploadFileType,
|
type: UploadFileType,
|
||||||
|
plan: Plan,
|
||||||
options?: { sessionKey?: string },
|
options?: { sessionKey?: string },
|
||||||
) {
|
) {
|
||||||
const limits = getPlanLimits();
|
const limits = getPlanLimits(plan);
|
||||||
|
|
||||||
if (type === "font") {
|
if (type === "font") {
|
||||||
|
if (!limits.customWatermark && !hasProFeatures(plan)) {
|
||||||
|
throw new PremiumRequiredError("Custom watermark fonts require Pro.");
|
||||||
|
}
|
||||||
if (file.size > FONT_UPLOAD_MAX_BYTES) {
|
if (file.size > FONT_UPLOAD_MAX_BYTES) {
|
||||||
throw new Error("Font file must be 10 MB or smaller");
|
throw new Error("Font file must be 10 MB or smaller");
|
||||||
}
|
}
|
||||||
@@ -487,6 +429,9 @@ export async function saveUploadedFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === "logo") {
|
if (type === "logo") {
|
||||||
|
if (!limits.customWatermark && !hasProFeatures(plan)) {
|
||||||
|
throw new PremiumRequiredError("Custom logo watermarks require Pro.");
|
||||||
|
}
|
||||||
const nameOk = /\.png$/i.test(file.name);
|
const nameOk = /\.png$/i.test(file.name);
|
||||||
const typeOk = file.type === "image/png" || file.type === "";
|
const typeOk = file.type === "image/png" || file.type === "";
|
||||||
if (!nameOk && !typeOk) {
|
if (!nameOk && !typeOk) {
|
||||||
@@ -521,9 +466,9 @@ export async function saveUploadedFile(
|
|||||||
) {
|
) {
|
||||||
throw new Error("Invalid audio file type");
|
throw new Error("Invalid audio file type");
|
||||||
}
|
}
|
||||||
if (!isAudioExtensionAllowed(file.name)) {
|
if (!isAudioExtensionAllowed(file.name, plan)) {
|
||||||
const allowed = limits.allowedAudioExtensions.join(", ");
|
const allowed = limits.allowedAudioExtensions.join(", ");
|
||||||
throw new Error(`Supported audio formats: ${allowed}`);
|
throw new Error(`Your plan supports ${allowed} audio files only`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Plan } from "@prisma/client";
|
||||||
|
import { hasProFeatures } from "../edition";
|
||||||
import type { CreateJobPayload, CreatePlaylistRequest, ItemMetadata } from "../types";
|
import type { CreateJobPayload, CreatePlaylistRequest, ItemMetadata } from "../types";
|
||||||
import { createYouTubePlaylist } from "../youtube/upload";
|
import { createYouTubePlaylist } from "../youtube/upload";
|
||||||
|
|
||||||
@@ -20,7 +22,7 @@ export function parseCreatePlaylistInput(raw: unknown): CreatePlaylistRequest |
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function applyCreatePlaylistToItems(
|
export async function applyCreatePlaylistToItems(
|
||||||
user: { id: string },
|
user: { id: string; plan: Plan },
|
||||||
items: CreateJobPayload["items"],
|
items: CreateJobPayload["items"],
|
||||||
createPlaylist: CreatePlaylistRequest | null | undefined,
|
createPlaylist: CreatePlaylistRequest | null | undefined,
|
||||||
) {
|
) {
|
||||||
@@ -28,6 +30,10 @@ export async function applyCreatePlaylistToItems(
|
|||||||
return { items, playlist: null as Awaited<ReturnType<typeof createYouTubePlaylist>> | null };
|
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 playlist = await createYouTubePlaylist(user.id, createPlaylist);
|
||||||
const nextItems = items.map((item) => ({
|
const nextItems = items.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ export const LAYOUT_TEMPLATES = [
|
|||||||
"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_RIGHT_COVER_TEXT",
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type LayoutTemplate = (typeof LAYOUT_TEMPLATES)[number];
|
export type LayoutTemplate = (typeof LAYOUT_TEMPLATES)[number];
|
||||||
@@ -39,31 +37,11 @@ export const TEXT_OFFSET_MIN = -120;
|
|||||||
export const TEXT_OFFSET_MAX = 120;
|
export const TEXT_OFFSET_MAX = 120;
|
||||||
export const TEXT_OFFSET_DEFAULT = 0;
|
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 ARTIST_MAX = 80;
|
||||||
export const SONG_TITLE_MAX = 120;
|
|
||||||
|
|
||||||
export type LayoutSettings = {
|
export type LayoutSettings = {
|
||||||
/** When null, classic letterbox (no art-track layout). */
|
/** When null, classic letterbox (no art-track layout / blur fill). */
|
||||||
template: LayoutTemplate | null;
|
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;
|
|
||||||
/** 0–100 → FFmpeg boxblur intensity. */
|
/** 0–100 → FFmpeg boxblur intensity. */
|
||||||
blurAmount: number;
|
blurAmount: number;
|
||||||
/** 0–100 → how visible the blurred fill is (vs black). */
|
/** 0–100 → how visible the blurred fill is (vs black). */
|
||||||
@@ -76,20 +54,16 @@ export type LayoutSettings = {
|
|||||||
textOffsetX: number;
|
textOffsetX: number;
|
||||||
/** Shift text block vertically within the template. */
|
/** Shift text block vertically within the template. */
|
||||||
textOffsetY: number;
|
textOffsetY: number;
|
||||||
/** Bold weight for the on-video song title (artist stays regular). */
|
|
||||||
titleBold: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_LAYOUT: LayoutSettings = {
|
export const DEFAULT_LAYOUT: LayoutSettings = {
|
||||||
template: null,
|
template: null,
|
||||||
blurFill: false,
|
|
||||||
blurAmount: BLUR_AMOUNT_DEFAULT,
|
blurAmount: BLUR_AMOUNT_DEFAULT,
|
||||||
blurOpacity: BLUR_OPACITY_DEFAULT,
|
blurOpacity: BLUR_OPACITY_DEFAULT,
|
||||||
textPadding: TEXT_PADDING_DEFAULT,
|
textPadding: TEXT_PADDING_DEFAULT,
|
||||||
titleArtistGap: TITLE_ARTIST_GAP_DEFAULT,
|
titleArtistGap: TITLE_ARTIST_GAP_DEFAULT,
|
||||||
textOffsetX: TEXT_OFFSET_DEFAULT,
|
textOffsetX: TEXT_OFFSET_DEFAULT,
|
||||||
textOffsetY: TEXT_OFFSET_DEFAULT,
|
textOffsetY: TEXT_OFFSET_DEFAULT,
|
||||||
titleBold: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isLayoutTemplate(v: unknown): v is LayoutTemplate {
|
export function isLayoutTemplate(v: unknown): v is LayoutTemplate {
|
||||||
@@ -144,8 +118,6 @@ type RawLayoutInput = {
|
|||||||
template?: unknown;
|
template?: unknown;
|
||||||
layoutTemplate?: unknown;
|
layoutTemplate?: unknown;
|
||||||
layout_template?: unknown;
|
layout_template?: unknown;
|
||||||
blurFill?: unknown;
|
|
||||||
blur_fill?: unknown;
|
|
||||||
blurAmount?: unknown;
|
blurAmount?: unknown;
|
||||||
blur_amount?: unknown;
|
blur_amount?: unknown;
|
||||||
blurOpacity?: unknown;
|
blurOpacity?: unknown;
|
||||||
@@ -158,8 +130,6 @@ type RawLayoutInput = {
|
|||||||
text_offset_x?: unknown;
|
text_offset_x?: unknown;
|
||||||
textOffsetY?: unknown;
|
textOffsetY?: unknown;
|
||||||
text_offset_y?: unknown;
|
text_offset_y?: unknown;
|
||||||
titleBold?: unknown;
|
|
||||||
title_bold?: unknown;
|
|
||||||
/** Rejected clients must not send free-form cover coordinates. */
|
/** Rejected clients must not send free-form cover coordinates. */
|
||||||
x?: unknown;
|
x?: unknown;
|
||||||
y?: unknown;
|
y?: unknown;
|
||||||
@@ -218,25 +188,15 @@ export function normalizeLayoutSettings(
|
|||||||
(input as RawLayoutInput).textOffsetY ??
|
(input as RawLayoutInput).textOffsetY ??
|
||||||
(input as RawLayoutInput).text_offset_y ??
|
(input as RawLayoutInput).text_offset_y ??
|
||||||
(input as LayoutSettings).textOffsetY;
|
(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 {
|
return {
|
||||||
template,
|
template,
|
||||||
blurFill: template !== null ? false : Boolean(blurFillRaw),
|
|
||||||
blurAmount: clampBlurAmount(blurRaw, BLUR_AMOUNT_DEFAULT),
|
blurAmount: clampBlurAmount(blurRaw, BLUR_AMOUNT_DEFAULT),
|
||||||
blurOpacity: clampBlurOpacity(opacityRaw, BLUR_OPACITY_DEFAULT),
|
blurOpacity: clampBlurOpacity(opacityRaw, BLUR_OPACITY_DEFAULT),
|
||||||
textPadding: clampTextPadding(padRaw, TEXT_PADDING_DEFAULT),
|
textPadding: clampTextPadding(padRaw, TEXT_PADDING_DEFAULT),
|
||||||
titleArtistGap: clampTitleArtistGap(gapRaw, TITLE_ARTIST_GAP_DEFAULT),
|
titleArtistGap: clampTitleArtistGap(gapRaw, TITLE_ARTIST_GAP_DEFAULT),
|
||||||
textOffsetX: clampTextOffset(oxRaw, TEXT_OFFSET_DEFAULT),
|
textOffsetX: clampTextOffset(oxRaw, TEXT_OFFSET_DEFAULT),
|
||||||
textOffsetY: clampTextOffset(oyRaw, TEXT_OFFSET_DEFAULT),
|
textOffsetY: clampTextOffset(oyRaw, TEXT_OFFSET_DEFAULT),
|
||||||
titleBold: boldRaw === undefined || boldRaw === null ? true : Boolean(boldRaw),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,54 +341,6 @@ export function computeLayoutGeometry(
|
|||||||
};
|
};
|
||||||
break;
|
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: {
|
default: {
|
||||||
throw new Error(INVALID_LAYOUT_TEMPLATE_MESSAGE);
|
throw new Error(INVALID_LAYOUT_TEMPLATE_MESSAGE);
|
||||||
}
|
}
|
||||||
@@ -437,27 +349,13 @@ export function computeLayoutGeometry(
|
|||||||
return applyTextOffsets(base, textOffsetX, textOffsetY);
|
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: {
|
export function buildArtTrackFilterComplex(opts: {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
layout: LayoutSettings;
|
layout: LayoutSettings;
|
||||||
titleEscaped: string;
|
titleEscaped: string;
|
||||||
artistEscaped: string | null;
|
artistEscaped: string | null;
|
||||||
/** Regular-weight font for artist (and title when not bold / no bold file). */
|
|
||||||
fontfileEscaped?: string | null;
|
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 {
|
}): string {
|
||||||
const { width: W, height: H, layout } = opts;
|
const { width: W, height: H, layout } = opts;
|
||||||
if (!layout.template) {
|
if (!layout.template) {
|
||||||
@@ -479,84 +377,15 @@ export function buildArtTrackFilterComplex(opts: {
|
|||||||
: `scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H}`;
|
: `scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H}`;
|
||||||
|
|
||||||
const opacity = clampBlurOpacity(layout.blurOpacity, BLUR_OPACITY_DEFAULT) / 100;
|
const opacity = clampBlurOpacity(layout.blurOpacity, BLUR_OPACITY_DEFAULT) / 100;
|
||||||
const artistFontPart = opts.fontfileEscaped ? `:fontfile='${opts.fontfileEscaped}'` : "";
|
const fontPart = opts.fontfileEscaped ? `:fontfile='${opts.fontfileEscaped}'` : "";
|
||||||
const titleUsesBoldFace = Boolean(layout.titleBold && opts.titleFontfileEscaped);
|
const titleDraw = `drawtext=text='${opts.titleEscaped}'${fontPart}:fontsize=${geo.titleFontSize}:fontcolor=white@0.95:x=${geo.titleX}:y=${geo.titleY}`;
|
||||||
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
|
const artistDraw = opts.artistEscaped
|
||||||
? `,drawtext=text='${opts.artistEscaped}'${artistFontPart}:fontsize=${geo.artistFontSize}:fontcolor=white@0.75:x=${geo.artistX}:y=${geo.artistY}`
|
? `,drawtext=text='${opts.artistEscaped}'${fontPart}:fontsize=${geo.artistFontSize}:fontcolor=white@0.75:x=${geo.artistX}:y=${geo.artistY}`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const separateBgIdx =
|
const parts: string[] = [`[0:v]split=2[bg][fg]`];
|
||||||
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%
|
// 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) {
|
if (opacity >= 0.999) {
|
||||||
parts.push(`[bg]${bgChain}[blurred]`);
|
parts.push(`[bg]${bgChain}[blurred]`);
|
||||||
} else if (opacity <= 0.001) {
|
} else if (opacity <= 0.001) {
|
||||||
@@ -571,7 +400,12 @@ export function buildClassicBlurFillFilterComplex(opts: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
parts.push(`[blurred][cover]overlay=(W-w)/2:(H-h)/2[laid]`);
|
parts.push(
|
||||||
|
`[fg]scale=${geo.coverMaxW}:${geo.coverMaxH}:force_original_aspect_ratio=decrease[cover]`,
|
||||||
|
`[blurred][cover]overlay=${geo.coverX}:${geo.coverY}[composed]`,
|
||||||
|
`[composed]${titleDraw}${artistDraw}[laid]`,
|
||||||
|
);
|
||||||
|
|
||||||
return parts.join(";");
|
return parts.join(";");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,74 +414,4 @@ export const LAYOUT_TEMPLATE_LABELS: Record<LayoutTemplate, string> = {
|
|||||||
COVER_TOP_TEXT_BOTTOM: "Cover top · text bottom",
|
COVER_TOP_TEXT_BOTTOM: "Cover top · text bottom",
|
||||||
COVER_RIGHT_TEXT_LEFT: "Cover right · text left",
|
COVER_RIGHT_TEXT_LEFT: "Cover right · text left",
|
||||||
CENTERED_COMPACT: "Centered compact",
|
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]!;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { SUPPORT_EMAIL } from "@/lib/plans";
|
import { SUPPORT_EMAIL, SALES_EMAIL } from "@/lib/plans";
|
||||||
import { BRAND_DOMAIN, BRAND_NAME } from "@/lib/branding";
|
|
||||||
|
|
||||||
export const LEGAL_LAST_UPDATED = "August 3, 2026";
|
export const LEGAL_LAST_UPDATED = "July 13, 2026";
|
||||||
|
|
||||||
export const LEGAL_OPERATOR = {
|
export const LEGAL_OPERATOR = {
|
||||||
name: BRAND_NAME,
|
name: "Songs2YT",
|
||||||
legalName: "Atakan Doğan Özban",
|
legalName: "Atakan Doğan Özban",
|
||||||
address: "Universitas u. 2/A",
|
address: "Universitas u. 2/A",
|
||||||
city: "7622 Pécs, Hungary",
|
city: "7622 Pécs, Hungary",
|
||||||
email: SUPPORT_EMAIL,
|
email: SUPPORT_EMAIL,
|
||||||
website: `https://www.${BRAND_DOMAIN}`,
|
salesEmail: SALES_EMAIL,
|
||||||
|
website: "https://www.songs2yt.com",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||