Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
925aadae75 | ||
|
|
935abfb22e |
@@ -1,16 +1,19 @@
|
||||
DATABASE_URL="postgresql://s2yt:s2yt@localhost:5432/s2yt"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
# Songs2VID OSS is always self-hosted and has no billing configuration.
|
||||
DATABASE_URL="postgresql://songs2vid:songs2vid@localhost:5433/songs2vid"
|
||||
REDIS_URL="redis://localhost:6380"
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="replace-with-a-long-random-secret"
|
||||
# Optional; falls back to NEXTAUTH_SECRET.
|
||||
# TOKEN_ENCRYPTION_KEY="replace-with-another-long-random-secret"
|
||||
|
||||
GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com"
|
||||
GOOGLE_CLIENT_SECRET="your-google-client-secret"
|
||||
UPLOAD_DIR="./uploads"
|
||||
# Self-hosted OSS edition (unlocked features, no Stripe / credits)
|
||||
S2VID_EDITION="selfhosted"
|
||||
# Optional legacy alias (also accepted by lib/edition.ts):
|
||||
# S2YT_EDITION="selfhosted"
|
||||
# Public docs (default: https://docs.songs2vid.com)
|
||||
# NEXT_PUBLIC_DOCS_URL="https://docs.songs2vid.com"
|
||||
|
||||
NEXT_PUBLIC_DOCS_URL="https://docs.songs2vid.com"
|
||||
NEXT_PUBLIC_GITEA_ISSUES_URL="https://git.atakanozban.com/Songs2VID/songs2vid/issues"
|
||||
NEXT_PUBLIC_GITEA_URL="https://git.atakanozban.com/Songs2VID/songs2vid"
|
||||
NEXT_PUBLIC_GITEA_URL="https://git.atakanozban.com/Songs2VID"
|
||||
NEXT_PUBLIC_DOCKER_HUB_URL="https://hub.docker.com/r/atakanozban/songs2vid"
|
||||
|
||||
# Optional: override the bundled ffmpeg-static binary.
|
||||
# FFMPEG_PATH="C:/path/to/ffmpeg.exe"
|
||||
|
||||
@@ -6,9 +6,3 @@ node_modules/
|
||||
website/node_modules/
|
||||
website/build/
|
||||
website/.docusaurus/
|
||||
*.tgz
|
||||
.DS_Store
|
||||
basibozuk_cover.jpg
|
||||
bg-video/
|
||||
_restore/
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Songs2VID OSS self-hosted image.
|
||||
|
||||
FROM node:22-bookworm-slim AS deps
|
||||
WORKDIR /app
|
||||
@@ -6,7 +7,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-cert
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY package.json package-lock.json ./
|
||||
COPY prisma ./prisma
|
||||
# Use npm install (not ci): lockfiles generated on Windows can omit Linux-only optional packages (@emnapi/*).
|
||||
RUN npm install
|
||||
|
||||
FROM node:22-bookworm-slim AS builder
|
||||
@@ -22,31 +22,37 @@ FROM node:22-bookworm-slim AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV S2VID_EDITION=selfhosted
|
||||
ENV UPLOAD_DIR=/app/uploads
|
||||
ENV FFMPEG_PATH=ffmpeg
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV HOME=/home/nextjs
|
||||
ENV npm_config_cache=/tmp/npm-cache
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
openssl ca-certificates ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& groupadd --system --gid 1001 nodejs \
|
||||
&& useradd --system --uid 1001 --gid nodejs nextjs
|
||||
&& useradd --system --uid 1001 --gid nodejs --create-home --home-dir /home/nextjs nextjs \
|
||||
&& mkdir -p /app/uploads \
|
||||
&& chown nextjs:nodejs /app /app/uploads
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/package-lock.json ./package-lock.json
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/assets ./assets
|
||||
COPY --from=builder /app/worker ./worker
|
||||
COPY --from=builder /app/lib ./lib
|
||||
COPY --from=builder /app/tsconfig.json ./tsconfig.json
|
||||
|
||||
RUN mkdir -p /app/uploads \
|
||||
&& chown -R nextjs:nodejs /app
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
COPY --chown=nextjs:nodejs package.json package-lock.json ./
|
||||
COPY --chown=nextjs:nodejs prisma ./prisma
|
||||
RUN npm install --omit=dev \
|
||||
&& npx prisma generate \
|
||||
&& rm -rf /tmp/npm-cache
|
||||
|
||||
COPY --chown=nextjs:nodejs --from=builder /app/.next/standalone/server.js ./server.js
|
||||
COPY --chown=nextjs:nodejs --from=builder /app/.next/standalone/.next ./.next
|
||||
COPY --chown=nextjs:nodejs --from=builder /app/.next/static ./.next/static
|
||||
COPY --chown=nextjs:nodejs --from=builder /app/public ./public
|
||||
COPY --chown=nextjs:nodejs --from=builder /app/assets ./assets
|
||||
COPY --chown=nextjs:nodejs --from=builder /app/worker ./worker
|
||||
COPY --chown=nextjs:nodejs --from=builder /app/lib ./lib
|
||||
COPY --chown=nextjs:nodejs --from=builder /app/tsconfig.json ./tsconfig.json
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Songs2YT
|
||||
|
||||
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,86 +1,42 @@
|
||||
# 🎵 Songs2VID
|
||||
# Songs2VID OSS
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Status-100%25%20Operational-brightgreen?style=for-the-badge" alt="Status">
|
||||
<img src="https://img.shields.io/badge/Docker-Supported-blue?style=for-the-badge&logo=docker" alt="Docker">
|
||||
<img src="https://img.shields.io/badge/Edition-Self--hosted-orange?style=for-the-badge" alt="Self-hosted">
|
||||
<img src="https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge" alt="License">
|
||||
</p>
|
||||
Payment-free, self-hosted software for creating videos from cover art and audio, then uploading them
|
||||
to YouTube. All features are available in every deployment; there are no plans, credits, purchases,
|
||||
subscriptions, or Stripe integration.
|
||||
|
||||
<p align="center">
|
||||
<b>Turn your cover art + audio files into YouTube videos automatically.</b><br>
|
||||
<i>Art-track layouts, blur backgrounds, typography, watermark studio, and per-track covers — unlocked for self-hosting.</i>
|
||||
</p>
|
||||
## Quick start with Docker
|
||||
|
||||
---
|
||||
|
||||
## What's the deal?
|
||||
|
||||
**Songs2VID** (npm package `songs2yt`) is a self-hosted automation app that takes your audio files (MP3/WAV/FLAC) and cover images, encodes them with FFmpeg in a background queue, and uploads them to your YouTube channel.
|
||||
|
||||
This is the **open-source (self-hosted) edition**:
|
||||
* **Zero practical limits:** no video quotas, credits, or Stripe paywalls
|
||||
* **Fully unlocked:** playlists, REST API, art-track layouts, custom watermarks/fonts, per-track covers (`S2VID_EDITION=selfhosted`)
|
||||
* **Your infra:** run it on your own server
|
||||
|
||||
> Hosted cloud product: [songs2vid.com](https://songs2vid.com) · Docs: [docs.songs2vid.com](https://docs.songs2vid.com) · API: [docs.songs2vid.com/docs/api/overview](https://docs.songs2vid.com/docs/api/overview)
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
* **Docker & Docker Compose** (recommended)
|
||||
* *Or:* Node 20+, Postgres, Redis, FFmpeg
|
||||
* Google Cloud OAuth client with **YouTube Data API v3** enabled
|
||||
|
||||
---
|
||||
|
||||
## Quick Start (Docker)
|
||||
1. Copy `.env.example` to `.env` and set `NEXTAUTH_SECRET`, `GOOGLE_CLIENT_ID`, and
|
||||
`GOOGLE_CLIENT_SECRET`.
|
||||
2. In Google Cloud Console, enable YouTube Data API v3 and add
|
||||
`http://localhost:3000/api/auth/callback/google` as an OAuth redirect URI.
|
||||
3. Start the stack:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Fill in GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, NEXTAUTH_SECRET, and NEXTAUTH_URL
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
* Open `http://localhost:3000`
|
||||
* OAuth redirect URL: `{NEXTAUTH_URL}/api/auth/callback/google`
|
||||
* `S2VID_EDITION=selfhosted` is set by compose (no Stripe keys required)
|
||||
Open http://localhost:3000.
|
||||
|
||||
## Local Development
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
npm install
|
||||
npm run db:push
|
||||
npm run dev # Terminal 1
|
||||
npm run worker # Terminal 2
|
||||
npm run db:migrate
|
||||
npm run dev:all
|
||||
```
|
||||
|
||||
Optional: fetch curated watermark fonts into `assets/fonts/` with `node scripts/fetch-watermark-fonts.mjs`.
|
||||
|
||||
## Features
|
||||
|
||||
* Batch audio → YouTube video jobs
|
||||
* Art-track layout templates + blur background + fine-tuning offsets
|
||||
* Watermark studio (text / logo / curated + custom fonts)
|
||||
* Per-track cover images and artist field
|
||||
* YouTube playlists + REST API (`/api/v1`)
|
||||
The web app runs on http://localhost:3000. `dev:all` starts both the Next.js app and BullMQ worker.
|
||||
|
||||
## Stack
|
||||
|
||||
* Next.js · Postgres · Redis / BullMQ · NextAuth (Google) · FFmpeg · YouTube Data API
|
||||
- Next.js 15, TypeScript, Tailwind
|
||||
- PostgreSQL and Prisma
|
||||
- Redis and BullMQ
|
||||
- NextAuth with Google OAuth
|
||||
- FFmpeg
|
||||
- YouTube Data API v3
|
||||
|
||||
## Links
|
||||
|
||||
* Docs: https://docs.songs2vid.com
|
||||
* Hosted: https://songs2vid.com
|
||||
* Gitea: https://git.atakanozban.com/Songs2VID/songs2vid
|
||||
* Docker Hub: https://hub.docker.com/r/atakanozban/songs2vid
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Documentation, including the REST API reference, is at
|
||||
[docs.songs2vid.com](https://docs.songs2vid.com).
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createUserApiKey, getUserApiKeyStatus, revokeUserApiKey } from "@/lib/api-keys";
|
||||
import { hasProFeatures } from "@/lib/edition";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
async function requireApiKeyAccess() {
|
||||
@@ -8,15 +7,6 @@ async function requireApiKeyAccess() {
|
||||
if (!user) {
|
||||
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null };
|
||||
}
|
||||
if (!hasProFeatures(user.plan)) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: "API key access is not available" },
|
||||
{ status: 403 },
|
||||
),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
return { error: null, user };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
|
||||
import { hasProFeatures } from "@/lib/edition";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
export async function GET() {
|
||||
@@ -8,13 +7,6 @@ export async function GET() {
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!hasProFeatures(user.plan)) {
|
||||
return NextResponse.json(
|
||||
{ error: "API rate limits are available on the Pro plan only" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const status = await getApiRateLimitStatus(user.id);
|
||||
return NextResponse.json(status);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import fs from "fs/promises";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getWatermarkPath } from "@/lib/storage";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const buf = await fs.readFile(getWatermarkPath());
|
||||
return new NextResponse(buf, {
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=86400, immutable",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Watermark asset not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs/promises";
|
||||
import { CURATED_FONTS, isCuratedFontKey } from "@/lib/fonts";
|
||||
import { resolveCuratedFontPath } from "@/lib/fonts-server";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ key: string }> },
|
||||
) {
|
||||
const { key } = await params;
|
||||
if (!isCuratedFontKey(key)) {
|
||||
return NextResponse.json({ error: "Unknown font" }, { status: 404 });
|
||||
}
|
||||
|
||||
const fontPath = resolveCuratedFontPath(key);
|
||||
try {
|
||||
const buf = await fs.readFile(fontPath);
|
||||
const meta = CURATED_FONTS.find((f) => f.key === key)!;
|
||||
return new NextResponse(buf, {
|
||||
headers: {
|
||||
"Content-Type": "font/ttf",
|
||||
"Content-Disposition": `inline; filename="${meta.file}"`,
|
||||
"Cache-Control": "public, max-age=86400, immutable",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Font file not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,7 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ jobId: job.id });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to create job";
|
||||
const status = message.includes("Quota exceeded") ? 403 : 400;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
|
||||
import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
@@ -28,14 +27,11 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan, {
|
||||
const result = await saveUploadedFile(user.id, file, type as UploadFileType, {
|
||||
sessionKey,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
if (err instanceof PremiumRequiredError) {
|
||||
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "Upload failed";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { requireApiUser } from "@/lib/api-auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { error, user } = await requirePaidApiUser(_req);
|
||||
const { error, user } = await requireApiUser(_req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { Privacy } from "@prisma/client";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { requireApiUser } from "@/lib/api-auth";
|
||||
import { createVideoJob, saveUploadedFile } from "@/lib/jobs/create-job";
|
||||
import {
|
||||
applyCreatePlaylistToItems,
|
||||
@@ -22,6 +22,7 @@ type BatchItemInput = Partial<ItemMetadata> & {
|
||||
function defaultMetadata(title: string): ItemMetadata {
|
||||
return {
|
||||
title,
|
||||
songTitle: null,
|
||||
description: "",
|
||||
tags: "",
|
||||
privacy: "PUBLIC",
|
||||
@@ -33,12 +34,13 @@ function defaultMetadata(title: string): ItemMetadata {
|
||||
creativeCommons: false,
|
||||
includeWatermark: false,
|
||||
playlistId: null,
|
||||
artist: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
const { error, user } = await requireApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const formData = await req.formData();
|
||||
@@ -53,10 +55,10 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const limits = getPlanLimits(user.plan);
|
||||
const limits = getPlanLimits();
|
||||
if (audioFiles.length > limits.maxBatchSize) {
|
||||
return NextResponse.json(
|
||||
{ error: `Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.` },
|
||||
{ error: `Batch limit exceeded. Max ${limits.maxBatchSize} files per batch.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
@@ -95,10 +97,10 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const sessionKey = Date.now().toString();
|
||||
const imageUpload = await saveUploadedFile(user.id, image, "image", user.plan, { sessionKey });
|
||||
const imageUpload = await saveUploadedFile(user.id, image, "image", { sessionKey });
|
||||
|
||||
const uploads = await mapWithConcurrency(audioFiles, 4, (audio) =>
|
||||
saveUploadedFile(user.id, audio, "audio", user.plan, { sessionKey }),
|
||||
saveUploadedFile(user.id, audio, "audio", { sessionKey }),
|
||||
);
|
||||
|
||||
const builtItems: CreateJobPayload["items"] = uploads.map((upload, i) => {
|
||||
@@ -140,9 +142,7 @@ export async function POST(req: NextRequest) {
|
||||
} catch (err) {
|
||||
console.error("Batch upload failed:", err);
|
||||
const message = err instanceof Error ? err.message : "Batch upload failed";
|
||||
const status = message.includes("Quota exceeded") || message.includes("Batch limit exceeded")
|
||||
? 403
|
||||
: 500;
|
||||
const status = message.includes("Batch limit exceeded") ? 400 : 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { requireApiUser } from "@/lib/api-auth";
|
||||
import { createVideoJob } from "@/lib/jobs/create-job";
|
||||
import {
|
||||
applyCreatePlaylistToItems,
|
||||
@@ -9,7 +9,7 @@ import { prisma } from "@/lib/db";
|
||||
import type { CreateJobPayload } from "@/lib/types";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
const { error, user } = await requireApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const body = (await req.json()) as CreateJobPayload & { createPlaylist?: unknown };
|
||||
@@ -47,13 +47,12 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to create job";
|
||||
const status = message.includes("Quota exceeded") ? 403 : 400;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
const { error, user } = await requireApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { requireApiUser } from "@/lib/api-auth";
|
||||
import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist";
|
||||
import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
const { error, user } = await requireApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
try {
|
||||
@@ -18,7 +18,7 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
const { error, user } = await requireApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
try {
|
||||
|
||||
@@ -6,8 +6,8 @@ export async function GET() {
|
||||
name: "Songs2VID API",
|
||||
version: "1.0",
|
||||
authentication: "Authorization: Bearer <api_key>",
|
||||
requirements: ["Self-hosted edition", "YouTube account connected"],
|
||||
rateLimit: "Generous self-hosted limits (see docs)",
|
||||
requirements: ["YouTube account connected"],
|
||||
rateLimit: "100,000 requests per minute per account",
|
||||
guidance: {
|
||||
recommended:
|
||||
"For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/jobs with the returned paths",
|
||||
@@ -18,14 +18,16 @@ export async function GET() {
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/upload",
|
||||
description: "Upload a single image or audio file (use for two-step flow)",
|
||||
body: "multipart/form-data: file, type (image|audio)",
|
||||
description:
|
||||
"Upload image, audio, PNG logo, or .ttf/.otf font (font ≤10MB)",
|
||||
body: "multipart/form-data: file, type (image|audio|logo|font)",
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/jobs",
|
||||
description: "Create a video job from uploaded file paths (recommended for large batches)",
|
||||
body: "application/json: { imagePath, items[] }",
|
||||
description:
|
||||
"Create a video job from uploaded file paths with layouts, watermark, and per-item covers",
|
||||
body: "application/json: { imagePath, items[{ audioPath, audioFilename, metadata }] }",
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
|
||||
import { requireApiUser } from "@/lib/api-auth";
|
||||
import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job";
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
const { error, user } = await requireApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const formData = await req.formData();
|
||||
@@ -25,12 +24,9 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan);
|
||||
const result = await saveUploadedFile(user.id, file, type as UploadFileType);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
if (err instanceof PremiumRequiredError) {
|
||||
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "Upload failed";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { hasProFeatures } from "@/lib/edition";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist";
|
||||
import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload";
|
||||
@@ -9,15 +8,6 @@ async function requirePremiumYouTubeUser() {
|
||||
if (!user) {
|
||||
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null };
|
||||
}
|
||||
if (!hasProFeatures(user.plan)) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: "Playlist features are available on the Pro plan only" },
|
||||
{ status: 403 },
|
||||
),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
if (!user.youtubeConnection) {
|
||||
return {
|
||||
error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }),
|
||||
|
||||
@@ -8,7 +8,6 @@ import { DashboardShell } from "@/components/DashboardShell";
|
||||
import { getUserApiKeyStatus } from "@/lib/api-keys";
|
||||
import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
|
||||
import { ApiKeySettings } from "@/components/ApiKeySettings";
|
||||
import { getQuotaInfo } from "@/lib/quota";
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
@@ -29,9 +28,10 @@ export default async function SettingsPage() {
|
||||
});
|
||||
if (!user) redirect("/");
|
||||
|
||||
const quota = await getQuotaInfo(user.id);
|
||||
const apiKeyStatus = await getUserApiKeyStatus(user.id);
|
||||
const apiRateLimit = await getApiRateLimitStatus(user.id);
|
||||
const [apiKeyStatus, apiRateLimit] = await Promise.all([
|
||||
getUserApiKeyStatus(user.id),
|
||||
getApiRateLimitStatus(user.id),
|
||||
]);
|
||||
const youtube = user.youtubeConnection;
|
||||
const channelUrl = youtube ? `https://www.youtube.com/channel/${youtube.channelId}` : null;
|
||||
|
||||
@@ -39,80 +39,51 @@ export default async function SettingsPage() {
|
||||
<DashboardShell channelTitle={youtube?.channelTitle}>
|
||||
<div className="mx-auto max-w-4xl px-6 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-white">Settings</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">Account information</h2>
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">Account</h2>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<InfoRow label="Name" value={user.name || "Not set"} />
|
||||
<InfoRow label="Email" value={user.email} />
|
||||
</dl>
|
||||
|
||||
<div className="mt-6 border-t border-gray-800 pt-6">
|
||||
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-500">
|
||||
YouTube
|
||||
</h3>
|
||||
{youtube ? (
|
||||
<dl className="space-y-3 text-sm">
|
||||
<InfoRow label="Channel name" value={youtube.channelTitle} />
|
||||
<InfoRow label="Channel ID" value={youtube.channelId} />
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-sm text-red-400">YouTube account not connected.</p>
|
||||
)}
|
||||
|
||||
{channelUrl && (
|
||||
<a
|
||||
href={channelUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-4 inline-flex rounded bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-hover"
|
||||
>
|
||||
Go to your channel
|
||||
</a>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-sm text-gray-400">
|
||||
To refresh your YouTube permissions,{" "}
|
||||
<Link href="/" className="text-accent hover:underline">
|
||||
sign out and sign in again
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">Usage</h2>
|
||||
|
||||
<div className="mb-6 rounded border border-gray-800 bg-surface p-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Videos processed
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-white">{quota.used}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Self-hosted edition: no video credits or quotas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="space-y-3 text-sm">
|
||||
<InfoRow label="Edition" value="Self-hosted" />
|
||||
<InfoRow label="Max batch size" value={`${quota.maxBatchSize} videos`} />
|
||||
<InfoRow label="Max resolution" value={`${quota.maxResolutionHeight}p`} />
|
||||
<InfoRow label="Videos created" value={String(user.createdVideoCount)} />
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">API access</h2>
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">YouTube</h2>
|
||||
{youtube ? (
|
||||
<dl className="space-y-3 text-sm">
|
||||
<InfoRow label="Channel name" value={youtube.channelTitle} />
|
||||
<InfoRow label="Channel ID" value={youtube.channelId} />
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-sm text-red-400">YouTube account not connected.</p>
|
||||
)}
|
||||
{channelUrl && (
|
||||
<a
|
||||
href={channelUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-4 inline-flex rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
|
||||
>
|
||||
Go to your channel
|
||||
</a>
|
||||
)}
|
||||
<p className="mt-4 text-sm text-gray-400">
|
||||
To refresh YouTube permissions,{" "}
|
||||
<Link href="/" className="text-accent hover:underline">sign out and sign in again</Link>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">API key</h2>
|
||||
<ApiKeySettings initialStatus={apiKeyStatus} initialRateLimit={apiRateLimit} />
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
||||
<h2 className="mb-2 text-lg font-semibold text-white">Account deletion & data</h2>
|
||||
<p className="mb-4 text-sm text-gray-400">
|
||||
Request a copy of your data or permanently delete your account and associated uploads.
|
||||
Request a copy of your data or permanently delete your account and uploads.
|
||||
</p>
|
||||
<AccountPrivacyActions email={user.email} />
|
||||
</section>
|
||||
|
||||
@@ -116,6 +116,68 @@
|
||||
.mobile-sidebar-link-danger:hover::before {
|
||||
@apply bg-red-400;
|
||||
}
|
||||
|
||||
.badge-marquee {
|
||||
@apply relative w-full overflow-hidden;
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black 8%,
|
||||
black 92%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.badge-marquee-track {
|
||||
@apply flex w-max items-center gap-10;
|
||||
animation: badge-marquee-scroll var(--badge-marquee-duration, 28s) linear infinite;
|
||||
}
|
||||
|
||||
.badge-marquee:hover .badge-marquee-track {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.badge-marquee-track {
|
||||
animation: none;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.badge-marquee-item[aria-hidden="true"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-marquee-item {
|
||||
@apply flex shrink-0 items-center justify-center;
|
||||
}
|
||||
|
||||
.badge-marquee-slot {
|
||||
@apply flex h-14 max-h-14 items-center justify-center;
|
||||
}
|
||||
|
||||
.badge-marquee-slot a,
|
||||
.badge-marquee-link {
|
||||
@apply m-0 inline-flex items-center p-0 leading-none no-underline;
|
||||
}
|
||||
|
||||
.badge-marquee-slot img,
|
||||
.badge-marquee-slot iframe,
|
||||
.badge-marquee-slot svg,
|
||||
.badge-marquee-slot object {
|
||||
display: block !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
max-height: 3.5rem !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
max-width: min(280px, 70vw) !important;
|
||||
object-fit: contain;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mockup-float {
|
||||
@@ -246,3 +308,12 @@
|
||||
transform: scale(0.6) rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes badge-marquee-scroll {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 374 KiB After Width: | Height: | Size: 14 KiB |
@@ -5,10 +5,18 @@ import { Providers } from "./providers";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
const SITE_NAME = "Songs2VID";
|
||||
const DEFAULT_TITLE = "Songs2VID";
|
||||
const DEFAULT_DESCRIPTION =
|
||||
"Self-hosted audio-to-video creation and YouTube uploading.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Songs2YT",
|
||||
description:
|
||||
"Songs2YT is an automation tool that converts your audio and image files into high-quality videos and uploads them directly to YouTube.",
|
||||
title: DEFAULT_TITLE,
|
||||
description: DEFAULT_DESCRIPTION,
|
||||
applicationName: SITE_NAME,
|
||||
icons: {
|
||||
icon: "/favicon.png",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -17,8 +25,8 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={inter.className} suppressHydrationWarning>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,106 +1,27 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { BenefitsSection } from "@/components/BenefitsSection";
|
||||
import { DownloadSection } from "@/components/DownloadSection";
|
||||
import { LandingNavbar } from "@/components/LandingNavbar";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { SignInButton } from "@/components/SignInButton";
|
||||
import { StepsSection } from "@/components/StepsSection";
|
||||
import { Footer } from "@/components/Footer";
|
||||
import { SupportSection } from "@/components/SupportSection";
|
||||
import { DOCS_URL, WEBSITE_URL } from "@/lib/plans";
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (session) redirect("/dashboard");
|
||||
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<section className="relative min-h-dvh overflow-hidden">
|
||||
<video
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<source src="/bg-video.mp4" type="video/mp4" />
|
||||
</video>
|
||||
<div className="absolute inset-0 bg-black/55" aria-hidden="true" />
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/40 to-black/80"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<LandingNavbar />
|
||||
|
||||
<div className="relative z-10 mx-auto flex min-h-dvh max-w-3xl flex-col justify-center px-6 pb-16 pt-28 text-center">
|
||||
<h1 className="text-5xl font-bold tracking-tight text-white sm:text-6xl lg:text-7xl">
|
||||
Songs2VID
|
||||
</h1>
|
||||
<p className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-gray-200 sm:text-lg md:text-xl">
|
||||
Self-hosted automation that turns your audio and cover art into YouTube videos —
|
||||
including art-track layouts, typography, and watermark studio. No quotas or paywalls.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-col items-center gap-3">
|
||||
{session ? (
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-flex items-center gap-2 rounded bg-accent px-8 py-3.5 font-medium text-white transition-all duration-300 hover:scale-[1.02] hover:bg-accent-hover hover:shadow-lg hover:shadow-accent/20"
|
||||
>
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<SignInButton large />
|
||||
<p className="max-w-md text-xs leading-relaxed text-gray-400">
|
||||
By clicking Continue with Google, you accept our{" "}
|
||||
<Link
|
||||
href="/terms"
|
||||
className="text-gray-300 underline-offset-2 transition-colors hover:text-white hover:underline"
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="text-gray-300 underline-offset-2 transition-colors hover:text-white hover:underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<p className="mt-4 text-sm text-gray-400">
|
||||
<a
|
||||
href={`${DOCS_URL}/docs/intro`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
{" · "}
|
||||
<a
|
||||
href={WEBSITE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Hosted product
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<main className="flex min-h-dvh items-center justify-center bg-surface px-6">
|
||||
<div className="w-full max-w-sm rounded-xl border border-gray-700 bg-surface-light p-8 text-center shadow-xl">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<Logo size="lg" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<StepsSection />
|
||||
<BenefitsSection />
|
||||
<DownloadSection />
|
||||
<SupportSection />
|
||||
<Footer />
|
||||
<SignInButton large />
|
||||
<p className="mt-5 text-xs leading-relaxed text-gray-500">
|
||||
By continuing, you accept the{" "}
|
||||
<Link href="/terms" className="hover:text-gray-300">Terms</Link> and{" "}
|
||||
<Link href="/privacy" className="hover:text-gray-300">Privacy Policy</Link>.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,189 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalPageLayout } from "@/components/LegalPageLayout";
|
||||
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Privacy Policy | Songs2YT",
|
||||
description: "How Songs2YT collects, uses, and protects your personal data.",
|
||||
};
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<LegalPageLayout
|
||||
title="Privacy Policy"
|
||||
description="How we handle your personal data when you use Songs2YT."
|
||||
>
|
||||
<h2>1. Overview</h2>
|
||||
<LegalPageLayout title="Privacy Policy">
|
||||
<p>
|
||||
This Privacy Policy explains how {LEGAL_OPERATOR.name} ("we", "us")
|
||||
processes personal data when you use our website, hosted cloud service, and related features
|
||||
that convert audio and images into videos for upload to YouTube.
|
||||
Songs2VID is self-hosted software. Your operator controls the deployment, database, uploads,
|
||||
logs, and Google OAuth configuration. Songs2VID does not include payment processing.
|
||||
</p>
|
||||
<p>
|
||||
We process personal data in accordance with applicable data protection laws, including the
|
||||
General Data Protection Regulation (GDPR) where it applies.
|
||||
</p>
|
||||
|
||||
<h2>2. Data controller</h2>
|
||||
<p>
|
||||
{LEGAL_OPERATOR.legalName}
|
||||
<br />
|
||||
{LEGAL_OPERATOR.address}
|
||||
<br />
|
||||
{LEGAL_OPERATOR.city}
|
||||
<br />
|
||||
Email: <a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>
|
||||
</p>
|
||||
|
||||
<h2>3. What data we collect</h2>
|
||||
<h3>3.1 Account and authentication data</h3>
|
||||
<p>When you sign in with Google, we receive and store:</p>
|
||||
<ul>
|
||||
<li>Your name, email address, and profile image (from Google)</li>
|
||||
<li>OAuth tokens required to authenticate your session</li>
|
||||
<li>YouTube connection data, including channel ID and title</li>
|
||||
<li>Encrypted YouTube API access and refresh tokens needed to upload videos on your behalf</li>
|
||||
</ul>
|
||||
|
||||
<h3>3.2 Uploaded content</h3>
|
||||
<p>When you use the service, we temporarily process:</p>
|
||||
<ul>
|
||||
<li>Image and audio files you upload</li>
|
||||
<li>Generated video files</li>
|
||||
<li>Per-video metadata you provide (title, description, tags, privacy settings, etc.)</li>
|
||||
</ul>
|
||||
|
||||
<h3>3.3 Usage and technical data</h3>
|
||||
<ul>
|
||||
<li>Plan type, quota usage, and job processing status</li>
|
||||
<li>IP address, browser type, device information, and request logs</li>
|
||||
<li>Error reports and operational diagnostics</li>
|
||||
</ul>
|
||||
|
||||
<h3>3.4 Payment data</h3>
|
||||
<p>
|
||||
If you purchase a paid plan, payment processing is handled by our payment provider. We do
|
||||
not store full payment card details on our servers. We may receive billing status,
|
||||
subscription identifiers, and transaction references.
|
||||
</p>
|
||||
|
||||
<h2>4. Why we process your data</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Contract performance:</strong> to provide video encoding, metadata handling, and
|
||||
YouTube upload features you request
|
||||
</li>
|
||||
<li>
|
||||
<strong>Legitimate interests:</strong> to secure our service, prevent abuse, improve
|
||||
reliability, and enforce our terms
|
||||
</li>
|
||||
<li>
|
||||
<strong>Legal obligations:</strong> where required by tax, accounting, or regulatory law
|
||||
</li>
|
||||
<li>
|
||||
<strong>Consent:</strong> where you have given explicit consent, such as optional
|
||||
marketing communications if offered
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Third-party services</h2>
|
||||
<p>We use trusted third parties to operate Songs2YT, including:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Google / YouTube:</strong> authentication and video uploads via Google OAuth and
|
||||
the YouTube Data API
|
||||
</li>
|
||||
<li>
|
||||
<strong>Hosting and infrastructure providers:</strong> servers, databases, queues, and
|
||||
storage
|
||||
</li>
|
||||
<li>
|
||||
<strong>Payment processors:</strong> for Pro and Enterprise billing when available
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
These providers process data only as necessary to deliver their services and under
|
||||
appropriate contractual safeguards where required.
|
||||
</p>
|
||||
|
||||
<h2>Google User Data Sharing and Disclosure</h2>
|
||||
<p>
|
||||
We do not sell, share, transfer, or disclose any Google user data to any third parties.
|
||||
</p>
|
||||
<p>
|
||||
All user data retrieved via Google OAuth APIs is used solely and strictly for the core
|
||||
functionality of the application (uploading user-generated media) and is never distributed,
|
||||
transferred, or disclosed to external services, partners, or third parties.
|
||||
</p>
|
||||
|
||||
<h2>6. Data retention</h2>
|
||||
<ul>
|
||||
<li>Uploaded source files and generated outputs are retained only as long as needed to complete your jobs</li>
|
||||
<li>Account data is kept while your account remains active</li>
|
||||
<li>Billing records may be retained as required by law</li>
|
||||
<li>Logs are retained for a limited period for security and troubleshooting</li>
|
||||
</ul>
|
||||
<p>You may request deletion of your account data subject to legal retention obligations.</p>
|
||||
|
||||
<h2>7. Self-hosted deployments</h2>
|
||||
<p>
|
||||
If you deploy Songs2YT on your own infrastructure, you are the data controller for data
|
||||
processed on your instance. This Privacy Policy applies to the hosted cloud service
|
||||
operated by us, not to independent self-hosted installations unless we provide managed
|
||||
hosting for you under contract.
|
||||
</p>
|
||||
|
||||
<h2>8. Your rights</h2>
|
||||
<p>Depending on your location, you may have the right to:</p>
|
||||
<ul>
|
||||
<li>Access the personal data we hold about you</li>
|
||||
<li>Request correction or deletion</li>
|
||||
<li>Restrict or object to certain processing</li>
|
||||
<li>Data portability</li>
|
||||
<li>Withdraw consent where processing is consent-based</li>
|
||||
<li>Lodge a complaint with a supervisory authority</li>
|
||||
</ul>
|
||||
<p>
|
||||
To exercise these rights, contact{" "}
|
||||
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>.
|
||||
</p>
|
||||
|
||||
<h2>9. Cookies and local storage</h2>
|
||||
<p>
|
||||
We use essential cookies and similar technologies for authentication, session management,
|
||||
and security. We do not use non-essential tracking cookies unless disclosed separately and
|
||||
enabled with your consent where required.
|
||||
</p>
|
||||
|
||||
<h2>10. Security</h2>
|
||||
<p>
|
||||
We implement appropriate technical and organizational measures to protect your data,
|
||||
including encryption in transit, access controls, and isolated processing environments.
|
||||
No method of transmission or storage is 100% secure.
|
||||
</p>
|
||||
|
||||
<h2>11. International transfers</h2>
|
||||
<p>
|
||||
If data is transferred outside your country, we ensure appropriate safeguards such as
|
||||
standard contractual clauses or equivalent mechanisms where required by law.
|
||||
</p>
|
||||
|
||||
<h2>12. Children</h2>
|
||||
<p>
|
||||
Songs2YT is not directed at children under 16. We do not knowingly collect personal data
|
||||
from children. If you believe a child has provided us data, please contact us.
|
||||
</p>
|
||||
|
||||
<h2>13. Changes to this policy</h2>
|
||||
<p>
|
||||
We may update this Privacy Policy from time to time. Material changes will be posted on
|
||||
this page with an updated effective date.
|
||||
</p>
|
||||
|
||||
<h2>14. Contact</h2>
|
||||
<p>
|
||||
Questions about this Privacy Policy:{" "}
|
||||
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>
|
||||
Google and YouTube process account and upload data according to their own policies. Contact
|
||||
the operator of this instance for data access or deletion requests.
|
||||
</p>
|
||||
</LegalPageLayout>
|
||||
);
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { LegalPageLayout } from "@/components/LegalPageLayout";
|
||||
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Refund Policy | Songs2YT",
|
||||
description: "Refund and cancellation policy for Songs2YT paid plans.",
|
||||
};
|
||||
|
||||
export default function RefundPage() {
|
||||
return (
|
||||
<LegalPageLayout
|
||||
title="Refund Policy"
|
||||
description="Our policy on refunds, cancellations, and billing for paid plans."
|
||||
>
|
||||
<h2>1. Overview</h2>
|
||||
<p>
|
||||
This Refund Policy explains how refunds and cancellations work for paid Songs2YT plans. The
|
||||
free Bedroom Producer plan does not involve payments and is not subject to refunds.
|
||||
</p>
|
||||
|
||||
<h2>2. Free plan</h2>
|
||||
<p>
|
||||
The free plan is provided at no charge. No payment information is required and no refunds
|
||||
apply.
|
||||
</p>
|
||||
|
||||
<h2>3. Pro subscriptions</h2>
|
||||
<h3>3.1 Billing cycle</h3>
|
||||
<p>
|
||||
Pro plans are billed on a recurring monthly basis unless otherwise stated at checkout.
|
||||
Your subscription renews automatically until cancelled.
|
||||
</p>
|
||||
|
||||
<h3>3.2 14-day refund window</h3>
|
||||
<p>
|
||||
If you are a new Pro subscriber, you may request a full refund within <strong>14 days</strong> of
|
||||
your initial purchase, provided you have not substantially consumed paid entitlements
|
||||
(for example, a large portion of your monthly video quota or premium-only features).
|
||||
</p>
|
||||
|
||||
<h3>3.3 After the refund window</h3>
|
||||
<p>
|
||||
After 14 days, subscription fees are generally non-refundable for the current billing
|
||||
period. You may cancel at any time to prevent future renewals. Access typically continues
|
||||
until the end of the paid period.
|
||||
</p>
|
||||
|
||||
<h3>3.4 Cancellation</h3>
|
||||
<p>
|
||||
You can cancel your subscription through your account billing settings or by contacting{" "}
|
||||
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>. Cancellation stops
|
||||
future charges; it does not automatically delete your account or uploaded content history
|
||||
unless you request account deletion separately.
|
||||
</p>
|
||||
|
||||
<h2>4. Enterprise and custom agreements</h2>
|
||||
<p>
|
||||
Enterprise, managed cloud, and paid self-hosted setup fees are governed by the individual
|
||||
quote or contract signed with us. Refund terms for those services are specified in your
|
||||
agreement. Contact{" "}
|
||||
<a href={`mailto:${LEGAL_OPERATOR.salesEmail}`}>{LEGAL_OPERATOR.salesEmail}</a> for
|
||||
contract-related billing questions.
|
||||
</p>
|
||||
|
||||
<h2>5. Non-refundable situations</h2>
|
||||
<p>Refunds are generally not provided when:</p>
|
||||
<ul>
|
||||
<li>The refund request is made outside the applicable refund window</li>
|
||||
<li>The account was terminated for violation of our <Link href="/terms">Terms of Service</Link></li>
|
||||
<li>The issue is caused by third-party services outside our control (e.g. YouTube API outages, Google account restrictions)</li>
|
||||
<li>You simply changed your mind after substantial use of paid quota or features</li>
|
||||
<li>One-time setup or license fees after delivery of agreed setup work, unless required by law or contract</li>
|
||||
</ul>
|
||||
|
||||
<h2>6. Chargebacks</h2>
|
||||
<p>
|
||||
If you believe a charge is incorrect, please contact us before initiating a chargeback so
|
||||
we can resolve the issue promptly. Unjustified chargebacks may result in account suspension.
|
||||
</p>
|
||||
|
||||
<h2>7. How to request a refund</h2>
|
||||
<p>Email us at <a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a> with:</p>
|
||||
<ul>
|
||||
<li>Your account email address</li>
|
||||
<li>Date of purchase and invoice or transaction reference if available</li>
|
||||
<li>Reason for the refund request</li>
|
||||
</ul>
|
||||
<p>We aim to respond within 5 business days. Approved refunds are issued to the original payment method where possible.</p>
|
||||
|
||||
<h2>8. Consumer rights</h2>
|
||||
<p>
|
||||
Nothing in this policy limits mandatory statutory rights you may have as a consumer under
|
||||
applicable law, including withdrawal rights where required by EU or local consumer
|
||||
protection regulations.
|
||||
</p>
|
||||
|
||||
<h2>9. Changes</h2>
|
||||
<p>
|
||||
We may update this Refund Policy from time to time. The version published on this page
|
||||
applies to purchases made after the effective date shown at the top.
|
||||
</p>
|
||||
</LegalPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/admin", "/api/admin"],
|
||||
},
|
||||
sitemap: "https://songs2vid.com/sitemap.xml",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
const SITE_URL = "https://songs2vid.com";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: SITE_URL,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "daily",
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/privacy`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.3,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,197 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { LegalPageLayout } from "@/components/LegalPageLayout";
|
||||
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Terms of Service | Songs2YT",
|
||||
description: "Terms and conditions for using the Songs2YT hosted service.",
|
||||
};
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<LegalPageLayout
|
||||
title="Terms of Service"
|
||||
description="Please read these terms carefully before using Songs2YT."
|
||||
>
|
||||
<h2>1. Agreement</h2>
|
||||
<LegalPageLayout title="Terms of Use">
|
||||
<p>
|
||||
These Terms of Service ("Terms") govern your access to and use of the Songs2YT
|
||||
website and hosted cloud service (the "Service") operated by{" "}
|
||||
{LEGAL_OPERATOR.legalName} ("we", "us"). By creating an account or using
|
||||
the Service, you agree to these Terms.
|
||||
Songs2VID is provided as open-source, self-hosted software without warranty. The operator of
|
||||
each instance is responsible for availability, configuration, and user access.
|
||||
</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>
|
||||
|
||||
<h2>2. The Service</h2>
|
||||
<p>
|
||||
Songs2YT converts user-provided images and audio files into videos and can upload them to
|
||||
YouTube using your connected Google/YouTube account. Features, limits, and availability
|
||||
depend on your plan.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Bedroom Producer (Free):</strong> limited quota, 720p, MP3, optional watermark
|
||||
</li>
|
||||
<li>
|
||||
<strong>Independent Artist (Pro):</strong> expanded limits and features as described on
|
||||
our pricing page
|
||||
</li>
|
||||
<li>
|
||||
<strong>Enterprise:</strong> custom terms as agreed in writing
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
We may modify features, limits, or pricing with reasonable notice where required. The
|
||||
open-source software is provided separately under its applicable open-source license.
|
||||
</p>
|
||||
|
||||
<h2>3. Eligibility and accounts</h2>
|
||||
<ul>
|
||||
<li>You must be at least 16 years old or the age required in your jurisdiction</li>
|
||||
<li>You must have a valid Google account and authorized access to the YouTube channel you connect</li>
|
||||
<li>You are responsible for maintaining the security of your account and OAuth connection</li>
|
||||
<li>You must provide accurate information and promptly update it if it changes</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. Your content and responsibilities</h2>
|
||||
<p>You retain ownership of content you upload. You grant us a limited license to host, process, encode, transmit, and upload your content solely to provide the Service.</p>
|
||||
<p>You represent and warrant that:</p>
|
||||
<ul>
|
||||
<li>You own or have all necessary rights to the content you upload</li>
|
||||
<li>Your content and use of the Service comply with applicable law and YouTube policies</li>
|
||||
<li>Your content does not infringe third-party rights or contain unlawful material</li>
|
||||
<li>You have configured metadata (including "Made for Kids" and privacy settings) accurately</li>
|
||||
</ul>
|
||||
<p>
|
||||
You are solely responsible for content published to your YouTube channel through the
|
||||
Service.
|
||||
</p>
|
||||
|
||||
<h2>5. Acceptable use</h2>
|
||||
<p>You agree not to:</p>
|
||||
<ul>
|
||||
<li>Use the Service for unlawful, harmful, or abusive purposes</li>
|
||||
<li>Upload malware, attempt unauthorized access, or interfere with the Service</li>
|
||||
<li>Circumvent quotas, plan limits, or technical restrictions</li>
|
||||
<li>Resell or commercially exploit the hosted Service without authorization</li>
|
||||
<li>Use the Service in a way that violates Google, YouTube, or third-party terms</li>
|
||||
</ul>
|
||||
<p>We may suspend or terminate access for violations or risks to the Service or other users.</p>
|
||||
|
||||
<h2>6. YouTube and third-party services</h2>
|
||||
<p>
|
||||
The Service integrates with Google OAuth and the YouTube API. Your use of those services is
|
||||
subject to Google's and YouTube's terms and policies. We are not responsible for
|
||||
changes, outages, quota limits, or enforcement actions taken by YouTube.
|
||||
</p>
|
||||
|
||||
<h2>7. Open-source software</h2>
|
||||
<p>
|
||||
Portions of Songs2YT are available as open-source software. Self-hosting is permitted under
|
||||
the applicable open-source license. The hosted Service, enterprise features, managed
|
||||
infrastructure, and certain premium capabilities may require a separate commercial license
|
||||
or subscription.
|
||||
</p>
|
||||
|
||||
<h2>8. Fees and billing</h2>
|
||||
<p>
|
||||
Paid plans are billed according to the pricing displayed at the time of purchase. Taxes may
|
||||
apply. Subscriptions renew automatically unless cancelled in accordance with our{" "}
|
||||
<Link href="/refund">Refund Policy</Link>. Failure to pay may result in downgrade or
|
||||
suspension.
|
||||
</p>
|
||||
|
||||
<h3>8.1 Pro plan quota</h3>
|
||||
<p>
|
||||
Independent Artist (Pro) subscribers receive a monthly video quota that resets on the{" "}
|
||||
<strong>1st day of each calendar month</strong> (UTC). Unused quota does not roll over to
|
||||
the next month unless we expressly grant an extension in writing.
|
||||
</p>
|
||||
<p>
|
||||
Pro subscribers may contact{" "}
|
||||
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a> to request a manual
|
||||
quota reset or temporary extension. Each Pro account is entitled to up to{" "}
|
||||
<strong>five (5) manual monthly quota resets per calendar year</strong>. We review requests
|
||||
in good faith and may decline requests that are abusive, repetitive without cause, or
|
||||
inconsistent with fair use. Approved resets do not increase the annual limit of five
|
||||
requests.
|
||||
</p>
|
||||
|
||||
<h3>8.2 Pro API access</h3>
|
||||
<p>
|
||||
Independent Artist (Pro) subscribers may generate an API key in account settings to upload
|
||||
files and create batch video jobs programmatically. API access requires an active Pro
|
||||
subscription, a connected YouTube account, and compliance with the same quota, file-type, and
|
||||
resolution limits as the web interface. API keys are personal, must be kept confidential, and
|
||||
may be revoked by you or by us if misused. We may apply rate limits and suspend API access
|
||||
for abuse, security incidents, or plan downgrades.
|
||||
</p>
|
||||
|
||||
<h2>9. Availability and support</h2>
|
||||
<p>
|
||||
We strive for high availability but do not guarantee uninterrupted access. Maintenance,
|
||||
updates, and outages may occur. Support levels depend on your plan. Self-hosted DIY
|
||||
deployments without a paid setup are community-supported unless otherwise agreed in
|
||||
writing.
|
||||
</p>
|
||||
|
||||
<h2>10. Disclaimer of warranties</h2>
|
||||
<p>
|
||||
THE SERVICE IS PROVIDED "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>
|
||||
You are responsible for the media you process and upload, including compliance with
|
||||
copyright law and YouTube's terms.
|
||||
</p>
|
||||
</LegalPageLayout>
|
||||
);
|
||||
|
||||
|
After Width: | Height: | Size: 307 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
@@ -1,5 +1,7 @@
|
||||
# Watermark image
|
||||
|
||||
Place `watermark.png` here for the bottom-right video overlay.
|
||||
The image should include the full attribution: "Uploaded through Songs2YT.com".
|
||||
Place `watermark.png` here for the default bottom-right video overlay.
|
||||
|
||||
Curated Pro typography fonts live in `fonts/` (run `node scripts/fetch-watermark-fonts.mjs`).
|
||||
The image should include the full attribution: "Uploaded through Songs2VID.com".
|
||||
If missing, FFmpeg falls back to bottom-right drawtext with the same message.
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 374 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 13 KiB |
@@ -37,7 +37,7 @@ export function AccountPrivacyActions({ email }: Props) {
|
||||
|
||||
const dataRequestSubject = encodeURIComponent("Data export request");
|
||||
const dataRequestBody = encodeURIComponent(
|
||||
`Hello,\n\nI would like to request a copy of my personal data associated with my Songs2YT account (${email}).\n\nThank you.`,
|
||||
`Hello,\n\nI would like to request a copy of my personal data associated with my Songs2VID account (${email}).\n\nThank you.`,
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,94 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { API_DOCS_URL } from "@/lib/plans";
|
||||
|
||||
type ApiKeyStatus = {
|
||||
configured: boolean;
|
||||
prefix: string | null;
|
||||
};
|
||||
|
||||
type RateLimitStatus = {
|
||||
limit: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
windowSeconds: number;
|
||||
resetsInSeconds: number;
|
||||
bonus?: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialStatus: ApiKeyStatus;
|
||||
initialRateLimit: RateLimitStatus;
|
||||
initialStatus: { configured: boolean; prefix: string | null };
|
||||
initialRateLimit: {
|
||||
limit: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
windowSeconds: number;
|
||||
resetsInSeconds: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function ApiKeySettings({ initialStatus, initialRateLimit }: Props) {
|
||||
const [status, setStatus] = useState(initialStatus);
|
||||
const [rateLimit, setRateLimit] = useState(initialRateLimit);
|
||||
const [newKey, setNewKey] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const refresh = () => {
|
||||
fetch("/api/account/api-rate-limit")
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (active) setRateLimit(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
refresh();
|
||||
const id = setInterval(refresh, 5000);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function generateKey() {
|
||||
if (
|
||||
status.configured &&
|
||||
!confirm("This will replace your existing API key. Continue?")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.configured && !confirm("Replace your existing API key?")) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
setNewKey(null);
|
||||
try {
|
||||
const res = await fetch("/api/account/api-key", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to generate key");
|
||||
if (!res.ok) throw new Error(data.error || "Failed to generate API key");
|
||||
setNewKey(data.apiKey);
|
||||
setStatus({ configured: true, prefix: data.prefix });
|
||||
setSuccess("API key generated. Copy it now — it will not be shown again.");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to generate key");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to generate API key");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeKey() {
|
||||
if (!confirm("Revoke the current API key?")) return;
|
||||
if (!confirm("Revoke your API key?")) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
setNewKey(null);
|
||||
try {
|
||||
const res = await fetch("/api/account/api-key", { method: "DELETE" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "Failed to revoke key");
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to revoke API key");
|
||||
setStatus({ configured: false, prefix: null });
|
||||
setSuccess("API key revoked.");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to revoke key");
|
||||
setNewKey(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to revoke API key");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -97,55 +58,31 @@ export function ApiKeySettings({ initialStatus, initialRateLimit }: Props) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
Use the REST API to upload files and create batch video jobs programmatically.
|
||||
Use the REST API to upload files and create video jobs programmatically.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Rate limit: {initialRateLimit.limit} requests per {initialRateLimit.windowSeconds} seconds.
|
||||
</p>
|
||||
|
||||
<div className="rounded border border-gray-800 bg-surface p-4 space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium text-white">API rate limit</p>
|
||||
<p className="text-xs text-gray-400">Resets in {rateLimit.resetsInSeconds}s</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">
|
||||
{rateLimit.used} / {rateLimit.limit} requests used this minute
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Self-hosted: API rate limits are effectively unlimited.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{status.configured && status.prefix && (
|
||||
<p className="text-sm text-gray-300">
|
||||
Active key: <span className="font-mono text-white">{status.prefix}…</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{newKey && (
|
||||
<div className="rounded border border-green-500/40 bg-green-500/10 p-4">
|
||||
<p className="mb-2 text-sm font-medium text-green-300">Your new API key (copy now)</p>
|
||||
<p className="mb-2 text-sm font-medium text-green-300">Copy your new API key now</p>
|
||||
<code className="block break-all rounded bg-black/40 px-3 py-2 text-xs text-green-200">
|
||||
{newKey}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="rounded border border-green-500/40 bg-green-500/10 px-4 py-3 text-sm text-green-300">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
||||
{error && <p className="text-sm text-red-300">{error}</p>}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateKey}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center justify-center rounded bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
|
||||
className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Working…" : status.configured ? "Regenerate API key" : "Generate API key"}
|
||||
</button>
|
||||
@@ -154,36 +91,19 @@ export function ApiKeySettings({ initialStatus, initialRateLimit }: Props) {
|
||||
type="button"
|
||||
onClick={revokeKey}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center justify-center rounded border border-red-600/50 px-4 py-2 text-sm font-medium text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
|
||||
className="rounded border border-red-600/50 px-4 py-2 text-sm font-medium text-red-300 hover:bg-red-500/10"
|
||||
>
|
||||
Revoke API key
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-gray-800 bg-surface p-4 text-xs text-gray-400">
|
||||
<p className="mb-2 font-semibold uppercase tracking-wider text-gray-500">Endpoints</p>
|
||||
<ul className="space-y-2 font-mono">
|
||||
<li>POST /api/v1/upload: upload image or audio file</li>
|
||||
<li>POST /api/v1/jobs: create job from paths (recommended for large batches)</li>
|
||||
<li>POST /api/v1/jobs/batch: small packs only (one-shot multipart)</li>
|
||||
<li>GET /api/v1/playlists: list YouTube playlists</li>
|
||||
<li>POST /api/v1/playlists: create a YouTube playlist</li>
|
||||
<li>GET /api/v1/jobs: list jobs</li>
|
||||
<li>GET /api/v1/jobs/:id: job status</li>
|
||||
</ul>
|
||||
<p className="mt-3">
|
||||
Send <span className="text-gray-300">Authorization: Bearer YOUR_API_KEY</span> on every
|
||||
request.{" "}
|
||||
<a
|
||||
href={API_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Full API docs
|
||||
</a>
|
||||
</p>
|
||||
<a
|
||||
href={API_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded border border-gray-600 px-4 py-2 text-sm text-gray-300 hover:text-white"
|
||||
>
|
||||
API documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,436 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ScrollReveal } from "@/components/ScrollReveal";
|
||||
import { useInView } from "@/hooks/useInView";
|
||||
import { useMockupProgress } from "@/hooks/useMockupProgress";
|
||||
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
|
||||
|
||||
function FilmStripIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<path d="M2 8h20M2 16h20M6 4v4M6 16v4M10 4v4M10 16v4M14 4v4M14 16v4M18 4v4M18 16v4" />
|
||||
<path d="m15 9 3 3-3 3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function YouTubeIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.4.6A3 3 0 0 0 .5 6.2 31 31 0 0 0 0 12a31 31 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.6 9.4.6 9.4.6s7.5 0 9.4-.6a3 3 0 0 0 2.1-2.1A31 31 0 0 0 24 12a31 31 0 0 0-.5-5.8z" />
|
||||
<path fill="#12121f" d="M9.75 15.02l6.35-3.02-6.35-3.02v6.04z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CoinsIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<ellipse cx="8" cy="6" rx="5" ry="2" />
|
||||
<path d="M3 6v4c0 1.1 2.24 2 5 2s5-.9 5-2V6" />
|
||||
<path d="M3 10v4c0 1.1 2.24 2 5 2s5-.9 5-2v-4" />
|
||||
<ellipse cx="16" cy="14" rx="5" ry="2" />
|
||||
<path d="M11 14v4c0 1.1 2.24 2 5 2s5-.9 5-2v-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaylistIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 6h12M4 12h12M4 18h8" strokeLinecap="round" />
|
||||
<path d="M17 14.5v5l4-2.5-4-2.5z" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WaveIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 12h1M7 12h1M10 8v8M13 10v4M16 7v10M19 11v2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GearsIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const BENEFITS = [
|
||||
{
|
||||
title: "No editing required",
|
||||
desc: "Skip complex video editors. Just upload an image and audio, and Songs2VID handles the rest.",
|
||||
Icon: FilmStripIcon,
|
||||
},
|
||||
{
|
||||
title: "YouTube-ready output",
|
||||
desc: "Videos are encoded and uploaded with the metadata you set, ready for your channel.",
|
||||
Icon: YouTubeIcon,
|
||||
},
|
||||
{
|
||||
title: "Self-hosted, unlocked",
|
||||
desc: "No video quotas or paywalls. Art-track layouts, typography, watermarks, and the REST API are all available.",
|
||||
Icon: CoinsIcon,
|
||||
},
|
||||
{
|
||||
title: "YouTube playlists",
|
||||
desc: "Create YouTube playlists and add every upload from the dashboard or API.",
|
||||
Icon: PlaylistIcon,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const STOCK_THUMBS = [
|
||||
"https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1487180144351-b8472da7d491?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1598488035139-bdbb2231ce04?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1571330735066-03aaa9429d89?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1619983081563-430f63602796?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1483412033650-1015ddeb83d1?w=120&h=120&fit=crop&auto=format",
|
||||
] as const;
|
||||
|
||||
function TypewriterText({ text }: { text: string }) {
|
||||
const [displayed, setDisplayed] = useState("");
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let index = 0;
|
||||
const interval = setInterval(() => {
|
||||
index += 1;
|
||||
setDisplayed(text.slice(0, index));
|
||||
if (index >= text.length) {
|
||||
clearInterval(interval);
|
||||
setDone(true);
|
||||
}
|
||||
}, 45);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<span className="text-xs font-medium text-gray-300">
|
||||
{displayed}
|
||||
{!done && <span className="ml-0.5 inline-block w-[2px] animate-pulse bg-red-400">|</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function BenefitCard({
|
||||
title,
|
||||
desc,
|
||||
Icon,
|
||||
}: {
|
||||
title: string;
|
||||
desc: string;
|
||||
Icon: typeof FilmStripIcon;
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-xl border border-red-500/20 bg-surface p-6 shadow-[0_0_24px_rgba(239,68,68,0.08)] transition-all duration-300 hover:border-red-500/40 hover:shadow-[0_0_32px_rgba(239,68,68,0.18)]">
|
||||
<Icon className="absolute right-5 top-5 h-8 w-8 text-gray-600 transition-colors duration-300 group-hover:text-red-500/50" />
|
||||
<h3 className="pr-12 text-lg font-semibold text-white">{title}</h3>
|
||||
<p className="mt-3 text-sm leading-relaxed text-gray-400">{desc}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MockupProgressRow({
|
||||
initialWidth,
|
||||
midWidth,
|
||||
active,
|
||||
phaseOneMs = 3000,
|
||||
phaseTwoMs = 2000,
|
||||
}: {
|
||||
initialWidth: string;
|
||||
midWidth: string;
|
||||
active: boolean;
|
||||
phaseOneMs?: number;
|
||||
phaseTwoMs?: number;
|
||||
}) {
|
||||
const { phase, processed, transitionMs } = useMockupProgress(active, phaseOneMs, phaseTwoMs);
|
||||
|
||||
const width = phase === 0 ? initialWidth : phase === 1 ? midWidth : "100%";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-1 text-[10px] text-gray-500">{processed ? "Processed" : "Processing..."}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1 min-w-0 flex-1 rounded-full bg-gray-700">
|
||||
<div
|
||||
className="h-full rounded-full bg-white/80 ease-out"
|
||||
style={{
|
||||
width,
|
||||
transition: phase >= 1 ? `width ${transitionMs}ms ease-out` : "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CheckIcon
|
||||
className={`h-3.5 w-3.5 shrink-0 text-accent transition-all duration-300 ${
|
||||
processed ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardMockup() {
|
||||
const { ref, inView } = useInView();
|
||||
const sidebarIcons = [NoteIcon, WaveIcon, GearsIcon] as const;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="rounded-xl border border-gray-700/50 bg-surface-dark p-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex w-11 shrink-0 flex-col items-center gap-3 rounded-lg bg-surface py-3">
|
||||
<div className="h-2 w-2 rounded-full bg-gray-500" />
|
||||
{sidebarIcons.map((Icon, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md border border-gray-700/50 bg-surface-dark text-gray-500"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<TypewriterText text="Multi-step configuration" />
|
||||
<div className="h-4 w-8 rounded-full bg-red-500/80" />
|
||||
</div>
|
||||
<div className="h-7 rounded border border-gray-700 bg-surface px-2 text-xs leading-7 text-gray-500">
|
||||
Title
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="rounded bg-gray-700 px-2 py-0.5 text-[10px] text-gray-400">genre</span>
|
||||
<span className="rounded bg-gray-700 px-2 py-0.5 text-[10px] text-gray-400">audio</span>
|
||||
</div>
|
||||
<div className="flex h-7 items-center justify-between rounded border border-gray-700 bg-surface px-2 text-xs text-gray-500">
|
||||
Category <span className="text-gray-400">Music ▾</span>
|
||||
</div>
|
||||
<div className="space-y-2 pt-1">
|
||||
<MockupProgressRow active={inView} initialWidth="25%" midWidth="45%" phaseOneMs={3000} phaseTwoMs={2000} />
|
||||
<MockupProgressRow active={inView} initialWidth="10%" midWidth="55%" phaseOneMs={3000} phaseTwoMs={2000} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProcessFlow() {
|
||||
return (
|
||||
<div className="relative mt-4 overflow-hidden rounded-xl border border-gray-700/50 bg-surface-dark p-5">
|
||||
<svg
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
viewBox="0 0 420 240"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="flowGradH" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="rgb(239 68 68 / 0.15)" />
|
||||
<stop offset="50%" stopColor="rgb(239 68 68 / 0.55)" />
|
||||
<stop offset="100%" stopColor="rgb(239 68 68 / 0.15)" />
|
||||
</linearGradient>
|
||||
<linearGradient id="flowGradV" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="rgb(239 68 68 / 0.5)" />
|
||||
<stop offset="100%" stopColor="rgb(239 68 68 / 0.15)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<path
|
||||
d="M 58 52 C 120 52, 140 58, 168 72"
|
||||
fill="none"
|
||||
stroke="url(#flowGradH)"
|
||||
strokeWidth="1.5"
|
||||
className="benefits-flow-path"
|
||||
/>
|
||||
<path
|
||||
d="M 58 118 C 120 108, 140 88, 168 78"
|
||||
fill="none"
|
||||
stroke="url(#flowGradH)"
|
||||
strokeWidth="1.5"
|
||||
className="benefits-flow-path"
|
||||
style={{ animationDelay: "0.3s" }}
|
||||
/>
|
||||
<path
|
||||
d="M 198 75 C 250 75, 285 68, 318 68"
|
||||
fill="none"
|
||||
stroke="url(#flowGradH)"
|
||||
strokeWidth="1.5"
|
||||
className="benefits-flow-path"
|
||||
style={{ animationDelay: "0.6s" }}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-4">
|
||||
<FlowInput label="Batch Audio Files" icon="audio" />
|
||||
<FlowInput label="Single Cover Image" icon="image" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col items-center px-2 pt-6">
|
||||
<div className="benefits-process-icon flex h-16 w-16 items-center justify-center rounded-xl border border-red-500/40 bg-surface shadow-[0_0_20px_rgba(239,68,68,0.25)]">
|
||||
<Image
|
||||
src="/database.png"
|
||||
alt="Process"
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-10 w-10 object-contain brightness-0 invert opacity-90"
|
||||
/>
|
||||
</div>
|
||||
<span className="mt-2 text-[10px] font-medium text-gray-400">Process</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{STOCK_THUMBS.map((src) => (
|
||||
<div
|
||||
key={src}
|
||||
className="relative h-11 w-11 overflow-hidden rounded border border-gray-700"
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt=""
|
||||
fill
|
||||
sizes="44px"
|
||||
className="scale-110 object-cover blur-[2px]"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/25" />
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[9px] text-white/80">
|
||||
▶
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<svg
|
||||
className="my-2 h-10 w-6 overflow-visible"
|
||||
viewBox="0 0 6 40"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line
|
||||
x1="3"
|
||||
y1="2"
|
||||
x2="3"
|
||||
y2="38"
|
||||
stroke="rgb(239 68 68 / 0.55)"
|
||||
strokeWidth="1.5"
|
||||
className="benefits-flow-path"
|
||||
style={{ animationDelay: "0.9s" }}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<YouTubeIcon className="h-9 w-9 text-red-500" />
|
||||
<div>
|
||||
<p className="text-xs font-bold tracking-wider text-white">YouTube</p>
|
||||
<p className="text-[10px] font-semibold tracking-[0.15em] text-red-400">DIRECT UPLOAD</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowInput({ label, icon }: { label: string; icon: "audio" | "image" }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-gray-700 bg-surface">
|
||||
{icon === "audio" ? (
|
||||
<NoteIcon className="h-5 w-5 text-gray-500" />
|
||||
) : (
|
||||
<svg className="h-5 w-5 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<path d="M21 15l-5-5L5 21" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span className="max-w-[72px] text-center text-[9px] leading-tight text-gray-500">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BenefitsVisual() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-700/50 bg-surface p-5 shadow-2xl shadow-black/30">
|
||||
<DashboardMockup />
|
||||
<ProcessFlow />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BenefitsScrollTitle({ sectionRef }: { sectionRef: React.RefObject<HTMLElement | null> }) {
|
||||
return <SectionScrollTitle sectionRef={sectionRef} title="Benefits" />;
|
||||
}
|
||||
|
||||
export function BenefitsSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id="benefits"
|
||||
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 py-24"
|
||||
>
|
||||
<BenefitsScrollTitle sectionRef={sectionRef} />
|
||||
<div className="relative z-10 mx-auto max-w-7xl">
|
||||
<ScrollReveal>
|
||||
<h2 className="mb-12 text-center text-3xl font-bold text-white">Benefits</h2>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid items-start gap-10 lg:grid-cols-[1fr_1.15fr] lg:gap-12">
|
||||
<div className="flex flex-col gap-5">
|
||||
{BENEFITS.map((benefit, index) => (
|
||||
<ScrollReveal key={benefit.title} delay={index * 100} direction="left">
|
||||
<BenefitCard title={benefit.title} desc={benefit.desc} Icon={benefit.Icon} />
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ScrollReveal delay={200} direction="right">
|
||||
<BenefitsVisual />
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { ScrollReveal } from "@/components/ScrollReveal";
|
||||
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
|
||||
import { DOCKER_HUB_URL, GITEA_URL } from "@/lib/plans";
|
||||
|
||||
function DockerIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" role="img" aria-label="Docker">
|
||||
<path
|
||||
fill="#2496ED"
|
||||
d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GiteaIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" role="img" aria-label="Gitea">
|
||||
<path
|
||||
fill="#609926"
|
||||
d="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const DEPLOY_CARDS = [
|
||||
{
|
||||
name: "Docker Hub",
|
||||
desc: "Pull the image and run Songs2YT on your own server with Docker Compose.",
|
||||
Icon: DockerIcon,
|
||||
cta: "View on Docker Hub",
|
||||
href: DOCKER_HUB_URL,
|
||||
accent: "text-[#2496ED]",
|
||||
hover:
|
||||
"hover:border-[#2496ED]/45 hover:bg-[#2496ED]/[0.06] hover:shadow-lg hover:shadow-[#2496ED]/20",
|
||||
titleHover: "group-hover:text-[#2496ED]",
|
||||
},
|
||||
{
|
||||
name: "Gitea",
|
||||
desc: "Clone the open-source repository and deploy from source on your infrastructure.",
|
||||
Icon: GiteaIcon,
|
||||
cta: "View on Gitea",
|
||||
href: GITEA_URL,
|
||||
accent: "text-[#609926]",
|
||||
hover:
|
||||
"hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20",
|
||||
titleHover: "group-hover:text-[#609926]",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function DeployCard({
|
||||
name,
|
||||
desc,
|
||||
Icon,
|
||||
cta,
|
||||
href,
|
||||
accent,
|
||||
hover,
|
||||
titleHover,
|
||||
}: (typeof DEPLOY_CARDS)[number]) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`group block rounded-xl border border-gray-700/50 bg-surface p-6 transition-all duration-300 ${hover}`}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Icon className="h-8 w-8 transition-transform duration-300 group-hover:scale-110" />
|
||||
<span className="rounded bg-gray-800 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-gray-500 transition-colors duration-300 group-hover:bg-gray-700/80">
|
||||
Open Source
|
||||
</span>
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold text-white transition-colors duration-300 ${titleHover}`}>
|
||||
{name}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-gray-400 transition-colors duration-300 group-hover:text-gray-300">
|
||||
{desc}
|
||||
</p>
|
||||
<p className={`mt-4 text-sm font-medium ${accent} transition-all duration-300 group-hover:underline`}>
|
||||
{cta}
|
||||
</p>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id="download"
|
||||
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 py-24"
|
||||
>
|
||||
<SectionScrollTitle sectionRef={sectionRef} title="Download" />
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-7xl">
|
||||
<ScrollReveal>
|
||||
<h2 className="mb-4 text-center text-3xl font-bold text-white">Download</h2>
|
||||
<p className="mx-auto mb-12 max-w-2xl text-center text-gray-400">
|
||||
Songs2YT is open source. Self-host on your own server with Docker or deploy from source.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid items-start gap-10 lg:grid-cols-[1fr_1.1fr] lg:gap-12">
|
||||
<ScrollReveal direction="left">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-white">Run it yourself</h3>
|
||||
<p className="mt-3 leading-relaxed text-gray-400">
|
||||
Host Songs2YT on your infrastructure with full control over data, queues, and
|
||||
storage. Ideal for teams and creators who want a private deployment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3 text-sm text-gray-400">
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
|
||||
Docker image published to Docker Hub
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
|
||||
Source code available on Gitea
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
|
||||
Gitea Issues community support
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
|
||||
PostgreSQL, Redis, FFmpeg, and worker included
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="rounded-xl border border-gray-700/50 bg-surface-dark p-4">
|
||||
<p className="mb-3 text-xs font-medium text-gray-500">Quick start</p>
|
||||
<pre className="overflow-x-auto text-sm leading-relaxed text-gray-300">
|
||||
<code>{`docker pull atakanozban/songs2yt:latest\ndocker compose up -d`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-1">
|
||||
{DEPLOY_CARDS.map((card, index) => (
|
||||
<ScrollReveal key={card.name} delay={index * 120} direction="right">
|
||||
<DeployCard {...card} />
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import {
|
||||
API_DOCS_URL,
|
||||
DOCKER_HUB_URL,
|
||||
DOCS_URL,
|
||||
GITEA_ISSUES_URL,
|
||||
GITEA_URL,
|
||||
SUPPORT_EMAIL,
|
||||
WEBSITE_URL,
|
||||
} from "@/lib/plans";
|
||||
|
||||
function GiteaIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" role="img" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DockerIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" role="img" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterLink({
|
||||
href,
|
||||
children,
|
||||
external,
|
||||
}: {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
external?: boolean;
|
||||
}) {
|
||||
const className = "transition-colors duration-300 hover:text-white";
|
||||
|
||||
if (external || href.startsWith("#") || href.startsWith("mailto:")) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={external ? "_blank" : undefined}
|
||||
rel={external ? "noopener noreferrer" : undefined}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function LegalLink({
|
||||
href,
|
||||
children,
|
||||
external,
|
||||
}: {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
external?: boolean;
|
||||
}) {
|
||||
const className = "transition-colors duration-300 hover:text-gray-300";
|
||||
|
||||
if (external) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className={className}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_URL = "https://status.atakanozban.com/status/2";
|
||||
const DOCS_INTRO_URL = `${DOCS_URL}/docs/intro`;
|
||||
|
||||
export function Footer() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="mt-20 w-full border-t border-gray-800 bg-black/40 pb-8 pt-16 text-sm text-gray-400">
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
<div className="grid grid-cols-1 gap-8 pb-12 md:grid-cols-12">
|
||||
<div className="flex flex-col gap-4 md:col-span-5">
|
||||
<Logo size="sm" />
|
||||
<p className="max-w-sm text-gray-500">
|
||||
Convert your audio tracks into stunning videos for YouTube. Beautiful, automated, and
|
||||
fully open-source.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-4 text-gray-500">
|
||||
<a
|
||||
href={GITEA_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Gitea"
|
||||
className="transition-colors duration-300 hover:text-[#609926]"
|
||||
>
|
||||
<GiteaIcon className="h-5 w-5" />
|
||||
</a>
|
||||
<a
|
||||
href={DOCKER_HUB_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Docker Hub"
|
||||
className="transition-colors duration-300 hover:text-[#2496ED]"
|
||||
>
|
||||
<DockerIcon className="h-5 w-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8 sm:grid-cols-3 md:col-span-7">
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Product
|
||||
</span>
|
||||
<FooterLink href="#download">Download</FooterLink>
|
||||
<FooterLink href="#benefits">Benefits</FooterLink>
|
||||
<FooterLink href={DOCS_INTRO_URL} external>
|
||||
Documentation
|
||||
</FooterLink>
|
||||
<FooterLink href={API_DOCS_URL} external>
|
||||
API docs
|
||||
</FooterLink>
|
||||
<FooterLink href={WEBSITE_URL} external>
|
||||
Hosted Songs2VID
|
||||
</FooterLink>
|
||||
<FooterLink href="/privacy">Privacy Policy</FooterLink>
|
||||
<FooterLink href="/terms">Terms of Service</FooterLink>
|
||||
<FooterLink href={STATUS_URL} external>
|
||||
Service Status
|
||||
</FooterLink>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Open Source
|
||||
</span>
|
||||
<FooterLink href={GITEA_URL} external>
|
||||
Gitea Instance
|
||||
</FooterLink>
|
||||
<FooterLink href={DOCKER_HUB_URL} external>
|
||||
Docker Image
|
||||
</FooterLink>
|
||||
<FooterLink href={GITEA_ISSUES_URL} external>
|
||||
Report a Bug
|
||||
</FooterLink>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Contact
|
||||
</span>
|
||||
<FooterLink href={`mailto:${SUPPORT_EMAIL}`}>Support Email</FooterLink>
|
||||
<span className="text-xs text-gray-600">Self-hosted community support</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 border-t border-gray-900 pt-8 text-xs text-gray-500 md:justify-start">
|
||||
<span>© {year} Songs2VID. All rights reserved.</span>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href="/privacy">Privacy Policy</LegalLink>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href="/terms">Terms of Service</LegalLink>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href={STATUS_URL} external>
|
||||
Service Status
|
||||
</LegalLink>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,9 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
|
||||
export function JobProgress({ jobId }: Props) {
|
||||
const [job, setJob] = useState<JobResponse | null>(null);
|
||||
const [videosProcessed, setVideosProcessed] = useState<number | null>(null);
|
||||
const [quota, setQuota] = useState<{
|
||||
used: number;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -39,7 +41,10 @@ export function JobProgress({ jobId }: Props) {
|
||||
|
||||
if (quotaRes.ok) {
|
||||
const quotaData = await quotaRes.json();
|
||||
if (active) setVideosProcessed(quotaData.used ?? 0);
|
||||
if (active)
|
||||
setQuota({
|
||||
used: quotaData.used ?? 0,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (active) setError(err instanceof Error ? err.message : "Error loading job");
|
||||
@@ -76,9 +81,9 @@ export function JobProgress({ jobId }: Props) {
|
||||
Overall: <span className="text-white">{job.status}</span>
|
||||
</p>
|
||||
</div>
|
||||
{videosProcessed !== null && (
|
||||
{quota && (
|
||||
<div className="rounded bg-surface-light px-4 py-2 text-sm text-gray-300">
|
||||
Self-hosted · {videosProcessed} videos processed
|
||||
{quota.used} videos created
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { MobileMenuButton, MobileSidebar, sidebarLinkClass } from "@/components/MobileSidebar";
|
||||
import { DOCS_URL, WEBSITE_URL } from "@/lib/plans";
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ href: "#benefits", label: "Benefits", kind: "anchor" as const },
|
||||
{ href: "#download", label: "Download", kind: "anchor" as const },
|
||||
{ href: `${DOCS_URL}/docs/intro`, label: "Docs", kind: "external" as const },
|
||||
{ href: WEBSITE_URL, label: "Hosted", kind: "external" as const },
|
||||
{ href: "#support", label: "Support", kind: "anchor" as const },
|
||||
] as const;
|
||||
|
||||
function NavAnchor({
|
||||
href,
|
||||
label,
|
||||
onNavigate,
|
||||
className = "text-base font-medium text-gray-300 transition-colors duration-200 hover:text-white",
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
onNavigate?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
const id = href.replace("#", "");
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
window.history.pushState(null, "", href);
|
||||
onNavigate?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<a href={href} onClick={handleClick} className={className}>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalLink({
|
||||
href,
|
||||
label,
|
||||
onNavigate,
|
||||
className = "text-base font-medium text-gray-300 transition-colors duration-200 hover:text-white",
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
onNavigate?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => onNavigate?.()}
|
||||
className={className}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function LandingNavbar() {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="group absolute left-0 right-0 top-0 z-20 bg-transparent transition-all duration-300 hover:bg-black/70 hover:backdrop-blur-md">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
|
||||
<Logo size="md" />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<nav className="hidden items-center gap-10 sm:flex">
|
||||
{NAV_LINKS.map((link) =>
|
||||
link.kind === "external" ? (
|
||||
<ExternalLink key={link.href} href={link.href} label={link.label} />
|
||||
) : (
|
||||
<NavAnchor key={link.href} href={link.href} label={link.label} />
|
||||
),
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<MobileMenuButton
|
||||
open={menuOpen}
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<MobileSidebar open={menuOpen} onClose={() => setMenuOpen(false)} title="Menu">
|
||||
{NAV_LINKS.map((link) =>
|
||||
link.kind === "external" ? (
|
||||
<ExternalLink
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
label={link.label}
|
||||
onNavigate={() => setMenuOpen(false)}
|
||||
className={sidebarLinkClass()}
|
||||
/>
|
||||
) : (
|
||||
<NavAnchor
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
label={link.label}
|
||||
onNavigate={() => setMenuOpen(false)}
|
||||
className={sidebarLinkClass()}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</MobileSidebar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { getVideoAttributionText } from "@/lib/branding";
|
||||
import {
|
||||
CURATED_FONTS,
|
||||
googleFontsStylesheetUrl,
|
||||
type CuratedFontKey,
|
||||
type WatermarkFontKey,
|
||||
} from "@/lib/fonts";
|
||||
@@ -32,6 +31,15 @@ import {
|
||||
type LayoutSettings,
|
||||
type LayoutTemplate,
|
||||
} from "@/lib/layout";
|
||||
import {
|
||||
WATERMARK_WIDTH_FRACTION,
|
||||
artistFontSizeForWidth,
|
||||
curatedFontApiUrl,
|
||||
previewFontFamilyCss,
|
||||
scaleFontToPreview,
|
||||
titleFontSizeForWidth,
|
||||
watermarkFontSizeForWidth,
|
||||
} from "@/lib/preview-typography";
|
||||
import {
|
||||
DEFAULT_WATERMARK,
|
||||
WATERMARK_OFFSET_MAX,
|
||||
@@ -44,10 +52,13 @@ import {
|
||||
} from "@/lib/watermark";
|
||||
|
||||
type Props = {
|
||||
locked?: boolean;
|
||||
locked: boolean;
|
||||
previewImageUrl: string | null;
|
||||
title: string;
|
||||
/** On-video song title (art-track layouts). */
|
||||
songTitle: string;
|
||||
artist: string;
|
||||
/** Encode width from selected resolution (e.g. 1280). */
|
||||
encodeWidth?: number;
|
||||
layout: LayoutSettings;
|
||||
onLayoutChange: (next: LayoutSettings) => void;
|
||||
watermark: WatermarkSettings;
|
||||
@@ -67,16 +78,24 @@ const WM_POSITION_LABELS: Record<WatermarkPosition, string> = {
|
||||
|
||||
const CUSTOM_PREVIEW_FAMILY = "S2VIDCustomWm";
|
||||
|
||||
function previewFontFamily(fontKey: WatermarkFontKey | undefined): string {
|
||||
return previewFontFamilyCss(fontKey);
|
||||
}
|
||||
|
||||
function watermarkOverlayStyle(
|
||||
position: WatermarkPosition,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
sizedBox = false,
|
||||
): CSSProperties {
|
||||
const base: CSSProperties = {
|
||||
position: "absolute",
|
||||
maxWidth: "32%",
|
||||
maxWidth: `${WATERMARK_WIDTH_FRACTION * 100}%`,
|
||||
pointerEvents: "none",
|
||||
zIndex: 5,
|
||||
...(sizedBox
|
||||
? { width: `${WATERMARK_WIDTH_FRACTION * 100}%` }
|
||||
: null),
|
||||
};
|
||||
const ox = `${offsetX}px`;
|
||||
const oy = `${offsetY}px`;
|
||||
@@ -100,13 +119,6 @@ function watermarkOverlayStyle(
|
||||
}
|
||||
}
|
||||
|
||||
function previewFontFamily(fontKey: WatermarkFontKey | undefined): string {
|
||||
if (!fontKey || fontKey === "system") return "ui-sans-serif, system-ui, sans-serif";
|
||||
if (fontKey === "custom") return `'${CUSTOM_PREVIEW_FAMILY}', sans-serif`;
|
||||
const meta = CURATED_FONTS.find((f) => f.key === fontKey);
|
||||
return meta ? `'${meta.cssFamily}', sans-serif` : "sans-serif";
|
||||
}
|
||||
|
||||
function MiniThumb({
|
||||
template,
|
||||
active,
|
||||
@@ -287,10 +299,11 @@ function previewTextStyle(
|
||||
}
|
||||
|
||||
export function LayoutStudio({
|
||||
locked = false,
|
||||
locked,
|
||||
previewImageUrl,
|
||||
title,
|
||||
songTitle,
|
||||
artist,
|
||||
encodeWidth = 1280,
|
||||
layout,
|
||||
onLayoutChange,
|
||||
watermark,
|
||||
@@ -337,8 +350,14 @@ export function LayoutStudio({
|
||||
);
|
||||
|
||||
const wmOverlay = useMemo(
|
||||
() => watermarkOverlayStyle(W.position, W.offsetX, W.offsetY),
|
||||
[W.position, W.offsetX, W.offsetY],
|
||||
() =>
|
||||
watermarkOverlayStyle(
|
||||
W.position,
|
||||
W.offsetX,
|
||||
W.offsetY,
|
||||
W.mode === "default" || W.mode === "logo",
|
||||
),
|
||||
[W.position, W.offsetX, W.offsetY, W.mode],
|
||||
);
|
||||
|
||||
const textFontStyle = useMemo(
|
||||
@@ -346,14 +365,34 @@ export function LayoutStudio({
|
||||
[W.fontKey],
|
||||
);
|
||||
|
||||
const titlePreviewPx = useMemo(
|
||||
() => scaleFontToPreview(titleFontSizeForWidth(encodeWidth), encodeWidth),
|
||||
[encodeWidth],
|
||||
);
|
||||
const artistPreviewPx = useMemo(
|
||||
() => scaleFontToPreview(artistFontSizeForWidth(encodeWidth), encodeWidth),
|
||||
[encodeWidth],
|
||||
);
|
||||
const wmTextPreviewPx = useMemo(
|
||||
() => scaleFontToPreview(watermarkFontSizeForWidth(encodeWidth), encodeWidth),
|
||||
[encodeWidth],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const id = "s2vid-watermark-google-fonts";
|
||||
if (document.getElementById(id)) return;
|
||||
const link = document.createElement("link");
|
||||
link.id = id;
|
||||
link.rel = "stylesheet";
|
||||
link.href = googleFontsStylesheetUrl(CURATED_FONTS.map((f) => f.key));
|
||||
document.head.appendChild(link);
|
||||
const styleId = "s2vid-preview-curated-fonts";
|
||||
let el = document.getElementById(styleId) as HTMLStyleElement | null;
|
||||
if (!el) {
|
||||
el = document.createElement("style");
|
||||
el.id = styleId;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.textContent = CURATED_FONTS.map(
|
||||
(f) => `@font-face {
|
||||
font-family: 'S2VIDPreview-${f.key}';
|
||||
src: url('${curatedFontApiUrl(f.key)}') format('truetype');
|
||||
font-display: swap;
|
||||
}`,
|
||||
).join("\n");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -416,7 +455,7 @@ export function LayoutStudio({
|
||||
if (customFontObjectUrl) URL.revokeObjectURL(customFontObjectUrl);
|
||||
setCustomFontObjectUrl(objectUrl);
|
||||
const path = await onUploadFont(file);
|
||||
patchWm({ mode: "text", fontKey: "custom", fontPath: path });
|
||||
patchWm({ fontKey: "custom", fontPath: path });
|
||||
} catch (err) {
|
||||
setFontError(err instanceof Error ? err.message : "Font upload failed");
|
||||
} finally {
|
||||
@@ -435,7 +474,11 @@ export function LayoutStudio({
|
||||
const artTrack = Boolean(L.template);
|
||||
|
||||
return (
|
||||
<div className="relative rounded-lg border border-gray-700 bg-surface p-6">
|
||||
<div
|
||||
className={`relative rounded-lg border border-gray-700 bg-surface p-6 ${
|
||||
locked ? "opacity-80" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-lg font-medium text-white">Video layout</h3>
|
||||
</div>
|
||||
@@ -478,18 +521,22 @@ export function LayoutStudio({
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className="max-w-full truncate text-sm font-semibold text-white drop-shadow"
|
||||
style={
|
||||
W.mode === "text" ? textFontStyle : undefined
|
||||
}
|
||||
className="max-w-full truncate font-semibold text-white drop-shadow"
|
||||
style={{
|
||||
...textFontStyle,
|
||||
fontSize: `${titlePreviewPx}px`,
|
||||
lineHeight: 1.15,
|
||||
}}
|
||||
>
|
||||
{title.trim() || "Track title"}
|
||||
{songTitle.trim() || "Track title"}
|
||||
</p>
|
||||
<p
|
||||
className="max-w-full truncate text-xs text-white/75 drop-shadow"
|
||||
style={
|
||||
W.mode === "text" ? textFontStyle : undefined
|
||||
}
|
||||
className="max-w-full truncate text-white/75 drop-shadow"
|
||||
style={{
|
||||
...textFontStyle,
|
||||
fontSize: `${artistPreviewPx}px`,
|
||||
lineHeight: 1.15,
|
||||
}}
|
||||
>
|
||||
{artist.trim() || "Artist"}
|
||||
</p>
|
||||
@@ -507,24 +554,34 @@ export function LayoutStudio({
|
||||
)}
|
||||
|
||||
{W.mode !== "none" && (
|
||||
<div style={wmOverlay} className="rounded bg-black/40 px-2 py-1">
|
||||
{W.mode === "logo" && logoPreviewUrl ? (
|
||||
<div style={wmOverlay}>
|
||||
{W.mode === "default" ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src="/api/branding/watermark"
|
||||
alt=""
|
||||
className="h-auto w-full object-contain"
|
||||
/>
|
||||
) : W.mode === "logo" && logoPreviewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={logoPreviewUrl}
|
||||
alt=""
|
||||
className="max-h-14 w-auto object-contain"
|
||||
className="h-auto w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
) : W.mode === "text" ? (
|
||||
<span
|
||||
className="text-[11px] font-medium text-white/90 drop-shadow"
|
||||
style={W.mode === "text" ? textFontStyle : undefined}
|
||||
className="font-medium text-white/90 drop-shadow"
|
||||
style={{
|
||||
...textFontStyle,
|
||||
fontSize: `${wmTextPreviewPx}px`,
|
||||
}}
|
||||
>
|
||||
{W.mode === "text" && W.text?.trim()
|
||||
{W.text?.trim()
|
||||
? W.text.trim().slice(0, WATERMARK_TEXT_MAX)
|
||||
: getVideoAttributionText()}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -632,6 +689,57 @@ export function LayoutStudio({
|
||||
</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">Arial (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 className="mt-3">
|
||||
<label className="mb-1 block text-sm text-gray-400">
|
||||
Upload font (.ttf or .otf, max 10 MB)
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".ttf,.otf,font/ttf,font/otf"
|
||||
disabled={locked || fontUploading}
|
||||
onChange={(e) => void handleFont(e)}
|
||||
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white"
|
||||
/>
|
||||
{fontUploading && (
|
||||
<p className="mt-1 text-xs text-yellow-400">Uploading font…</p>
|
||||
)}
|
||||
{fontError && <p className="mt-1 text-xs text-red-400">{fontError}</p>}
|
||||
</div>
|
||||
)}
|
||||
{(W.fontKey === "custom" ||
|
||||
(W.fontKey &&
|
||||
W.fontKey !== "system" &&
|
||||
CURATED_FONTS.some((f) => f.key === (W.fontKey as CuratedFontKey)))) && (
|
||||
<p
|
||||
className="mt-2 text-xs text-gray-500"
|
||||
style={{ ...textFontStyle, fontSize: `${scaleFontToPreview(16, encodeWidth)}px` }}
|
||||
>
|
||||
Preview: The quick brown fox jumps over the lazy dog
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Watermark section */}
|
||||
<div className="mt-6 border-t border-gray-800 pt-5">
|
||||
<p className="mb-3 text-sm font-medium text-gray-300">Watermark</p>
|
||||
@@ -674,51 +782,6 @@ export function LayoutStudio({
|
||||
placeholder="Your brand name"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-gray-400">
|
||||
Font
|
||||
<select
|
||||
disabled={locked}
|
||||
value={W.fontKey ?? "system"}
|
||||
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
|
||||
className="input-field mt-1"
|
||||
>
|
||||
<option value="system">System default</option>
|
||||
{CURATED_FONTS.map((f) => (
|
||||
<option key={f.key} value={f.key}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">Custom upload (.ttf / .otf)</option>
|
||||
</select>
|
||||
</label>
|
||||
{W.fontKey === "custom" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-gray-400">
|
||||
Upload font (.ttf or .otf, max 10 MB)
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".ttf,.otf,font/ttf,font/otf"
|
||||
disabled={locked || fontUploading}
|
||||
onChange={(e) => void handleFont(e)}
|
||||
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white"
|
||||
/>
|
||||
{fontUploading && (
|
||||
<p className="mt-1 text-xs text-yellow-400">Uploading font…</p>
|
||||
)}
|
||||
{fontError && <p className="mt-1 text-xs text-red-400">{fontError}</p>}
|
||||
</div>
|
||||
)}
|
||||
{(W.fontKey === "custom" ||
|
||||
(W.fontKey &&
|
||||
W.fontKey !== "system" &&
|
||||
CURATED_FONTS.some(
|
||||
(f) => f.key === (W.fontKey as CuratedFontKey),
|
||||
))) && (
|
||||
<p className="text-xs text-gray-500" style={textFontStyle}>
|
||||
Preview: The quick brown fox jumps over the lazy dog
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -33,7 +33,16 @@ export function LegalFooter() {
|
||||
return (
|
||||
<footer className="mt-auto w-full border-t border-gray-800 bg-black/40 py-8 text-xs text-gray-500">
|
||||
<div className="mx-auto flex max-w-4xl flex-wrap items-center justify-center gap-x-4 gap-y-2 px-6 md:justify-start">
|
||||
<span>© {year} Songs2YT. All rights reserved.</span>
|
||||
<span>© {year} Songs2VID. 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>
|
||||
@@ -45,7 +54,7 @@ export function LegalFooter() {
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href="/refund">Refund Policy</LegalLink>
|
||||
<LegalLink href="https://docs.songs2vid.com" external>Documentation</LegalLink>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
|
||||
@@ -44,16 +44,13 @@ export function LegalPageLayout({ title, description, children }: Props) {
|
||||
<Link href="/terms" className="hover:text-gray-300">
|
||||
Terms
|
||||
</Link>
|
||||
<Link href="/refund" className="hover:text-gray-300">
|
||||
Refund
|
||||
</Link>
|
||||
<a
|
||||
href="https://status.atakanozban.com/status/2"
|
||||
href="https://docs.songs2vid.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-gray-300"
|
||||
>
|
||||
Service Status
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
import Link from "next/link";
|
||||
import { BRAND_NAME } from "@/lib/branding";
|
||||
|
||||
type Props = {
|
||||
href?: string;
|
||||
size?: "sm" | "md";
|
||||
size?: "sm" | "md" | "lg";
|
||||
};
|
||||
|
||||
export function Logo({ href = "/", size = "md" }: Props) {
|
||||
const textSize = size === "sm" ? "text-xl" : "text-2xl";
|
||||
const textSize = size === "sm" ? "text-xl" : size === "lg" ? "text-3xl" : "text-2xl";
|
||||
|
||||
const content = (
|
||||
<span className={`inline-flex items-baseline font-bold tracking-tight ${textSize}`}>
|
||||
<span className={`relative inline-flex items-baseline font-bold tracking-tight ${textSize}`}>
|
||||
<span className="text-white">Songs</span>
|
||||
<span className="text-red-400">2YT</span>
|
||||
<span className="text-red-400">2VID</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
const label = BRAND_NAME;
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className="inline-flex items-center" aria-label="Songs2YT">
|
||||
<Link href={href} className="inline-flex items-center" aria-label={label}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center" aria-label="Songs2YT">
|
||||
<span className="inline-flex items-center" aria-label={label}>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -79,8 +79,6 @@ export function PlaylistSelect({ value, onChange, enabled }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!enabled) { return null; }
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
@@ -133,7 +131,7 @@ export function PlaylistSelect({ value, onChange, enabled }: Props) {
|
||||
}
|
||||
}}
|
||||
className="input-field w-full"
|
||||
placeholder="My Songs2YT uploads"
|
||||
placeholder="My Songs2VID uploads"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Plan } from "@prisma/client";
|
||||
import { getResolutionsForPlan } from "@/lib/plans";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
plan?: Plan;
|
||||
};
|
||||
|
||||
export function ResolutionSelect({ value, onChange, disabled, plan = "FREE" }: Props) {
|
||||
const resolutions = getResolutionsForPlan(plan);
|
||||
export function ResolutionSelect({ value, onChange, disabled }: Props) {
|
||||
const resolutions = getResolutionsForPlan();
|
||||
|
||||
return (
|
||||
<select
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
type Direction = "up" | "left" | "right";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
direction?: Direction;
|
||||
};
|
||||
|
||||
const OFFSET: Record<Direction, string> = {
|
||||
up: "translate-y-10",
|
||||
left: "-translate-x-10",
|
||||
right: "translate-x-10",
|
||||
};
|
||||
|
||||
export function ScrollReveal({
|
||||
children,
|
||||
className = "",
|
||||
delay = 0,
|
||||
direction = "up",
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.unobserve(el);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.15, rootMargin: "0px 0px -40px 0px" },
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`transition-all duration-700 ease-out ${className} ${
|
||||
visible ? "translate-x-0 translate-y-0 opacity-100" : `opacity-0 ${OFFSET[direction]}`
|
||||
}`}
|
||||
style={{ transitionDelay: `${delay}ms` }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type RefObject } from "react";
|
||||
|
||||
type Props = {
|
||||
sectionRef: RefObject<HTMLElement | null>;
|
||||
title: string;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export function SectionScrollTitle({ sectionRef, title, offset = 350 }: Props) {
|
||||
const [offsetX, setOffsetX] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const section = sectionRef.current;
|
||||
if (!section) return;
|
||||
|
||||
const update = () => {
|
||||
const rect = section.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const progress = Math.min(
|
||||
1,
|
||||
Math.max(0, (viewportHeight - rect.top) / (viewportHeight + rect.height * 0.5)),
|
||||
);
|
||||
setOffsetX(progress * offset);
|
||||
};
|
||||
|
||||
update();
|
||||
window.addEventListener("scroll", update, { passive: true });
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", update);
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, [sectionRef, offset]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-0 left-1/2 z-0 select-none whitespace-nowrap text-7xl font-bold leading-none text-white/[0.04] sm:text-8xl lg:text-9xl"
|
||||
style={{ transform: `translateX(calc(-50% + ${offsetX}px))` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ScrollReveal } from "@/components/ScrollReveal";
|
||||
import { useInView } from "@/hooks/useInView";
|
||||
import { useMockupProgress } from "@/hooks/useMockupProgress";
|
||||
|
||||
function LayersIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SlidersIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3" />
|
||||
<circle cx="4" cy="14" r="2" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<circle cx="20" cy="16" r="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudUploadIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M12 16V8M12 8l-3 3M12 8l3 3" />
|
||||
<path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.9-1A4 4 0 1 1 17 18H7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<path d="M21 15l-5-5L5 21" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AudioIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
number: 1,
|
||||
title: "Single/Batch creation",
|
||||
desc: "One image, many audios, each becomes a separate video.",
|
||||
Icon: LayersIcon,
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: "Per-video settings",
|
||||
desc: "Title, tags, privacy, and category for every upload.",
|
||||
Icon: SlidersIcon,
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: "Direct upload",
|
||||
desc: "Connect YouTube once and publish automatically.",
|
||||
Icon: CloudUploadIcon,
|
||||
},
|
||||
] as const;
|
||||
|
||||
function CheckIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AppMockup() {
|
||||
const phaseOneMs = 3000;
|
||||
const phaseTwoMs = 2000;
|
||||
const initialWidth = "20%";
|
||||
const midWidth = "45%";
|
||||
const { ref, inView } = useInView();
|
||||
const { phase, processed, transitionMs } = useMockupProgress(inView, phaseOneMs, phaseTwoMs);
|
||||
|
||||
const width = phase === 0 ? initialWidth : phase === 1 ? midWidth : "100%";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="mt-10 overflow-hidden rounded-2xl border border-gray-700/60 bg-surface-dark shadow-2xl shadow-black/40"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-gray-700/50 px-4 py-3">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-gray-600" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-gray-600" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-gray-600" />
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="rounded-xl border border-gray-700/50 bg-surface p-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mockup-upload-box mockup-upload-box-image flex flex-col items-center justify-center rounded-lg border border-dashed border-gray-600 bg-surface-dark px-4 py-8">
|
||||
<ImageIcon className="mockup-icon-image mb-3 h-8 w-8 text-gray-500" />
|
||||
<span className="text-xs font-semibold tracking-widest text-gray-500">IMAGE</span>
|
||||
</div>
|
||||
<div className="mockup-upload-box mockup-upload-box-audio flex flex-col items-center justify-center rounded-lg border border-dashed border-gray-600 bg-surface-dark px-4 py-8">
|
||||
<AudioIcon className="mockup-icon-audio mb-3 h-8 w-8 text-gray-500" />
|
||||
<span className="text-xs font-semibold tracking-widest text-gray-500">AUDIO</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-sm text-gray-400">
|
||||
{processed ? (
|
||||
"Processed"
|
||||
) : (
|
||||
<>
|
||||
Processing
|
||||
<span className="mockup-dot-1">.</span>
|
||||
<span className="mockup-dot-2">.</span>
|
||||
<span className="mockup-dot-3">.</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="relative h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-gray-700">
|
||||
<div
|
||||
className="relative h-full rounded-full bg-white/90 ease-out"
|
||||
style={{
|
||||
width,
|
||||
transition: phase >= 1 ? `width ${transitionMs}ms ease-out` : "none",
|
||||
}}
|
||||
>
|
||||
{!processed && (
|
||||
<span className="mockup-progress-shimmer absolute inset-y-0 w-1/2 rounded-full bg-gradient-to-r from-transparent via-white/50 to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CheckIcon
|
||||
className={`h-4 w-4 shrink-0 text-accent transition-all duration-300 ${
|
||||
processed ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepCard({
|
||||
number,
|
||||
title,
|
||||
desc,
|
||||
Icon,
|
||||
}: {
|
||||
number: number;
|
||||
title: string;
|
||||
desc: string;
|
||||
Icon: typeof LayersIcon;
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-2xl border border-gray-700/50 bg-surface transition-all duration-300 hover:border-gray-500 hover:bg-surface-light hover:shadow-xl hover:shadow-black/30">
|
||||
<span
|
||||
className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 select-none text-[7.5rem] font-bold leading-none text-gray-600/20 transition-all duration-500 ease-out group-hover:left-1/2 group-hover:-translate-x-1/2 group-hover:scale-[1.18] group-hover:text-red-500/25 sm:text-[8.5rem]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{number}
|
||||
</span>
|
||||
|
||||
<div className="relative flex items-center gap-6 px-8 py-9 pl-24 sm:pl-28">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl border border-gray-600/50 bg-surface-dark text-gray-400 transition-all duration-300 group-hover:border-red-500/30 group-hover:text-red-400">
|
||||
<Icon className="h-7 w-7" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white transition-colors duration-300 group-hover:text-red-400 sm:text-xl">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-2 text-base leading-relaxed text-gray-400 transition-colors duration-300 group-hover:text-gray-300">
|
||||
{desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepsSection() {
|
||||
return (
|
||||
<section className="mx-auto max-w-6xl scroll-mt-20 px-6 pb-24 pt-32">
|
||||
<div className="grid items-center gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<ScrollReveal direction="left">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold leading-snug text-white sm:text-3xl">
|
||||
Upload your content in 3 simple steps:
|
||||
</h2>
|
||||
<p className="mt-6 text-base leading-relaxed text-gray-400">
|
||||
From a single track to a full batch, Songs2YT walks you through creating and
|
||||
publishing videos to YouTube without touching a video editor.
|
||||
</p>
|
||||
<AppMockup />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="flex flex-col gap-7">
|
||||
{STEPS.map((step, index) => (
|
||||
<ScrollReveal key={step.number} delay={index * 120} direction="right">
|
||||
<StepCard
|
||||
number={step.number}
|
||||
title={step.title}
|
||||
desc={step.desc}
|
||||
Icon={step.Icon}
|
||||
/>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { ScrollReveal } from "@/components/ScrollReveal";
|
||||
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
|
||||
import { GITEA_ISSUES_URL, SUPPORT_EMAIL } from "@/lib/plans";
|
||||
|
||||
type SupportCardProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
cta: string;
|
||||
external?: boolean;
|
||||
hover: string;
|
||||
titleHover: string;
|
||||
linkClass: string;
|
||||
linkHover: string;
|
||||
};
|
||||
|
||||
function SupportCard({
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
cta,
|
||||
external,
|
||||
hover,
|
||||
titleHover,
|
||||
linkClass,
|
||||
linkHover,
|
||||
}: SupportCardProps) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={external ? "_blank" : undefined}
|
||||
rel={external ? "noopener noreferrer" : undefined}
|
||||
className={`group block rounded-xl border border-gray-700/50 bg-surface/80 p-6 backdrop-blur-sm transition-all duration-300 ${hover}`}
|
||||
>
|
||||
<h3
|
||||
className={`mb-2 text-lg font-semibold text-white transition-colors duration-300 ${titleHover}`}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm leading-relaxed text-gray-400 transition-colors duration-300 group-hover:text-gray-300">
|
||||
{description}
|
||||
</p>
|
||||
<span
|
||||
className={`inline-flex text-sm font-medium transition-all duration-300 group-hover:underline ${linkClass} ${linkHover}`}
|
||||
>
|
||||
{cta}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const SUPPORT_CARDS: SupportCardProps[] = [
|
||||
{
|
||||
title: "🛠️ Community & Self-Hosting",
|
||||
description:
|
||||
"Found a bug, want to request a feature, or need help setting up your Docker instance? Open an issue on our self-hosted Gitea.",
|
||||
href: GITEA_ISSUES_URL,
|
||||
cta: "Open Gitea Issues",
|
||||
external: true,
|
||||
hover:
|
||||
"hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20",
|
||||
titleHover: "group-hover:text-[#609926]",
|
||||
linkClass: "text-[#609926]",
|
||||
linkHover: "group-hover:text-[#7ab33a]",
|
||||
},
|
||||
{
|
||||
title: "📩 Billing & Premium Support",
|
||||
description:
|
||||
"Have questions about your Pro subscription, custom limits, or Enterprise inquiries? Drop us an email.",
|
||||
href: `mailto:${SUPPORT_EMAIL}`,
|
||||
cta: SUPPORT_EMAIL,
|
||||
hover:
|
||||
"hover:border-accent/50 hover:bg-accent/[0.06] hover:shadow-lg hover:shadow-accent/20",
|
||||
titleHover: "group-hover:text-accent",
|
||||
linkClass: "text-accent",
|
||||
linkHover: "group-hover:text-accent-hover",
|
||||
},
|
||||
];
|
||||
|
||||
export function SupportSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id="support"
|
||||
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 pb-24 pt-24"
|
||||
>
|
||||
<SectionScrollTitle sectionRef={sectionRef} title="Support" />
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-4xl">
|
||||
<ScrollReveal>
|
||||
<h2 className="mb-4 text-center text-3xl font-bold text-white">Support</h2>
|
||||
<p className="mx-auto mb-10 max-w-2xl text-center text-gray-400">
|
||||
Community help for self-hosters, or reach us directly for billing and premium support.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mx-auto mt-10 grid grid-cols-1 gap-8 text-left md:grid-cols-2">
|
||||
{SUPPORT_CARDS.map((card, index) => (
|
||||
<ScrollReveal
|
||||
key={card.title}
|
||||
direction={index === 0 ? "left" : "right"}
|
||||
delay={index * 120}
|
||||
>
|
||||
<SupportCard {...card} />
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plan, Privacy } from "@prisma/client";
|
||||
import { Privacy } from "@prisma/client";
|
||||
import { filenameWithoutExtension } from "@/lib/constants";
|
||||
import { getVideoAttributionText } from "@/lib/branding";
|
||||
import { audioTagsToMetadata } from "@/lib/audio-tags";
|
||||
import { resolveYouTubeTitle } from "@/lib/titles";
|
||||
import { SONG_TITLE_MAX } from "@/lib/layout";
|
||||
import type { ItemMetadata } from "@/lib/types";
|
||||
import { DEFAULT_LAYOUT, type LayoutSettings } from "@/lib/layout";
|
||||
import { DEFAULT_WATERMARK, type WatermarkSettings } from "@/lib/watermark";
|
||||
@@ -14,6 +15,7 @@ import { LayoutStudio } from "./LayoutStudio";
|
||||
import { PlaylistSelect } from "./PlaylistSelect";
|
||||
import { PrivacyToggle } from "./PrivacyToggle";
|
||||
import { ResolutionSelect } from "./ResolutionSelect";
|
||||
|
||||
const UPLOAD_CONCURRENCY = 4;
|
||||
|
||||
type AudioItem = {
|
||||
@@ -43,6 +45,7 @@ function defaultMetadata(title = ""): ItemMetadata {
|
||||
playlistId: null,
|
||||
imagePath: null,
|
||||
artist: null,
|
||||
songTitle: null,
|
||||
watermark: { ...DEFAULT_WATERMARK },
|
||||
layout: { ...DEFAULT_LAYOUT },
|
||||
};
|
||||
@@ -63,7 +66,6 @@ export function UploadForm() {
|
||||
const [jobLayout, setJobLayout] = useState<LayoutSettings>({ ...DEFAULT_LAYOUT });
|
||||
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
|
||||
const [quota, setQuota] = useState<{
|
||||
plan: Plan;
|
||||
maxBatchSize: number;
|
||||
used: number;
|
||||
} | null>(null);
|
||||
@@ -73,7 +75,6 @@ export function UploadForm() {
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setQuota({
|
||||
plan: data.plan,
|
||||
maxBatchSize: data.maxBatchSize,
|
||||
used: data.used ?? 0,
|
||||
});
|
||||
@@ -198,10 +199,6 @@ export function UploadForm() {
|
||||
}
|
||||
|
||||
async function handleItemImageChange(itemId: string, file: File | null) {
|
||||
if (!true) {
|
||||
setError("Matching a unique image per audio requires Pro.");
|
||||
return;
|
||||
}
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
const preview = URL.createObjectURL(file);
|
||||
@@ -295,9 +292,9 @@ export function UploadForm() {
|
||||
if (!files.length) return;
|
||||
setError(null);
|
||||
|
||||
const maxBatch = quota?.maxBatchSize ?? 3;
|
||||
const maxBatch = quota?.maxBatchSize ?? 100;
|
||||
if (audioItems.length + files.length > maxBatch) {
|
||||
setError(`Your plan allows up to ${maxBatch} audio files per batch.`);
|
||||
setError(`You can upload up to ${maxBatch} audio files per batch.`);
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
@@ -338,17 +335,28 @@ export function UploadForm() {
|
||||
imagePath &&
|
||||
!imageUploading &&
|
||||
readyAudios.length > 0 &&
|
||||
readyAudios.every((a) => a.metadata.title.trim()) &&
|
||||
readyAudios.every((a) =>
|
||||
Boolean(
|
||||
resolveYouTubeTitle(
|
||||
{
|
||||
title: a.metadata.title,
|
||||
songTitle: a.metadata.songTitle,
|
||||
artist: a.metadata.artist,
|
||||
},
|
||||
),
|
||||
),
|
||||
) &&
|
||||
!submitting;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
async function submitJob() {
|
||||
if (!canSubmit || !imagePath) return;
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const effectiveWatermark: WatermarkSettings = jobWatermark;
|
||||
|
||||
const res = await fetch("/api/jobs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -359,16 +367,9 @@ export function UploadForm() {
|
||||
audioFilename: item.file.name,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
imagePath: true ? item.itemImagePath || item.metadata.imagePath || null : null,
|
||||
includeWatermark: jobWatermark.mode !== "none",
|
||||
watermark: true
|
||||
? jobWatermark
|
||||
: {
|
||||
mode: jobWatermark.mode === "none" ? "none" : "default",
|
||||
position: "bottom-right",
|
||||
offsetX: 20,
|
||||
offsetY: 20,
|
||||
},
|
||||
imagePath: item.itemImagePath || item.metadata.imagePath || null,
|
||||
includeWatermark: effectiveWatermark.mode !== "none",
|
||||
watermark: effectiveWatermark,
|
||||
layout: jobLayout,
|
||||
},
|
||||
})),
|
||||
@@ -377,9 +378,6 @@ export function UploadForm() {
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
if (data.code === "PREMIUM_REQUIRED") {
|
||||
throw new Error(data.error || "This feature requires Pro.");
|
||||
}
|
||||
throw new Error(data.error || "Failed to create job");
|
||||
}
|
||||
|
||||
@@ -390,11 +388,18 @@ export function UploadForm() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!canSubmit || !imagePath) return;
|
||||
|
||||
await submitJob();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{quota && (
|
||||
<div className="rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm text-gray-300">
|
||||
Self-hosted · {quota.used} videos processed · up to {quota.maxBatchSize} files per batch
|
||||
Self-hosted · {quota.used} videos created · up to {quota.maxBatchSize} files per batch
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -423,7 +428,7 @@ export function UploadForm() {
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Shared cover for all tracks. Optionally override per audio below.
|
||||
Shared cover for all tracks. You can override it per audio below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -452,14 +457,15 @@ export function UploadForm() {
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium text-white">Video details (per audio)</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Each audio file gets its own metadata. Title is auto-filled from the filename.
|
||||
Each audio file gets its own metadata. Video title is used for YouTube; song title and
|
||||
artist appear in art-track layouts.
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg border border-gray-700 bg-surface p-6">
|
||||
<PlaylistSelect
|
||||
value={playlistId}
|
||||
onChange={handlePlaylistChange}
|
||||
enabled={true}
|
||||
enabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -487,29 +493,40 @@ export function UploadForm() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Title">
|
||||
<Field label="Video title (YouTube)">
|
||||
<input
|
||||
type="text"
|
||||
value={item.metadata.title}
|
||||
onChange={(e) => updateItemMetadata(item.id, { title: e.target.value })}
|
||||
className="input-field"
|
||||
required
|
||||
placeholder="Title shown on YouTube"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Artist (art-track layouts)">
|
||||
<Field label="Song title (on-video)">
|
||||
<input
|
||||
type="text"
|
||||
value={item.metadata.artist ?? ""}
|
||||
onChange={(e) => updateItemMetadata(item.id, { artist: e.target.value })}
|
||||
value={item.metadata.songTitle ?? ""}
|
||||
onChange={(e) => updateItemMetadata(item.id, { songTitle: e.target.value })}
|
||||
className="input-field"
|
||||
placeholder="Optional shown in layout templates"
|
||||
maxLength={80}
|
||||
placeholder="Shown in art-track layout"
|
||||
maxLength={SONG_TITLE_MAX}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Artist (on-video)">
|
||||
<input
|
||||
type="text"
|
||||
value={item.metadata.artist ?? ""}
|
||||
onChange={(e) => updateItemMetadata(item.id, { artist: e.target.value })}
|
||||
className="input-field"
|
||||
placeholder="Shown in art-track layout"
|
||||
maxLength={80}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Category">
|
||||
<CategorySelect
|
||||
value={item.metadata.categoryId}
|
||||
@@ -548,19 +565,14 @@ export function UploadForm() {
|
||||
<ResolutionSelect
|
||||
value={item.metadata.resolution}
|
||||
onChange={(v) => updateItemMetadata(item.id, { resolution: v })}
|
||||
plan={quota?.plan}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="relative rounded border border-gray-800 bg-surface-light/40 p-3">
|
||||
|
||||
<Field
|
||||
label="Cover for this track (optional)"
|
||||
>
|
||||
<Field label="Cover for this track (optional)">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
disabled={false}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0] ?? null;
|
||||
void handleItemImageChange(item.id, f);
|
||||
@@ -574,7 +586,6 @@ export function UploadForm() {
|
||||
{item.itemImagePath ? `Using ${item.itemImageName}` : `Uploading ${item.itemImageName}…`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
@@ -598,7 +609,6 @@ export function UploadForm() {
|
||||
checked={item.metadata.creativeCommons}
|
||||
onChange={(v) => updateItemMetadata(item.id, { creativeCommons: v })}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -610,8 +620,13 @@ export function UploadForm() {
|
||||
previewImageUrl={
|
||||
audioItems.find((a) => a.itemImagePreview)?.itemImagePreview || imagePreviewUrl
|
||||
}
|
||||
title={audioItems[0]?.metadata.title || "Track title"}
|
||||
songTitle={
|
||||
audioItems[0]?.metadata.songTitle ||
|
||||
audioItems[0]?.metadata.title ||
|
||||
"Track title"
|
||||
}
|
||||
artist={audioItems[0]?.metadata.artist || ""}
|
||||
encodeWidth={parseInt(readyAudios[0]?.metadata.resolution?.split("x")[0] || "1280", 10)}
|
||||
layout={jobLayout}
|
||||
onLayoutChange={applyLayoutToAll}
|
||||
watermark={jobWatermark}
|
||||
@@ -621,8 +636,6 @@ export function UploadForm() {
|
||||
logoPreviewUrl={logoPreviewUrl}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
|
||||
type Props = {
|
||||
enabled: boolean;
|
||||
locked?: boolean;
|
||||
locked: boolean;
|
||||
previewImageUrl: string | null;
|
||||
value: WatermarkSettings;
|
||||
onChange: (next: WatermarkSettings) => void;
|
||||
@@ -86,7 +86,7 @@ function previewFontFamily(fontKey: WatermarkFontKey | undefined): string {
|
||||
|
||||
export function WatermarkPreview({
|
||||
enabled,
|
||||
locked = false,
|
||||
locked,
|
||||
previewImageUrl,
|
||||
value,
|
||||
onChange,
|
||||
@@ -198,9 +198,10 @@ export function WatermarkPreview({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative rounded-lg border border-gray-700 bg-surface p-6"
|
||||
className={`relative rounded-lg border border-gray-700 bg-surface p-6 ${
|
||||
locked ? "opacity-80" : ""
|
||||
}`}
|
||||
>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-lg font-medium text-white">Watermark layout</h3>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,36 @@
|
||||
# Local infra only (Postgres + Redis). Use root docker-compose.yml for full stack.
|
||||
# Host ports 5433/6380 avoid clashes with other local stacks on 5432/6379.
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: s2yt
|
||||
POSTGRES_PASSWORD: s2yt
|
||||
POSTGRES_DB: s2yt
|
||||
POSTGRES_USER: songs2vid
|
||||
POSTGRES_PASSWORD: songs2vid
|
||||
POSTGRES_DB: songs2vid
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
- "127.0.0.1:5433:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U songs2vid -d songs2vid"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
- "127.0.0.1:6380:6379"
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
@@ -3,13 +3,13 @@ services:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: s2yt
|
||||
POSTGRES_PASSWORD: s2yt
|
||||
POSTGRES_DB: s2yt
|
||||
POSTGRES_USER: songs2vid
|
||||
POSTGRES_PASSWORD: songs2vid
|
||||
POSTGRES_DB: songs2vid
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U s2yt -d s2yt"]
|
||||
test: ["CMD-SHELL", "pg_isready -U songs2vid -d songs2vid"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
@@ -30,12 +30,11 @@ services:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${S2YT_PORT:-3000}:3000"
|
||||
- "${S2VID_PORT:-3000}:3000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
S2VID_EDITION: selfhosted
|
||||
DATABASE_URL: postgresql://s2yt:s2yt@postgres:5432/s2yt
|
||||
DATABASE_URL: postgresql://songs2vid:songs2vid@postgres:5432/songs2vid
|
||||
REDIS_URL: redis://redis:6379
|
||||
UPLOAD_DIR: /app/uploads
|
||||
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
|
||||
@@ -55,8 +54,7 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
S2VID_EDITION: selfhosted
|
||||
DATABASE_URL: postgresql://s2yt:s2yt@postgres:5432/s2yt
|
||||
DATABASE_URL: postgresql://songs2vid:songs2vid@postgres:5432/songs2vid
|
||||
REDIS_URL: redis://redis:6379
|
||||
UPLOAD_DIR: /app/uploads
|
||||
volumes:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { findUserByApiKey } from "./api-keys";
|
||||
import { checkApiRateLimit } from "./api-rate-limit";
|
||||
import { hasProFeatures } from "./edition";
|
||||
import { getSessionUser } from "./session";
|
||||
|
||||
function extractBearerToken(req: NextRequest) {
|
||||
@@ -10,7 +9,7 @@ function extractBearerToken(req: NextRequest) {
|
||||
return auth.slice(7).trim();
|
||||
}
|
||||
|
||||
export async function requirePaidApiUser(req: NextRequest) {
|
||||
export async function requireApiUser(req: NextRequest) {
|
||||
const token = extractBearerToken(req);
|
||||
if (!token) {
|
||||
return {
|
||||
@@ -30,16 +29,6 @@ export async function requirePaidApiUser(req: NextRequest) {
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasProFeatures(user.plan)) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: "API access is unavailable for this account" },
|
||||
{ status: 403 },
|
||||
),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!user.youtubeConnection) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
@@ -70,8 +59,8 @@ export async function requirePaidApiUser(req: NextRequest) {
|
||||
return { error: null, user };
|
||||
}
|
||||
|
||||
export async function requirePaidUserFromSessionOrApi(req: NextRequest) {
|
||||
const apiResult = await requirePaidApiUser(req);
|
||||
export async function requireUserFromSessionOrApi(req: NextRequest) {
|
||||
const apiResult = await requireApiUser(req);
|
||||
if (apiResult.user) return apiResult;
|
||||
|
||||
const sessionUser = await getSessionUser();
|
||||
@@ -84,16 +73,6 @@ export async function requirePaidUserFromSessionOrApi(req: NextRequest) {
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasProFeatures(sessionUser.plan)) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: "API access is unavailable for this account" },
|
||||
{ status: 403 },
|
||||
),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!sessionUser.youtubeConnection) {
|
||||
return {
|
||||
error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }),
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import IORedis from "ioredis";
|
||||
import { prisma } from "./db";
|
||||
import { isSelfHostedEdition } from "./edition";
|
||||
|
||||
export const DEFAULT_API_RATE_LIMIT = 60;
|
||||
export const DEFAULT_API_RATE_LIMIT = 100_000;
|
||||
export const API_RATE_WINDOW_SECONDS = 60;
|
||||
export const MAX_ADMIN_API_RATE_BONUS = 120;
|
||||
const SELFHOSTED_API_RATE_LIMIT = 100_000;
|
||||
|
||||
type MemoryBucket = {
|
||||
count: number;
|
||||
@@ -36,13 +31,8 @@ function rateKey(userId: string) {
|
||||
}
|
||||
|
||||
export async function getEffectiveApiRateLimit(userId: string) {
|
||||
if (isSelfHostedEdition()) return SELFHOSTED_API_RATE_LIMIT;
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { apiRateLimitBonus: true, plan: true },
|
||||
});
|
||||
if (!user || user.plan !== "PREMIUM") return DEFAULT_API_RATE_LIMIT;
|
||||
return DEFAULT_API_RATE_LIMIT + Math.max(0, user.apiRateLimitBonus);
|
||||
void userId;
|
||||
return DEFAULT_API_RATE_LIMIT;
|
||||
}
|
||||
|
||||
function memoryStatus(userId: string, limit: number) {
|
||||
@@ -120,9 +110,6 @@ function checkMemoryRateLimit(userId: string, limit: number) {
|
||||
}
|
||||
|
||||
export async function checkApiRateLimit(userId: string) {
|
||||
if (isSelfHostedEdition()) {
|
||||
return { ok: true as const, limit: SELFHOSTED_API_RATE_LIMIT, remaining: SELFHOSTED_API_RATE_LIMIT };
|
||||
}
|
||||
const limit = await getEffectiveApiRateLimit(userId);
|
||||
const client = getRedis();
|
||||
if (!client) return checkMemoryRateLimit(userId, limit);
|
||||
|
||||
@@ -31,17 +31,21 @@ export async function readAudioTags(filePath: string): Promise<AudioTagMetadata
|
||||
export function audioTagsToMetadata(
|
||||
tags: AudioTagMetadata,
|
||||
fallbackTitle: string,
|
||||
): Pick<ItemMetadata, "title" | "description" | "tags"> {
|
||||
const title =
|
||||
tags.title ||
|
||||
(tags.artist ? `${tags.artist}${tags.album ? ` - ${tags.album}` : ""}` : fallbackTitle);
|
||||
): Pick<ItemMetadata, "title" | "description" | "tags" | "artist" | "songTitle"> {
|
||||
const songTitle = tags.title || fallbackTitle;
|
||||
const videoTitle =
|
||||
tags.artist && tags.title
|
||||
? `${tags.artist} - ${tags.title}`
|
||||
: tags.title || fallbackTitle;
|
||||
|
||||
const description = [tags.artist, tags.album, tags.year].filter(Boolean).join(" · ");
|
||||
const tagParts = [tags.genre, tags.artist].filter(Boolean);
|
||||
|
||||
return {
|
||||
title,
|
||||
title: videoTitle,
|
||||
songTitle,
|
||||
description,
|
||||
tags: tagParts.join(", "),
|
||||
artist: tags.artist || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { NextAuthOptions } from "next-auth";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
import { encryptSecret } from "./crypto/secrets";
|
||||
import { prisma } from "./db";
|
||||
import { getInitialQuotaResetAt } from "./quota";
|
||||
import { fetchYouTubeChannel } from "./youtube/upload";
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
@@ -36,7 +35,6 @@ export const authOptions: NextAuthOptions = {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
quotaResetAt: getInitialQuotaResetAt(),
|
||||
},
|
||||
update: {
|
||||
name: user.name,
|
||||
@@ -87,7 +85,6 @@ export const authOptions: NextAuthOptions = {
|
||||
});
|
||||
if (dbUser) {
|
||||
session.user.id = dbUser.id;
|
||||
session.user.plan = dbUser.plan;
|
||||
session.user.youtubeConnected = !!dbUser.youtubeConnection;
|
||||
session.user.channelTitle = dbUser.youtubeConnection?.channelTitle;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const BRAND_NAME = "Songs2VID";
|
||||
export const BRAND_DOMAIN = `${BRAND_NAME}.com`;
|
||||
export const BRAND_DOMAIN = "songs2vid.com";
|
||||
export const VIDEO_ATTRIBUTION_PREFIX = "Uploaded through";
|
||||
|
||||
export function getVideoAttributionText() {
|
||||
return `${VIDEO_ATTRIBUTION_PREFIX} ${BRAND_DOMAIN}`;
|
||||
return `${VIDEO_ATTRIBUTION_PREFIX} ${BRAND_NAME}.com`;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
export const FREE_PLAN = {
|
||||
monthlyQuota: 14,
|
||||
maxFileSizeBytes: 30 * 1024 * 1024,
|
||||
maxBatchSize: 3,
|
||||
watermarkOptional: true,
|
||||
} as const;
|
||||
|
||||
export const RESOLUTIONS = [
|
||||
{ value: "1920x1080", label: "1920x1080 (16:9)", width: 1920, height: 1080 },
|
||||
{ value: "1280x720", label: "1280x720 (16:9)", width: 1280, height: 720 },
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import { Plan } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Self-hosted / OSS edition. Prefer S2VID_EDITION; S2YT_EDITION kept for older compose files.
|
||||
*/
|
||||
export function isSelfHostedEdition(): boolean {
|
||||
const edition =
|
||||
process.env.S2VID_EDITION?.trim() || process.env.S2YT_EDITION?.trim() || "";
|
||||
// Default to self-hosted for this OSS package when unset
|
||||
if (!edition) return true;
|
||||
return edition === "selfhosted";
|
||||
}
|
||||
|
||||
/** All Pro features are unlocked in the OSS / self-hosted edition. */
|
||||
export function hasProFeatures(_plan?: Plan): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function hasProFeatures(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,41 +1,11 @@
|
||||
import { Plan } from "@prisma/client";
|
||||
import type { LayoutSettings } from "./layout";
|
||||
import {
|
||||
PREMIUM_REQUIRED_CODE,
|
||||
type WatermarkSettings,
|
||||
} from "./watermark";
|
||||
import type { WatermarkSettings } from "./watermark";
|
||||
|
||||
export class PremiumRequiredError extends Error {
|
||||
readonly code = PREMIUM_REQUIRED_CODE;
|
||||
readonly status = 403;
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "PremiumRequiredError";
|
||||
}
|
||||
}
|
||||
export function assertCustomWatermarkAllowed(_settings: WatermarkSettings) {}
|
||||
|
||||
/** OSS: all watermark / typography features unlocked. */
|
||||
export function assertCustomWatermarkAllowed(_plan: Plan, _settings: WatermarkSettings) {
|
||||
return;
|
||||
}
|
||||
export function assertArtTrackLayoutAllowed(_settings: LayoutSettings) {}
|
||||
|
||||
/** OSS: all art-track layout features unlocked. */
|
||||
export function assertArtTrackLayoutAllowed(_plan: Plan, _settings: LayoutSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** OSS: per-track cover images always allowed. */
|
||||
export function assertPerItemImagesAllowed(
|
||||
_plan: Plan,
|
||||
_sharedImagePath: string,
|
||||
_itemImagePaths: Array<string | null | undefined>,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
export function premiumRequiredResponse(message: string) {
|
||||
return {
|
||||
error: message,
|
||||
code: PREMIUM_REQUIRED_CODE,
|
||||
};
|
||||
}
|
||||
) {}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Modular FFmpeg filter fragments for the Songs2VID brand watermark overlay.
|
||||
* Always bottom-right with padding; scales to a fraction of frame width.
|
||||
*/
|
||||
export function buildBrandWatermarkOverlayFilters(options: {
|
||||
baseLabel: string;
|
||||
watermarkInputIndex: number;
|
||||
watermarkWidthPx: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
outLabel?: string;
|
||||
}): string[] {
|
||||
const ox = options.offsetX ?? 20;
|
||||
const oy = options.offsetY ?? 20;
|
||||
const out = options.outLabel ?? "vout";
|
||||
return [
|
||||
`[${options.watermarkInputIndex}:v]scale=${options.watermarkWidthPx}:-1[wm]`,
|
||||
`[${options.baseLabel}][wm]overlay=W-w-${ox}:H-h-${oy}[${out}]`,
|
||||
];
|
||||
}
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
buildArtTrackFilterComplex,
|
||||
type LayoutSettings,
|
||||
} from "../layout";
|
||||
import {
|
||||
WATERMARK_WIDTH_FRACTION,
|
||||
watermarkFontSizeForWidth,
|
||||
} from "../preview-typography";
|
||||
import { getWatermarkPath } from "../storage";
|
||||
import {
|
||||
buildDrawtextFilter,
|
||||
@@ -21,6 +25,7 @@ import {
|
||||
sanitizeDrawtext,
|
||||
type WatermarkSettings,
|
||||
} from "../watermark";
|
||||
import { buildBrandWatermarkOverlayFilters } from "./brand-watermark";
|
||||
|
||||
function getFfmpegPath(): string {
|
||||
if (process.env.FFMPEG_PATH) return process.env.FFMPEG_PATH;
|
||||
@@ -102,7 +107,8 @@ export async function encodeVideo(options: {
|
||||
includeWatermark: boolean;
|
||||
watermark?: Partial<WatermarkSettings> | null;
|
||||
layout?: LayoutSettings | null;
|
||||
title?: string;
|
||||
/** On-video song title (art-track layouts). */
|
||||
songTitle?: string;
|
||||
artist?: string | null;
|
||||
}): Promise<void> {
|
||||
const res = getResolution(options.resolution);
|
||||
@@ -112,8 +118,8 @@ export async function encodeVideo(options: {
|
||||
|
||||
const settings = normalizeWatermarkSettings(options.watermark, options.includeWatermark);
|
||||
const layout = options.layout?.template ? options.layout : null;
|
||||
const watermarkWidth = Math.max(1, Math.round(res.width * 0.32));
|
||||
const fontSize = Math.max(16, Math.round(res.width * 0.018));
|
||||
const watermarkWidth = Math.max(1, Math.round(res.width * WATERMARK_WIDTH_FRACTION));
|
||||
const fontSize = watermarkFontSizeForWidth(res.width);
|
||||
const fontfile = await resolveFontfileEscaped(settings);
|
||||
|
||||
const args = ["-y", "-loop", "1", "-r", "1", "-i", options.imagePath, "-i", options.audioPath];
|
||||
@@ -174,7 +180,7 @@ export async function encodeVideo(options: {
|
||||
const filterParts: string[] = [];
|
||||
|
||||
if (layout) {
|
||||
const titleEscaped = sanitizeDrawtext(options.title?.trim() || "Untitled");
|
||||
const titleEscaped = sanitizeDrawtext(options.songTitle?.trim() || "Untitled");
|
||||
const artistRaw = options.artist?.trim();
|
||||
const artistEscaped = artistRaw ? sanitizeDrawtext(artistRaw) : null;
|
||||
filterParts.push(
|
||||
@@ -212,10 +218,14 @@ export async function encodeVideo(options: {
|
||||
});
|
||||
filterParts.push(`[${baseLabel}]${draw}[vout]`);
|
||||
} else if (applyWm === "default-png" && defaultWmInputIndex !== null) {
|
||||
const { x, y } = overlayXy(settings.position, settings.offsetX, settings.offsetY);
|
||||
filterParts.push(
|
||||
`[${defaultWmInputIndex}:v]scale=${watermarkWidth}:-1[wm]`,
|
||||
`[${baseLabel}][wm]overlay=${x}:${y}[vout]`,
|
||||
...buildBrandWatermarkOverlayFilters({
|
||||
baseLabel,
|
||||
watermarkInputIndex: defaultWmInputIndex,
|
||||
watermarkWidthPx: watermarkWidth,
|
||||
offsetX: settings.offsetX,
|
||||
offsetY: settings.offsetY,
|
||||
}),
|
||||
);
|
||||
} else if (applyWm === "default-text") {
|
||||
const draw = buildDrawtextFilter({
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { Plan, Privacy } from "@prisma/client";
|
||||
import { Privacy } from "@prisma/client";
|
||||
import { readAudioTags } from "../audio-tags";
|
||||
import { isAllowedResolution } from "../constants";
|
||||
import { filenameWithoutExtension, isAllowedResolution } from "../constants";
|
||||
import { prisma } from "../db";
|
||||
import { hasProFeatures } from "../edition";
|
||||
import {
|
||||
assertArtTrackLayoutAllowed,
|
||||
assertCustomWatermarkAllowed,
|
||||
assertPerItemImagesAllowed,
|
||||
PremiumRequiredError,
|
||||
} from "../entitlements";
|
||||
import { FONT_UPLOAD_MAX_BYTES } from "../fonts";
|
||||
import { assertFontFile } from "../fonts-server";
|
||||
@@ -19,6 +17,7 @@ import {
|
||||
ARTIST_MAX,
|
||||
INVALID_LAYOUT_TEMPLATE_MESSAGE,
|
||||
normalizeLayoutSettings,
|
||||
SONG_TITLE_MAX,
|
||||
type LayoutSettings,
|
||||
} from "../layout";
|
||||
import { enqueueVideoJob } from "../queue/client";
|
||||
@@ -27,7 +26,7 @@ import {
|
||||
isAudioExtensionAllowed,
|
||||
isResolutionAllowedForPlan,
|
||||
} from "../plans";
|
||||
import { releaseReservationSplit, reserveQuota } from "../quota";
|
||||
import { reserveQuota } from "../quota";
|
||||
import { getJobDir } from "../storage";
|
||||
import {
|
||||
assertPathInUserUploads,
|
||||
@@ -35,10 +34,12 @@ import {
|
||||
sanitizeUploadSessionKey,
|
||||
} from "../upload-paths";
|
||||
import type { CreateJobPayload, ItemMetadata } from "../types";
|
||||
import { resolveBurnedSongTitle, resolveYouTubeTitle, validateYouTubeTitle } from "../titles";
|
||||
import {
|
||||
normalizeWatermarkSettings,
|
||||
WATERMARK_TEXT_MAX,
|
||||
} from "../watermark";
|
||||
import { applyBrandWatermarkPolicy } from "../watermark-policy";
|
||||
|
||||
/** Merge nested + flat layout fields, then normalize (throws on bad template / coords). */
|
||||
export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSettings {
|
||||
@@ -83,8 +84,11 @@ export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSetting
|
||||
});
|
||||
}
|
||||
|
||||
export function validateItemMetadata(metadata: CreateJobPayload["items"][0]["metadata"]) {
|
||||
if (!metadata.title?.trim()) return "Each video must have a title";
|
||||
export function validateItemMetadata(
|
||||
metadata: CreateJobPayload["items"][0]["metadata"],
|
||||
) {
|
||||
const titleErr = validateYouTubeTitle(metadata);
|
||||
if (titleErr) return titleErr;
|
||||
if (!isAllowedResolution(metadata.resolution)) return "Invalid resolution";
|
||||
if (!["PUBLIC", "PRIVATE", "UNLISTED"].includes(metadata.privacy)) {
|
||||
return "Invalid privacy setting";
|
||||
@@ -95,6 +99,9 @@ export function validateItemMetadata(metadata: CreateJobPayload["items"][0]["met
|
||||
if (metadata.artist && metadata.artist.length > ARTIST_MAX) {
|
||||
return `Artist must be at most ${ARTIST_MAX} characters`;
|
||||
}
|
||||
if (metadata.songTitle && metadata.songTitle.length > SONG_TITLE_MAX) {
|
||||
return `Song title must be at most ${SONG_TITLE_MAX} characters`;
|
||||
}
|
||||
try {
|
||||
resolveLayoutFromMetadata(metadata);
|
||||
} catch (err) {
|
||||
@@ -106,7 +113,7 @@ export function validateItemMetadata(metadata: CreateJobPayload["items"][0]["met
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function validateJobPayload(user: { id: string; plan: Plan }, body: CreateJobPayload) {
|
||||
export async function validateJobPayload(user: { id: string }, body: CreateJobPayload) {
|
||||
if (!body.imagePath || !body.items?.length) {
|
||||
return "Image and at least one audio file required";
|
||||
}
|
||||
@@ -116,11 +123,8 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
|
||||
for (const item of body.items) {
|
||||
const metaError = validateItemMetadata(item.metadata);
|
||||
if (metaError) return metaError;
|
||||
if (!isResolutionAllowedForPlan(item.metadata.resolution, user.plan)) {
|
||||
return `Resolution ${item.metadata.resolution} is not available on your plan`;
|
||||
}
|
||||
if (item.metadata.playlistId && !hasProFeatures(user.plan)) {
|
||||
return "Adding videos to a YouTube playlist is unavailable for this account";
|
||||
if (!isResolutionAllowedForPlan(item.metadata.resolution)) {
|
||||
return `Resolution ${item.metadata.resolution} is not available`;
|
||||
}
|
||||
|
||||
const wm = normalizeWatermarkSettings(
|
||||
@@ -128,10 +132,9 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
|
||||
item.metadata.includeWatermark,
|
||||
);
|
||||
try {
|
||||
assertCustomWatermarkAllowed(user.plan, wm);
|
||||
assertArtTrackLayoutAllowed(user.plan, resolveLayoutFromMetadata(item.metadata));
|
||||
assertCustomWatermarkAllowed(wm);
|
||||
assertArtTrackLayoutAllowed(resolveLayoutFromMetadata(item.metadata));
|
||||
} catch (err) {
|
||||
if (err instanceof PremiumRequiredError) return err.message;
|
||||
if (err instanceof Error && err.message === INVALID_LAYOUT_TEMPLATE_MESSAGE) {
|
||||
return INVALID_LAYOUT_TEMPLATE_MESSAGE;
|
||||
}
|
||||
@@ -142,13 +145,12 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
|
||||
}
|
||||
|
||||
try {
|
||||
assertPerItemImagesAllowed(user.plan, body.imagePath, itemImagePaths);
|
||||
assertPerItemImagesAllowed(body.imagePath, itemImagePaths);
|
||||
} catch (err) {
|
||||
if (err instanceof PremiumRequiredError) return err.message;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const limits = getPlanLimits(user.plan);
|
||||
const limits = getPlanLimits();
|
||||
|
||||
try {
|
||||
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
|
||||
@@ -159,8 +161,8 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
|
||||
}
|
||||
|
||||
for (const item of body.items) {
|
||||
if (!isAudioExtensionAllowed(item.audioFilename, user.plan)) {
|
||||
return `Audio file ${item.audioFilename} is not supported on your plan`;
|
||||
if (!isAudioExtensionAllowed(item.audioFilename)) {
|
||||
return `Audio file ${item.audioFilename} is not supported`;
|
||||
}
|
||||
const audioPath = assertPathInUserUploads(user.id, item.audioPath);
|
||||
await fs.access(audioPath);
|
||||
@@ -194,7 +196,7 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
|
||||
if (wm.mode === "text" && !wm.text?.trim()) {
|
||||
return "Watermark text mode requires non-empty text";
|
||||
}
|
||||
if (wm.mode === "text" && wm.fontKey === "custom") {
|
||||
if (wm.fontKey === "custom") {
|
||||
if (!wm.fontPath) {
|
||||
return "Custom font selected but no font file uploaded";
|
||||
}
|
||||
@@ -208,7 +210,6 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof PremiumRequiredError) return err.message;
|
||||
if (err instanceof Error && err.message === "Invalid upload path") {
|
||||
return "Invalid upload path";
|
||||
}
|
||||
@@ -229,7 +230,7 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
|
||||
}
|
||||
|
||||
export async function createVideoJob(
|
||||
user: { id: string; plan: Plan },
|
||||
user: { id: string },
|
||||
body: CreateJobPayload,
|
||||
) {
|
||||
const validationError = await validateJobPayload(user, body);
|
||||
@@ -237,13 +238,7 @@ export async function createVideoJob(
|
||||
if (validationError === INVALID_LAYOUT_TEMPLATE_MESSAGE) {
|
||||
throw new Error(validationError);
|
||||
}
|
||||
const isPremium =
|
||||
validationError.includes("requires Pro") ||
|
||||
validationError.includes("Pro plan");
|
||||
const err = isPremium
|
||||
? new PremiumRequiredError(validationError)
|
||||
: new Error(validationError);
|
||||
throw err;
|
||||
throw new Error(validationError);
|
||||
}
|
||||
|
||||
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
|
||||
@@ -262,7 +257,7 @@ export async function createVideoJob(
|
||||
: null,
|
||||
}));
|
||||
|
||||
const reservation = await reserveQuota(user.id, items.length);
|
||||
await reserveQuota(user.id, items.length);
|
||||
|
||||
try {
|
||||
const job = await prisma.job.create({
|
||||
@@ -270,8 +265,8 @@ export async function createVideoJob(
|
||||
userId: user.id,
|
||||
imagePath,
|
||||
items: {
|
||||
create: items.map((item) => {
|
||||
const wm = normalizeWatermarkSettings(
|
||||
create: items.map((item, index) => {
|
||||
const wm = applyBrandWatermarkPolicy(
|
||||
item.metadata.watermark
|
||||
? {
|
||||
...item.metadata.watermark,
|
||||
@@ -282,10 +277,16 @@ export async function createVideoJob(
|
||||
item.metadata.includeWatermark,
|
||||
);
|
||||
const layout = resolveLayoutFromMetadata(item.metadata);
|
||||
const youtubeTitle = resolveYouTubeTitle(item.metadata);
|
||||
const burnedTitle = resolveBurnedSongTitle(
|
||||
item.metadata,
|
||||
filenameWithoutExtension(item.audioFilename),
|
||||
);
|
||||
return {
|
||||
audioPath: item.audioPath,
|
||||
audioFilename: item.audioFilename,
|
||||
title: item.metadata.title.trim(),
|
||||
title: youtubeTitle,
|
||||
songTitle: burnedTitle,
|
||||
description: item.metadata.description || "",
|
||||
tags: item.metadata.tags || "",
|
||||
privacy: item.metadata.privacy as Privacy,
|
||||
@@ -300,9 +301,9 @@ export async function createVideoJob(
|
||||
watermarkMode: wm.mode,
|
||||
watermarkText: wm.mode === "text" ? wm.text?.trim() || null : null,
|
||||
watermarkLogoPath: wm.mode === "logo" ? item.watermarkLogoPath : null,
|
||||
watermarkFontKey: wm.mode === "text" ? wm.fontKey ?? "system" : null,
|
||||
watermarkFontKey: wm.fontKey ?? "system",
|
||||
watermarkFontPath:
|
||||
wm.mode === "text" && wm.fontKey === "custom" ? item.watermarkFontPath : null,
|
||||
wm.fontKey === "custom" ? item.watermarkFontPath : null,
|
||||
watermarkPosition: wm.position,
|
||||
watermarkOffsetX: wm.offsetX,
|
||||
watermarkOffsetY: wm.offsetY,
|
||||
@@ -315,7 +316,6 @@ export async function createVideoJob(
|
||||
textOffsetX: layout.textOffsetX,
|
||||
textOffsetY: layout.textOffsetY,
|
||||
playlistId: item.metadata.playlistId?.trim() || null,
|
||||
billingSource: "QUOTA",
|
||||
};
|
||||
}),
|
||||
},
|
||||
@@ -398,7 +398,6 @@ export async function createVideoJob(
|
||||
|
||||
return job;
|
||||
} catch (err) {
|
||||
await releaseReservationSplit(user.id, reservation).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -409,15 +408,11 @@ export async function saveUploadedFile(
|
||||
userId: string,
|
||||
file: File,
|
||||
type: UploadFileType,
|
||||
plan: Plan,
|
||||
options?: { sessionKey?: string },
|
||||
) {
|
||||
const limits = getPlanLimits(plan);
|
||||
const limits = getPlanLimits();
|
||||
|
||||
if (type === "font") {
|
||||
if (!limits.customWatermark && !hasProFeatures(plan)) {
|
||||
throw new PremiumRequiredError("Custom watermark fonts require Pro.");
|
||||
}
|
||||
if (file.size > FONT_UPLOAD_MAX_BYTES) {
|
||||
throw new Error("Font file must be 10 MB or smaller");
|
||||
}
|
||||
@@ -429,9 +424,6 @@ export async function saveUploadedFile(
|
||||
}
|
||||
|
||||
if (type === "logo") {
|
||||
if (!limits.customWatermark && !hasProFeatures(plan)) {
|
||||
throw new PremiumRequiredError("Custom logo watermarks require Pro.");
|
||||
}
|
||||
const nameOk = /\.png$/i.test(file.name);
|
||||
const typeOk = file.type === "image/png" || file.type === "";
|
||||
if (!nameOk && !typeOk) {
|
||||
@@ -466,9 +458,9 @@ export async function saveUploadedFile(
|
||||
) {
|
||||
throw new Error("Invalid audio file type");
|
||||
}
|
||||
if (!isAudioExtensionAllowed(file.name, plan)) {
|
||||
if (!isAudioExtensionAllowed(file.name)) {
|
||||
const allowed = limits.allowedAudioExtensions.join(", ");
|
||||
throw new Error(`Your plan supports ${allowed} audio files only`);
|
||||
throw new Error(`Supported audio formats: ${allowed}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { Plan } from "@prisma/client";
|
||||
import { hasProFeatures } from "../edition";
|
||||
import type { CreateJobPayload, CreatePlaylistRequest, ItemMetadata } from "../types";
|
||||
import { createYouTubePlaylist } from "../youtube/upload";
|
||||
|
||||
@@ -22,7 +20,7 @@ export function parseCreatePlaylistInput(raw: unknown): CreatePlaylistRequest |
|
||||
}
|
||||
|
||||
export async function applyCreatePlaylistToItems(
|
||||
user: { id: string; plan: Plan },
|
||||
user: { id: string },
|
||||
items: CreateJobPayload["items"],
|
||||
createPlaylist: CreatePlaylistRequest | null | undefined,
|
||||
) {
|
||||
@@ -30,10 +28,6 @@ export async function applyCreatePlaylistToItems(
|
||||
return { items, playlist: null as Awaited<ReturnType<typeof createYouTubePlaylist>> | null };
|
||||
}
|
||||
|
||||
if (!hasProFeatures(user.plan)) {
|
||||
throw new Error("Creating a YouTube playlist requires the Pro plan");
|
||||
}
|
||||
|
||||
const playlist = await createYouTubePlaylist(user.id, createPlaylist);
|
||||
const nextItems = items.map((item) => ({
|
||||
...item,
|
||||
|
||||
@@ -38,6 +38,7 @@ export const TEXT_OFFSET_MAX = 120;
|
||||
export const TEXT_OFFSET_DEFAULT = 0;
|
||||
|
||||
export const ARTIST_MAX = 80;
|
||||
export const SONG_TITLE_MAX = 120;
|
||||
|
||||
export type LayoutSettings = {
|
||||
/** When null, classic letterbox (no art-track layout / blur fill). */
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { SUPPORT_EMAIL, SALES_EMAIL } from "@/lib/plans";
|
||||
import { SUPPORT_EMAIL } from "@/lib/plans";
|
||||
import { BRAND_DOMAIN, BRAND_NAME } from "@/lib/branding";
|
||||
|
||||
export const LEGAL_LAST_UPDATED = "July 13, 2026";
|
||||
export const LEGAL_LAST_UPDATED = "July 30, 2026";
|
||||
|
||||
export const LEGAL_OPERATOR = {
|
||||
name: "Songs2YT",
|
||||
name: BRAND_NAME,
|
||||
legalName: "Atakan Doğan Özban",
|
||||
address: "Universitas u. 2/A",
|
||||
city: "7622 Pécs, Hungary",
|
||||
email: SUPPORT_EMAIL,
|
||||
salesEmail: SALES_EMAIL,
|
||||
website: "https://www.songs2yt.com",
|
||||
website: `https://www.${BRAND_DOMAIN}`,
|
||||
} as const;
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Plan } from "@prisma/client";
|
||||
import { getResolution, RESOLUTIONS } from "./constants";
|
||||
import { isSelfHostedEdition } from "./edition";
|
||||
|
||||
export type PlanLimits = {
|
||||
monthlyQuota: number;
|
||||
@@ -8,17 +6,19 @@ export type PlanLimits = {
|
||||
maxResolutionHeight: number;
|
||||
maxFileSizeBytes: number;
|
||||
watermarkOptional: boolean;
|
||||
/** Custom text / PNG logo + position controls (Pro). */
|
||||
customWatermark: boolean;
|
||||
/** Pair a unique cover image per audio in a batch (Pro). */
|
||||
perItemImages: boolean;
|
||||
/** Blurred cover background + art-track layout templates (Pro). */
|
||||
artTrackLayouts: boolean;
|
||||
allowedAudioExtensions: readonly string[];
|
||||
id3TagSupport: boolean;
|
||||
};
|
||||
|
||||
/** Unlimited self-hosted limits — used for every plan in this OSS build. */
|
||||
export const SELFHOSTED_LIMITS: PlanLimits = {
|
||||
monthlyQuota: 1_000_000,
|
||||
maxBatchSize: 1_000,
|
||||
export const APP_LIMITS: PlanLimits = {
|
||||
monthlyQuota: Number.MAX_SAFE_INTEGER,
|
||||
maxBatchSize: 100,
|
||||
maxResolutionHeight: Math.max(...RESOLUTIONS.map((r) => r.height)),
|
||||
maxFileSizeBytes: 500 * 1024 * 1024,
|
||||
watermarkOptional: true,
|
||||
@@ -29,52 +29,40 @@ export const SELFHOSTED_LIMITS: PlanLimits = {
|
||||
id3TagSupport: true,
|
||||
};
|
||||
|
||||
/** Kept for type compatibility; OSS always returns SELFHOSTED_LIMITS. */
|
||||
export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
||||
FREE: { ...SELFHOSTED_LIMITS },
|
||||
PREMIUM: { ...SELFHOSTED_LIMITS },
|
||||
};
|
||||
|
||||
export const SALES_EMAIL = "songs2vid@atakanozban.com";
|
||||
export const SUPPORT_EMAIL = "songs2vid@atakanozban.com";
|
||||
export const QUOTA_REQUEST_EMAIL = "songs2vid@atakanozban.com";
|
||||
export const UPGRADE_URL = "https://songs2vid.com";
|
||||
export const WEBSITE_URL = "https://songs2vid.com";
|
||||
export const GITEA_ISSUES_URL =
|
||||
process.env.NEXT_PUBLIC_GITEA_ISSUES_URL ??
|
||||
"https://git.atakanozban.com/Songs2VID/songs2vid/issues";
|
||||
process.env.NEXT_PUBLIC_GITEA_ISSUES_URL ?? "https://git.atakanozban.com/Songs2VID/songs2vid/issues";
|
||||
export const GITEA_URL =
|
||||
process.env.NEXT_PUBLIC_GITEA_URL ?? "https://git.atakanozban.com/Songs2VID/songs2vid";
|
||||
process.env.NEXT_PUBLIC_GITEA_URL ?? "https://git.atakanozban.com/Songs2VID";
|
||||
export const DOCKER_HUB_URL =
|
||||
process.env.NEXT_PUBLIC_DOCKER_HUB_URL ?? "https://hub.docker.com/r/atakanozban/songs2vid";
|
||||
|
||||
/** Public docs (Docusaurus). Override with NEXT_PUBLIC_DOCS_URL if needed. */
|
||||
export const DOCS_URL = (
|
||||
process.env.NEXT_PUBLIC_DOCS_URL?.trim() || "https://docs.songs2vid.com"
|
||||
).replace(/\/$/, "");
|
||||
export const API_DOCS_URL = `${DOCS_URL}/docs/api/overview`;
|
||||
|
||||
export function getPlanLimits(_plan?: Plan): PlanLimits {
|
||||
void isSelfHostedEdition();
|
||||
return SELFHOSTED_LIMITS;
|
||||
export function resolveDocsUrl(): string {
|
||||
if (process.env.NEXT_PUBLIC_DOCS_URL?.trim()) {
|
||||
return process.env.NEXT_PUBLIC_DOCS_URL.replace(/\/$/, "");
|
||||
}
|
||||
return "https://docs.songs2vid.com";
|
||||
}
|
||||
|
||||
export function getResolutionsForPlan(plan: Plan) {
|
||||
const maxHeight = getPlanLimits(plan).maxResolutionHeight;
|
||||
export const DOCS_URL = resolveDocsUrl();
|
||||
export const API_DOCS_URL = `${DOCS_URL}/docs/api/overview`;
|
||||
|
||||
export function getPlanLimits(): PlanLimits {
|
||||
return APP_LIMITS;
|
||||
}
|
||||
|
||||
export function getResolutionsForPlan() {
|
||||
const maxHeight = APP_LIMITS.maxResolutionHeight;
|
||||
return RESOLUTIONS.filter((r) => r.height <= maxHeight);
|
||||
}
|
||||
|
||||
export function isResolutionAllowedForPlan(resolution: string, plan: Plan): boolean {
|
||||
export function isResolutionAllowedForPlan(resolution: string): boolean {
|
||||
const res = getResolution(resolution);
|
||||
if (!res) return false;
|
||||
return res.height <= getPlanLimits(plan).maxResolutionHeight;
|
||||
return res.height <= APP_LIMITS.maxResolutionHeight;
|
||||
}
|
||||
|
||||
export function isAudioExtensionAllowed(filename: string, plan: Plan): boolean {
|
||||
export function isAudioExtensionAllowed(filename: string): boolean {
|
||||
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
|
||||
return getPlanLimits(plan).allowedAudioExtensions.includes(ext);
|
||||
}
|
||||
|
||||
export function getNextMonthlyQuotaReset(from: Date = new Date()): Date {
|
||||
return new Date(from.getFullYear(), from.getMonth() + 1, 1, 0, 0, 0, 0);
|
||||
return APP_LIMITS.allowedAudioExtensions.includes(ext);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Shared typography math for dashboard preview ↔ FFmpeg output parity.
|
||||
* Preview container uses a fixed reference width; scale from target encode resolution.
|
||||
*/
|
||||
|
||||
import type { WatermarkFontKey } from "./fonts";
|
||||
import { CURATED_FONTS } from "./fonts";
|
||||
|
||||
/** Matches LayoutStudio max preview width (tailwind max-w-xl ≈ 576px; use 576 for scaling). */
|
||||
export const PREVIEW_REFERENCE_WIDTH = 576;
|
||||
|
||||
export function titleFontSizeForWidth(width: number): number {
|
||||
return Math.max(22, Math.round(width * 0.032));
|
||||
}
|
||||
|
||||
export function artistFontSizeForWidth(width: number): number {
|
||||
return Math.max(16, Math.round(width * 0.02));
|
||||
}
|
||||
|
||||
export function watermarkFontSizeForWidth(width: number): number {
|
||||
return Math.max(22, Math.round(width * 0.028));
|
||||
}
|
||||
|
||||
/** Scale encode-resolution px to preview container px. */
|
||||
export function scaleFontToPreview(fontPx: number, encodeWidth: number): number {
|
||||
const ref = encodeWidth > 0 ? encodeWidth : 1280;
|
||||
return Math.max(8, Math.round((fontPx * PREVIEW_REFERENCE_WIDTH) / ref));
|
||||
}
|
||||
|
||||
export function previewFontFamilyCss(fontKey: WatermarkFontKey | undefined): string {
|
||||
if (!fontKey || fontKey === "system") {
|
||||
return "Arial, Helvetica, sans-serif";
|
||||
}
|
||||
if (fontKey === "custom") {
|
||||
return "'S2VIDCustomWm', Arial, sans-serif";
|
||||
}
|
||||
const meta = CURATED_FONTS.find((f) => f.key === fontKey);
|
||||
return meta ? `'S2VIDPreview-${meta.key}', Arial, sans-serif` : "Arial, sans-serif";
|
||||
}
|
||||
|
||||
export function curatedFontApiUrl(key: string): string {
|
||||
return `/api/fonts/${key}`;
|
||||
}
|
||||
|
||||
/** Watermark overlay width as fraction of frame (matches encode.ts). */
|
||||
export const WATERMARK_WIDTH_FRACTION = 0.42;
|
||||
@@ -1,133 +1,39 @@
|
||||
import { Plan } from "@prisma/client";
|
||||
import { prisma } from "./db";
|
||||
import { getNextMonthlyQuotaReset, getPlanLimits } from "./plans";
|
||||
|
||||
/**
|
||||
* OSS / self-hosted quota: never blocks on credits or plan limits.
|
||||
* Batch size still enforced via SELFHOSTED_LIMITS.maxBatchSize.
|
||||
* videosUsed is incremented for display only.
|
||||
*/
|
||||
|
||||
export function formatQuotaResetCountdown(_resetsAt: Date | string): string {
|
||||
return "never";
|
||||
}
|
||||
|
||||
export function formatQuotaResetDisplay(_plan: Plan, _resetsAt: Date | string): string {
|
||||
return "never (self-hosted)";
|
||||
}
|
||||
|
||||
export async function ensureQuotaReset(userId: string) {
|
||||
return prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
}
|
||||
import { APP_LIMITS } from "./plans";
|
||||
|
||||
export async function getQuotaInfo(userId: string) {
|
||||
const user = await ensureQuotaReset(userId);
|
||||
const limits = getPlanLimits(user.plan);
|
||||
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
|
||||
return {
|
||||
used: user.videosUsed,
|
||||
limit: limits.monthlyQuota,
|
||||
baseLimit: limits.monthlyQuota,
|
||||
bonusQuota: 0,
|
||||
remaining: limits.monthlyQuota,
|
||||
videoCredits: 0,
|
||||
extraCredits: 0,
|
||||
creditBalanceMax: 0,
|
||||
totalAvailable: limits.monthlyQuota,
|
||||
resetsAt: user.quotaResetAt.toISOString(),
|
||||
resetsIn: formatQuotaResetDisplay(user.plan, user.quotaResetAt),
|
||||
plan: user.plan,
|
||||
maxBatchSize: limits.maxBatchSize,
|
||||
watermarkOptional: limits.watermarkOptional,
|
||||
customWatermark: limits.customWatermark,
|
||||
perItemImages: limits.perItemImages,
|
||||
artTrackLayouts: limits.artTrackLayouts,
|
||||
maxResolutionHeight: limits.maxResolutionHeight,
|
||||
used: user.createdVideoCount,
|
||||
createdVideoCount: user.createdVideoCount,
|
||||
maxBatchSize: APP_LIMITS.maxBatchSize,
|
||||
maxResolutionHeight: APP_LIMITS.maxResolutionHeight,
|
||||
watermarkOptional: true,
|
||||
customWatermark: true,
|
||||
perItemImages: true,
|
||||
artTrackLayouts: true,
|
||||
selfHosted: true,
|
||||
unlimited: true,
|
||||
};
|
||||
}
|
||||
|
||||
export type ReservationSplit = {
|
||||
fromQuota: number;
|
||||
fromCredits: number;
|
||||
};
|
||||
|
||||
/** Always succeed (except empty). Credits are never used in OSS. */
|
||||
export function planReservation(
|
||||
_plan: Plan,
|
||||
_remainingQuota: number,
|
||||
_videoCredits: number,
|
||||
count: number,
|
||||
): ReservationSplit | null {
|
||||
if (count <= 0) return { fromQuota: 0, fromCredits: 0 };
|
||||
return { fromQuota: count, fromCredits: 0 };
|
||||
}
|
||||
|
||||
export async function checkQuota(userId: string, requestedCount: number) {
|
||||
const info = await getQuotaInfo(userId);
|
||||
if (requestedCount > info.maxBatchSize) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `Batch limit exceeded. Max ${info.maxBatchSize} files per batch.`,
|
||||
used: info.used,
|
||||
limit: info.limit,
|
||||
remaining: info.remaining,
|
||||
videoCredits: 0,
|
||||
resetsAt: info.resetsAt,
|
||||
...info,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true as const,
|
||||
...info,
|
||||
reservation: { fromQuota: requestedCount, fromCredits: 0 },
|
||||
};
|
||||
|
||||
return { ok: true as const, ...info };
|
||||
}
|
||||
|
||||
/** Track usage for display; never deduct credits or block. */
|
||||
export async function reserveQuota(userId: string, count: number): Promise<ReservationSplit> {
|
||||
if (count <= 0) return { fromQuota: 0, fromCredits: 0 };
|
||||
|
||||
const limits = getPlanLimits();
|
||||
if (count > limits.maxBatchSize) {
|
||||
throw new Error(`Batch limit exceeded. Max ${limits.maxBatchSize} files per batch.`);
|
||||
export async function reserveQuota(userId: string, count: number) {
|
||||
if (count > APP_LIMITS.maxBatchSize) {
|
||||
throw new Error(`Batch limit exceeded. Max ${APP_LIMITS.maxBatchSize} files per batch.`);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { videosUsed: { increment: count } },
|
||||
});
|
||||
return { fromQuota: count, fromCredits: 0 };
|
||||
}
|
||||
|
||||
export async function releaseQuota(userId: string, count: number) {
|
||||
if (count <= 0) return;
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "User"
|
||||
SET "videosUsed" = GREATEST(0, "videosUsed" - ${count})
|
||||
WHERE id = ${userId}
|
||||
`;
|
||||
}
|
||||
|
||||
/** No-op in OSS (no prepaid credits). */
|
||||
export async function releaseCredits(_userId: string, _count: number) {
|
||||
return;
|
||||
}
|
||||
|
||||
export async function releaseReservation(
|
||||
userId: string,
|
||||
_billingSource: "QUOTA" | "CREDIT",
|
||||
count = 1,
|
||||
) {
|
||||
await releaseQuota(userId, count);
|
||||
}
|
||||
|
||||
export async function releaseReservationSplit(userId: string, split: ReservationSplit) {
|
||||
if (split.fromQuota > 0) await releaseQuota(userId, split.fromQuota);
|
||||
}
|
||||
|
||||
export async function incrementQuota(userId: string, count: number) {
|
||||
await reserveQuota(userId, count);
|
||||
}
|
||||
|
||||
export function getInitialQuotaResetAt(): Date {
|
||||
return getNextMonthlyQuotaReset();
|
||||
await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "path";
|
||||
import { WATERMARK_FILE_PATH } from "./watermark-policy";
|
||||
|
||||
export function getUploadDir(): string {
|
||||
return process.env.UPLOAD_DIR || path.join(process.cwd(), "uploads");
|
||||
@@ -8,6 +9,9 @@ export function getJobDir(userId: string, jobId: string): string {
|
||||
return path.join(getUploadDir(), userId, jobId);
|
||||
}
|
||||
|
||||
/** Brand watermark PNG used by the default overlay pipeline. */
|
||||
export function getWatermarkPath(): string {
|
||||
return path.join(process.cwd(), "assets", "watermark.png");
|
||||
return process.env.WATERMARK_FILE_PATH?.trim() || WATERMARK_FILE_PATH;
|
||||
}
|
||||
|
||||
export { WATERMARK_FILE_PATH };
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export type TitleFields = {
|
||||
/** YouTube video title */
|
||||
title?: string | null;
|
||||
/** On-video song / track title (art-track layouts) */
|
||||
songTitle?: string | null;
|
||||
artist?: string | null;
|
||||
};
|
||||
|
||||
/** Resolve the title sent to YouTube, falling back to artist and song metadata. */
|
||||
export function resolveYouTubeTitle(meta: TitleFields): string {
|
||||
const videoTitle = meta.title?.trim();
|
||||
if (videoTitle) return videoTitle;
|
||||
|
||||
const artist = meta.artist?.trim();
|
||||
const song = meta.songTitle?.trim();
|
||||
if (artist && song) return `${artist} - ${song}`;
|
||||
if (song) return song;
|
||||
if (artist) return artist;
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Title burned into art-track layout (never the YouTube-only field alone when songTitle set). */
|
||||
export function resolveBurnedSongTitle(
|
||||
meta: TitleFields,
|
||||
fallback = "Untitled",
|
||||
): string {
|
||||
return meta.songTitle?.trim() || fallback;
|
||||
}
|
||||
|
||||
export function validateYouTubeTitle(meta: TitleFields): string | null {
|
||||
const resolved = resolveYouTubeTitle(meta);
|
||||
if (!resolved) {
|
||||
return "Enter a video title, song title, or artist";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -13,21 +13,23 @@ export type ItemMetadata = {
|
||||
madeForKids: boolean;
|
||||
embeddable: boolean;
|
||||
creativeCommons: boolean;
|
||||
/** Legacy Free toggle default Songs2VID branding when watermark.mode is omitted. */
|
||||
/** Legacy toggle — default Songs2VID branding when watermark.mode is omitted. */
|
||||
includeWatermark: boolean;
|
||||
/** YouTube playlist ID (Pro only). Video is added after upload. */
|
||||
/** YouTube playlist ID. Video is added after upload. */
|
||||
playlistId?: string | null;
|
||||
/**
|
||||
* Optional per-item cover image path (Pro / perItemImages).
|
||||
* Optional per-item cover image path.
|
||||
* When omitted, the job-level shared imagePath is used.
|
||||
*/
|
||||
imagePath?: string | null;
|
||||
/** Pro custom branding. Free may only use mode none|default at bottom-right. */
|
||||
/** Custom branding watermark settings. */
|
||||
watermark?: Partial<WatermarkSettings> | null;
|
||||
/** Artist line for art-track layouts (optional). */
|
||||
/** Artist line for art-track layouts (on-video only). */
|
||||
artist?: string | null;
|
||||
/** Song / track title burned into art-track layouts (on-video only). */
|
||||
songTitle?: string | null;
|
||||
/**
|
||||
* Pro art-track layout. Prefer camelCase; snake_case aliases accepted:
|
||||
* Art-track layout. Prefer camelCase; snake_case aliases accepted:
|
||||
* layout_template, blur_amount, text_padding.
|
||||
*/
|
||||
layout?: Partial<LayoutSettings> & {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import path from "path";
|
||||
import {
|
||||
normalizeWatermarkSettings,
|
||||
type WatermarkSettings,
|
||||
} from "./watermark";
|
||||
|
||||
/** Preserve the user's optional watermark choice without tier-based forcing. */
|
||||
export function applyBrandWatermarkPolicy(
|
||||
input: Partial<WatermarkSettings> | null | undefined,
|
||||
includeWatermarkFallback: boolean,
|
||||
): WatermarkSettings {
|
||||
return normalizeWatermarkSettings(input, includeWatermarkFallback);
|
||||
}
|
||||
|
||||
export const WATERMARK_FILE_PATH = path.join(process.cwd(), "assets", "watermark.png");
|
||||
@@ -30,7 +30,7 @@ export type WatermarkSettings = {
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
/**
|
||||
* Typography (Pro / text mode).
|
||||
* Typography (text mode).
|
||||
* `system` = FFmpeg default; curated keys map to assets/fonts; `custom` uses fontPath.
|
||||
*/
|
||||
fontKey?: WatermarkFontKey;
|
||||
@@ -178,7 +178,7 @@ export function normalizeWatermarkSettings(
|
||||
};
|
||||
}
|
||||
|
||||
/** True when settings go beyond Free-tier default branding toggle. */
|
||||
/** True when settings go beyond the default branding toggle. */
|
||||
export function requiresCustomWatermarkEntitlement(settings: WatermarkSettings): boolean {
|
||||
if (settings.mode === "text" || settings.mode === "logo") return true;
|
||||
if (settings.mode === "none") return false;
|
||||
@@ -189,4 +189,3 @@ export function requiresCustomWatermarkEntitlement(settings: WatermarkSettings):
|
||||
return false;
|
||||
}
|
||||
|
||||
export const PREMIUM_REQUIRED_CODE = "PREMIUM_REQUIRED" as const;
|
||||
|
||||
@@ -39,10 +39,10 @@ export function isYouTubeUploadLimitError(message: string) {
|
||||
|
||||
export const YOUTUBE_UPLOAD_LIMIT_USER_MESSAGE =
|
||||
"YouTube upload limit reached: this Google/YouTube account has exceeded the number of videos " +
|
||||
"it may upload right now. This is YouTube's own daily limit, not your Songs2YT plan quota. " +
|
||||
"it may upload right now. This is YouTube's own daily limit, not your Songs2VID plan quota. " +
|
||||
"Try again later (often after 24 hours) or use a different YouTube channel.";
|
||||
|
||||
/** User-facing message for the dashboard (distinct from Songs2YT plan quota). */
|
||||
/** User-facing message for the dashboard (distinct from Songs2VID plan quota). */
|
||||
export function formatYouTubeErrorForUser(err: unknown): string {
|
||||
const raw = extractYouTubeErrorMessage(err);
|
||||
if (isYouTubeUploadLimitError(raw)) return YOUTUBE_UPLOAD_LIMIT_USER_MESSAGE;
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const docsUrl = (
|
||||
process.env.NEXT_PUBLIC_DOCS_URL?.trim() || "https://docs.songs2vid.com"
|
||||
).replace(/\/$/, "");
|
||||
const apiDocsUrl = `${docsUrl}/docs/api/overview`;
|
||||
function resolveDocsUrl(): string {
|
||||
if (process.env.NEXT_PUBLIC_DOCS_URL?.trim()) {
|
||||
return process.env.NEXT_PUBLIC_DOCS_URL.replace(/\/$/, "");
|
||||
}
|
||||
return "https://docs.songs2vid.com";
|
||||
}
|
||||
|
||||
const apiDocsUrl = `${resolveDocsUrl()}/docs/api/overview`;
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "songs2yt",
|
||||
"name": "songs2vid-oss",
|
||||
"description": "Payment-free self-hosted audio-to-video app with YouTube upload",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -19,7 +20,7 @@
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"bullmq": "^5.34.5",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"googleapis": "^173.0.0",
|
||||
"googleapis": "^144.0.0",
|
||||
"ioredis": "^5.11.1",
|
||||
"music-metadata": "^11.13.0",
|
||||
"next": "^15.1.3",
|
||||
@@ -34,23 +35,10 @@
|
||||
"concurrently": "^9.2.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-next": "^15.1.3",
|
||||
"postcss": "^8.5.18",
|
||||
"postcss": "^8.4.49",
|
||||
"prisma": "^6.1.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"overrides": {
|
||||
"uuid": "^11.1.1",
|
||||
"cookie": "^0.7.2",
|
||||
"postcss": "^8.5.18",
|
||||
"sharp": "^0.35.3",
|
||||
"@auth/core": "^0.41.3",
|
||||
"brace-expansion": "^5.0.8",
|
||||
"glob": "^11.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@emnapi/core": "1.11.2",
|
||||
"@emnapi/runtime": "1.11.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +1,88 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Plan" AS ENUM ('FREE', 'PREMIUM');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Privacy" AS ENUM ('PUBLIC', 'PRIVATE', 'UNLISTED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "JobStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'PARTIAL');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "JobItemStatus" AS ENUM ('PENDING', 'ENCODING', 'UPLOADING', 'COMPLETED', 'FAILED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"image" TEXT,
|
||||
"plan" "Plan" NOT NULL DEFAULT 'FREE',
|
||||
"videosUsed" INTEGER NOT NULL DEFAULT 0,
|
||||
"quotaResetAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"image" TEXT,
|
||||
"createdVideoCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"apiKeyHash" TEXT,
|
||||
"apiKeyPrefix" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "YouTubeConnection" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"accessToken" TEXT NOT NULL,
|
||||
"refreshToken" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"channelId" TEXT NOT NULL,
|
||||
"channelTitle" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "YouTubeConnection_pkey" PRIMARY KEY ("id")
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"accessToken" TEXT NOT NULL,
|
||||
"refreshToken" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"channelId" TEXT NOT NULL,
|
||||
"channelTitle" TEXT NOT NULL,
|
||||
CONSTRAINT "YouTubeConnection_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Job" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"status" "JobStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"imagePath" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "Job_pkey" PRIMARY KEY ("id")
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"status" "JobStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"imagePath" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
CONSTRAINT "Job_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "JobItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"jobId" TEXT NOT NULL,
|
||||
"audioPath" TEXT NOT NULL,
|
||||
"audioFilename" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL DEFAULT '',
|
||||
"tags" TEXT NOT NULL DEFAULT '',
|
||||
"privacy" "Privacy" NOT NULL DEFAULT 'PUBLIC',
|
||||
"categoryId" TEXT NOT NULL DEFAULT '10',
|
||||
"resolution" TEXT NOT NULL DEFAULT '1280x720',
|
||||
"notifySubscribers" BOOLEAN NOT NULL DEFAULT true,
|
||||
"madeForKids" BOOLEAN NOT NULL DEFAULT false,
|
||||
"embeddable" BOOLEAN NOT NULL DEFAULT true,
|
||||
"creativeCommons" BOOLEAN NOT NULL DEFAULT false,
|
||||
"includeWatermark" BOOLEAN NOT NULL DEFAULT true,
|
||||
"status" "JobItemStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"outputPath" TEXT,
|
||||
"youtubeVideoId" TEXT,
|
||||
"error" TEXT,
|
||||
|
||||
CONSTRAINT "JobItem_pkey" PRIMARY KEY ("id")
|
||||
"id" TEXT NOT NULL,
|
||||
"jobId" TEXT NOT NULL,
|
||||
"audioPath" TEXT NOT NULL,
|
||||
"audioFilename" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL DEFAULT '',
|
||||
"tags" TEXT NOT NULL DEFAULT '',
|
||||
"privacy" "Privacy" NOT NULL DEFAULT 'PUBLIC',
|
||||
"categoryId" TEXT NOT NULL DEFAULT '10',
|
||||
"resolution" TEXT NOT NULL DEFAULT '1280x720',
|
||||
"notifySubscribers" BOOLEAN NOT NULL DEFAULT true,
|
||||
"madeForKids" BOOLEAN NOT NULL DEFAULT false,
|
||||
"embeddable" BOOLEAN NOT NULL DEFAULT true,
|
||||
"creativeCommons" BOOLEAN NOT NULL DEFAULT false,
|
||||
"includeWatermark" BOOLEAN NOT NULL DEFAULT false,
|
||||
"itemImagePath" TEXT,
|
||||
"watermarkMode" TEXT NOT NULL DEFAULT 'none',
|
||||
"watermarkText" TEXT,
|
||||
"watermarkLogoPath" TEXT,
|
||||
"watermarkFontKey" TEXT,
|
||||
"watermarkFontPath" TEXT,
|
||||
"watermarkPosition" TEXT NOT NULL DEFAULT 'bottom-right',
|
||||
"watermarkOffsetX" INTEGER NOT NULL DEFAULT 20,
|
||||
"watermarkOffsetY" INTEGER NOT NULL DEFAULT 20,
|
||||
"artist" TEXT,
|
||||
"songTitle" TEXT,
|
||||
"layoutTemplate" TEXT,
|
||||
"blurAmount" INTEGER NOT NULL DEFAULT 55,
|
||||
"blurOpacity" INTEGER NOT NULL DEFAULT 100,
|
||||
"textPadding" INTEGER NOT NULL DEFAULT 48,
|
||||
"titleArtistGap" INTEGER NOT NULL DEFAULT 10,
|
||||
"textOffsetX" INTEGER NOT NULL DEFAULT 0,
|
||||
"textOffsetY" INTEGER NOT NULL DEFAULT 0,
|
||||
"playlistId" TEXT,
|
||||
"status" "JobItemStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"outputPath" TEXT,
|
||||
"youtubeVideoId" TEXT,
|
||||
"error" TEXT,
|
||||
CONSTRAINT "JobItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_apiKeyHash_key" ON "User"("apiKeyHash");
|
||||
CREATE UNIQUE INDEX "YouTubeConnection_userId_key" ON "YouTubeConnection"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "YouTubeConnection" ADD CONSTRAINT "YouTubeConnection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Job" ADD CONSTRAINT "Job_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JobItem" ADD CONSTRAINT "JobItem_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "YouTubeConnection" ADD CONSTRAINT "YouTubeConnection_userId_fkey"
|
||||
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "Job" ADD CONSTRAINT "Job_userId_fkey"
|
||||
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "JobItem" ADD CONSTRAINT "JobItem_jobId_fkey"
|
||||
FOREIGN KEY ("jobId") REFERENCES "Job"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "cardLast4" TEXT,
|
||||
ADD COLUMN "subscribedAt" TIMESTAMP(3);
|
||||
@@ -1,21 +0,0 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "QuotaExtensionRequestStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "bonusQuota" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "QuotaExtensionRequest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"status" "QuotaExtensionRequestStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"message" TEXT NOT NULL DEFAULT '',
|
||||
"requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"processedAt" TIMESTAMP(3),
|
||||
"adminNote" TEXT,
|
||||
|
||||
CONSTRAINT "QuotaExtensionRequest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "QuotaExtensionRequest" ADD CONSTRAINT "QuotaExtensionRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,6 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "apiKeyHash" TEXT,
|
||||
ADD COLUMN "apiKeyPrefix" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_apiKeyHash_key" ON "User"("apiKeyHash");
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "JobItem" ADD COLUMN "playlistId" TEXT;
|
||||
@@ -1,8 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "apiRateLimitBonus" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "QuotaExtensionKind" AS ENUM ('VIDEO_QUOTA', 'API_RATE_LIMIT');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "QuotaExtensionRequest" ADD COLUMN "kind" "QuotaExtensionKind" NOT NULL DEFAULT 'VIDEO_QUOTA';
|
||||
@@ -7,11 +7,6 @@ datasource db {
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum Plan {
|
||||
FREE
|
||||
PREMIUM
|
||||
}
|
||||
|
||||
enum Privacy {
|
||||
PUBLIC
|
||||
PRIVATE
|
||||
@@ -34,54 +29,17 @@ enum JobItemStatus {
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum QuotaExtensionRequestStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum QuotaExtensionKind {
|
||||
VIDEO_QUOTA
|
||||
API_RATE_LIMIT
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
image String?
|
||||
plan Plan @default(FREE)
|
||||
videosUsed Int @default(0)
|
||||
quotaResetAt DateTime
|
||||
cardLast4 String?
|
||||
subscribedAt DateTime?
|
||||
bonusQuota Int @default(0)
|
||||
apiRateLimitBonus Int @default(0)
|
||||
/// Legacy column retained for schema compatibility; unused in OSS (always 0).
|
||||
videoCredits Int @default(0)
|
||||
apiKeyHash String? @unique
|
||||
apiKeyPrefix String?
|
||||
youtubeConnection YouTubeConnection?
|
||||
jobs Job[]
|
||||
quotaExtensionRequests QuotaExtensionRequest[]
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
enum JobItemBilling {
|
||||
QUOTA
|
||||
CREDIT
|
||||
}
|
||||
|
||||
model QuotaExtensionRequest {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
kind QuotaExtensionKind @default(VIDEO_QUOTA)
|
||||
status QuotaExtensionRequestStatus @default(PENDING)
|
||||
message String @default("")
|
||||
requestedAt DateTime @default(now())
|
||||
processedAt DateTime?
|
||||
adminNote String?
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
image String?
|
||||
createdVideoCount Int @default(0)
|
||||
apiKeyHash String? @unique
|
||||
apiKeyPrefix String?
|
||||
youtubeConnection YouTubeConnection?
|
||||
jobs Job[]
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model YouTubeConnection {
|
||||
@@ -107,54 +65,42 @@ model Job {
|
||||
}
|
||||
|
||||
model JobItem {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
jobId String
|
||||
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
|
||||
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
|
||||
audioPath String
|
||||
audioFilename String
|
||||
title String
|
||||
description String @default("")
|
||||
tags String @default("")
|
||||
privacy Privacy @default(PUBLIC)
|
||||
categoryId String @default("10")
|
||||
resolution String @default("1280x720")
|
||||
notifySubscribers Boolean @default(true)
|
||||
madeForKids Boolean @default(false)
|
||||
embeddable Boolean @default(true)
|
||||
creativeCommons Boolean @default(false)
|
||||
includeWatermark Boolean @default(true)
|
||||
/// Optional per-item cover. Falls back to Job.imagePath when null.
|
||||
description String @default("")
|
||||
tags String @default("")
|
||||
privacy Privacy @default(PUBLIC)
|
||||
categoryId String @default("10")
|
||||
resolution String @default("1280x720")
|
||||
notifySubscribers Boolean @default(true)
|
||||
madeForKids Boolean @default(false)
|
||||
embeddable Boolean @default(true)
|
||||
creativeCommons Boolean @default(false)
|
||||
includeWatermark Boolean @default(false)
|
||||
itemImagePath String?
|
||||
/// none | default | text | logo
|
||||
watermarkMode String @default("default")
|
||||
watermarkMode String @default("none")
|
||||
watermarkText String?
|
||||
watermarkLogoPath String?
|
||||
/// curated key | custom | system
|
||||
watermarkFontKey String?
|
||||
/// uploaded .ttf/.otf when fontKey=custom
|
||||
watermarkFontPath String?
|
||||
/// top-left | top-right | bottom-left | bottom-right | center
|
||||
watermarkPosition String @default("bottom-right")
|
||||
watermarkOffsetX Int @default(20)
|
||||
watermarkOffsetY Int @default(20)
|
||||
/// Optional artist line for art-track layouts
|
||||
watermarkPosition String @default("bottom-right")
|
||||
watermarkOffsetX Int @default(20)
|
||||
watermarkOffsetY Int @default(20)
|
||||
artist String?
|
||||
/// COVER_LEFT_TEXT_RIGHT | COVER_TOP_TEXT_BOTTOM | COVER_RIGHT_TEXT_LEFT | CENTERED_COMPACT | null=classic
|
||||
songTitle String?
|
||||
layoutTemplate String?
|
||||
/// 0–100 Gaussian / boxblur intensity
|
||||
blurAmount Int @default(55)
|
||||
/// 0–100 visibility of blurred fill vs black
|
||||
blurOpacity Int @default(100)
|
||||
/// Padding around cover + text in art-track layouts
|
||||
textPadding Int @default(48)
|
||||
/// Extra gap between title and artist lines (px)
|
||||
titleArtistGap Int @default(10)
|
||||
/// Fine-tune text block within template (-120..120)
|
||||
textOffsetX Int @default(0)
|
||||
textOffsetY Int @default(0)
|
||||
blurAmount Int @default(55)
|
||||
blurOpacity Int @default(100)
|
||||
textPadding Int @default(48)
|
||||
titleArtistGap Int @default(10)
|
||||
textOffsetX Int @default(0)
|
||||
textOffsetY Int @default(0)
|
||||
playlistId String?
|
||||
billingSource JobItemBilling @default(QUOTA)
|
||||
status JobItemStatus @default(PENDING)
|
||||
status JobItemStatus @default(PENDING)
|
||||
outputPath String?
|
||||
youtubeVideoId String?
|
||||
error String?
|
||||
|
||||
|
Before Width: | Height: | Size: 374 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,103 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
BLUR_AMOUNT_MAX,
|
||||
INVALID_LAYOUT_TEMPLATE_MESSAGE,
|
||||
blurToBoxblur,
|
||||
boxblurFilterSegment,
|
||||
buildArtTrackFilterComplex,
|
||||
clampBlurAmount,
|
||||
clampTextPadding,
|
||||
clampTitleArtistGap,
|
||||
computeLayoutGeometry,
|
||||
normalizeLayoutSettings,
|
||||
requiresArtTrackLayoutEntitlement,
|
||||
} from "../lib/layout";
|
||||
import { sanitizeDrawtext } from "../lib/watermark";
|
||||
|
||||
function testEnumsAndClamp() {
|
||||
assert.equal(clampBlurAmount(150), BLUR_AMOUNT_MAX);
|
||||
assert.equal(clampBlurAmount(-1), 0);
|
||||
assert.equal(clampTextPadding(999), 120);
|
||||
assert.equal(clampTextPadding(1), 16);
|
||||
assert.equal(clampTitleArtistGap(100), 64);
|
||||
assert.equal(clampTitleArtistGap(-5), 0);
|
||||
|
||||
assert.equal(blurToBoxblur(0), null);
|
||||
const mid = blurToBoxblur(50);
|
||||
assert.ok(mid && mid.radius >= 1 && mid.power >= 1);
|
||||
assert.ok(boxblurFilterSegment(60).includes("boxblur="));
|
||||
assert.equal(boxblurFilterSegment(0), "");
|
||||
}
|
||||
|
||||
function testNormalize() {
|
||||
const classic = normalizeLayoutSettings(null);
|
||||
assert.equal(classic.template, null);
|
||||
assert.equal(classic.titleArtistGap, 10);
|
||||
assert.equal(classic.textOffsetX, 0);
|
||||
|
||||
assert.equal(
|
||||
normalizeLayoutSettings({
|
||||
layout_template: "CENTERED_COMPACT",
|
||||
blur_amount: 80,
|
||||
title_artist_gap: 24,
|
||||
text_offset_y: -20,
|
||||
}).titleArtistGap,
|
||||
24,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => normalizeLayoutSettings({ template: "NOT_A_TEMPLATE" }),
|
||||
(err: Error) => err.message === INVALID_LAYOUT_TEMPLATE_MESSAGE,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeLayoutSettings({ template: "COVER_LEFT_TEXT_RIGHT", x: 10 }),
|
||||
(err: Error) => err.message === INVALID_LAYOUT_TEMPLATE_MESSAGE,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
requiresArtTrackLayoutEntitlement(normalizeLayoutSettings({ template: null })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
requiresArtTrackLayoutEntitlement(
|
||||
normalizeLayoutSettings({ template: "COVER_TOP_TEXT_BOTTOM" }),
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
function testGeometryAndFilter() {
|
||||
const geo = computeLayoutGeometry("COVER_TOP_TEXT_BOTTOM", 1920, 1080, 48, 16, 10, -5);
|
||||
assert.ok(geo.coverMaxW > 1600, "top layout cover should be near full width");
|
||||
assert.ok(geo.artistY !== geo.titleY);
|
||||
|
||||
const withGap = computeLayoutGeometry("COVER_LEFT_TEXT_RIGHT", 1280, 720, 40, 40, 0, 0);
|
||||
const tight = computeLayoutGeometry("COVER_LEFT_TEXT_RIGHT", 1280, 720, 40, 0, 0, 0);
|
||||
assert.ok(Number(withGap.artistY) - Number(withGap.titleY) > Number(tight.artistY) - Number(tight.titleY));
|
||||
|
||||
const fc = buildArtTrackFilterComplex({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
layout: {
|
||||
template: "CENTERED_COMPACT",
|
||||
blurAmount: 40,
|
||||
blurOpacity: 70,
|
||||
textPadding: 40,
|
||||
titleArtistGap: 18,
|
||||
textOffsetX: 5,
|
||||
textOffsetY: -8,
|
||||
},
|
||||
titleEscaped: sanitizeDrawtext("Hello:World"),
|
||||
artistEscaped: sanitizeDrawtext("Artist"),
|
||||
});
|
||||
assert.ok(fc.includes("split=2"));
|
||||
assert.ok(fc.includes("blend=") || fc.includes("[blurred]"));
|
||||
assert.ok(fc.includes("overlay="));
|
||||
assert.ok(fc.includes("drawtext="));
|
||||
assert.ok(fc.endsWith("[laid]"));
|
||||
}
|
||||
|
||||
testEnumsAndClamp();
|
||||
testNormalize();
|
||||
testGeometryAndFilter();
|
||||
console.log("layout.test.ts: ok");
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
artistFontSizeForWidth,
|
||||
scaleFontToPreview,
|
||||
titleFontSizeForWidth,
|
||||
watermarkFontSizeForWidth,
|
||||
} from "../lib/preview-typography";
|
||||
|
||||
function testFontSizesMatchLayout() {
|
||||
const width = 1280;
|
||||
assert.equal(titleFontSizeForWidth(width), Math.max(22, Math.round(width * 0.032)));
|
||||
assert.equal(artistFontSizeForWidth(width), Math.max(16, Math.round(width * 0.02)));
|
||||
assert.equal(watermarkFontSizeForWidth(width), Math.max(22, Math.round(width * 0.028)));
|
||||
}
|
||||
|
||||
function testPreviewScaling() {
|
||||
const encodeWidth = 1920;
|
||||
const titlePx = titleFontSizeForWidth(encodeWidth);
|
||||
const previewPx = scaleFontToPreview(titlePx, encodeWidth);
|
||||
assert.ok(previewPx > 0 && previewPx < titlePx);
|
||||
}
|
||||
|
||||
testFontSizesMatchLayout();
|
||||
testPreviewScaling();
|
||||
console.log("preview-typography.test.ts: ok");
|
||||