Compare commits

..
11 Commits
Author SHA1 Message Date
Atakan Doğan Özban b40127c2e8 Exclude integrations/n8n from Next/Docker builds so the app image typechecks cleanly. 2026-08-08 16:47:16 +02:00
Atakan Doğan Özban f9b2a997a2 Add n8n community node, /api/v1/render alias, and job webhooks for OSS automation.
Payment-free self-hosted builds keep full API access with optional webhookUrl callbacks and the published n8n-nodes-songs2vid package source under integrations/n8n.
2026-08-08 16:32:28 +02:00
Atakan Doğan Özban 848607f9e0 Add lower-corner layouts, custom blur backgrounds, and classic blur fill.
Ship composition families, optional background images for lower-corner templates, and an optional blurred cover fill for classic letterbox.
2026-08-07 00:51:37 +02:00
atakan 03634d5100 Update README.md 2026-08-03 06:01:10 +00:00
atakan 6bc98606eb Update README.md 2026-08-03 05:57:28 +00:00
atakan b68e9fd8e5 Update README.md 2026-08-03 05:55:34 +00:00
atakan 888f5fb66a Update README.md 2026-08-03 05:55:08 +00:00
Atakan Doğan Özban 54bbf8b574 Close OSS self-host gaps: in-repo API docs, legal notes, and packaging.
Add MIT license and docs/api, strip SaaS status/admin remnants from robots and footer, align env/compose/README with payment-free product truth.
2026-08-03 07:36:08 +02:00
Atakan Doğan Özban 1fcae33ca3 Document payment-free OSS self-host quick start and Docker Hub overview. 2026-08-03 07:03:17 +02:00
Atakan Doğan Özban 6476bb42a4 Strip Songs2VID to a payment-free self-hosted OSS core.
Remove Stripe/billing/pricing/marketing, simplify schema and entitlements for unlimited self-host use, and keep auth, encode, and YouTube upload.
2026-08-03 06:52:00 +02:00
Songs2VID Support 506d56fa92 Initial OSS scaffold from Songs2VID (pre-strip) 2026-08-03 06:15:05 +02:00
62 changed files with 9092 additions and 175 deletions
+1
View File
@@ -10,3 +10,4 @@ uploads
deploy-*.tgz
scripts
bg-video
integrations
+10 -6
View File
@@ -1,19 +1,23 @@
# Songs2VID OSS is always self-hosted and has no billing configuration.
# Songs2VID OSS is always self-hosted and payment-free.
# Do not set Stripe, billing, credits, pricing, or S2VID_EDITION variables — they are unused.
# --- Required ---
DATABASE_URL="postgresql://songs2vid:songs2vid@localhost:5433/songs2vid"
REDIS_URL="redis://localhost:6380"
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="replace-with-a-long-random-secret"
# 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"
# --- Optional ---
# TOKEN_ENCRYPTION_KEY="replace-with-another-long-random-secret" # falls back to NEXTAUTH_SECRET
UPLOAD_DIR="./uploads"
# FFMPEG_PATH="C:/path/to/ffmpeg.exe" # override bundled / image ffmpeg
# S2VID_PORT=3000 # host port for docker-compose.yml (default 3000)
# S2VID_IMAGE_TAG=latest # Docker Hub tag when using published image
NEXT_PUBLIC_DOCS_URL="https://docs.songs2vid.com"
NEXT_PUBLIC_GITEA_ISSUES_URL="https://git.atakanozban.com/Songs2VID/songs2vid/issues"
NEXT_PUBLIC_GITEA_URL="https://git.atakanozban.com/Songs2VID"
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"
+2
View File
@@ -3,6 +3,8 @@ node_modules/
.next/
.env
.env.local
*.tsbuildinfo
tsconfig.tsbuildinfo
website/node_modules/
website/build/
website/.docusaurus/
+50
View File
@@ -0,0 +1,50 @@
# Songs2VID
Payment-free, self-hosted software that turns cover art and audio into YouTube videos (FFmpeg + BullMQ). Full entitlements in every deployment — no plans, credits, Stripe, or paywall.
**Image:** `atakanozban/songs2vid:latest`
**License:** MIT
**Docs:** https://docs.songs2vid.com
**Source:** https://git.atakanozban.com/Songs2VID/songs2vid
**Hosted SaaS (separate):** https://songs2vid.com
## Quick start
1. Clone the repo (Compose + `.env.example`) from Gitea, or reuse the sample `docker-compose.yml`.
2. Copy `.env.example``.env` and set:
- `NEXTAUTH_SECRET`
- `GOOGLE_CLIENT_ID`
- `GOOGLE_CLIENT_SECRET`
3. Enable **YouTube Data API v3** in Google Cloud and add OAuth redirect:
- `http://localhost:3000/api/auth/callback/google` (or your public HTTPS origin)
4. Run:
```bash
docker pull atakanozban/songs2vid:latest
docker compose up -d
```
Open http://localhost:3000 → sign in with Google → dashboard.
No Stripe, billing, or edition environment variables are required. Watermarks are optional (no free-tier badge paywall).
## What you get
- Batch encode: cover art + audio → YouTube upload
- Classic and art-track layouts, blur, typography, watermarks
- Playlists, privacy, tags, resolutions
- REST API (Dashboard → Settings → API key)
- Always-on self-hosted entitlements
## Stack
Next.js 15 · PostgreSQL/Prisma · Redis/BullMQ · NextAuth (Google + YouTube) · FFmpeg
## Links
- Full documentation: https://docs.songs2vid.com
- Getting started: https://docs.songs2vid.com/docs/getting-started
- Environment variables: https://docs.songs2vid.com/docs/environment
- REST API: https://docs.songs2vid.com/docs/api/overview
- In-repo API notes: https://git.atakanozban.com/Songs2VID/songs2vid/src/branch/main/docs/api
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Atakan Doğan Özban
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
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.
+74 -18
View File
@@ -1,42 +1,98 @@
# Songs2VID OSS
# 🎬 Songs2VID
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.
Payment-free, self-hosted software that turns cover art and audio into YouTube videos
(FFmpeg encoding + BullMQ jobs). Every deployment has full entitlements — there are no
plans, credits, purchases, subscriptions, paywalls, or Stripe.
## Quick start with Docker
Sign in with Google, open the dashboard, and create videos. Settings cover account,
YouTube connection, and API keys only.
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:
![Songs2VID Dashboard](https://www.atakanozban.com/Content/uploads/projects-media/f59f2b3c290042ceb8d12fe9395b0dda.png)
## 🚀 Quick start (Docker)
Published image: [`atakanozban/songs2vid:latest`](https://hub.docker.com/r/atakanozban/songs2vid)
1. Clone this repo (or copy `docker-compose.yml` + `.env.example`).
2. Copy `.env.example` to `.env` and set at least:
- `NEXTAUTH_SECRET`
- `GOOGLE_CLIENT_ID`
- `GOOGLE_CLIENT_SECRET`
3. In [Google Cloud Console](https://console.cloud.google.com/), enable **YouTube Data API v3**
and add an OAuth redirect URI:
- Local: `http://localhost:3000/api/auth/callback/google`
- Production: `https://YOUR_DOMAIN/api/auth/callback/google`
4. Start the stack (web + worker + Postgres + Redis):
```bash
docker compose up -d --build
docker compose up -d
```
Open http://localhost:3000.
Compose uses the published image by default (`atakanozban/songs2vid:latest`) and can also
build from this repo (`docker compose up -d --build`).
## Local development
Open http://localhost:3000 → sign in → dashboard.
No Stripe keys, billing env vars, or edition flags are required. Watermarks are optional;
OSS does not force a free-tier SaaS badge paywall.
## ✨ Features
- 📦 Batch: one cover (or per-track covers) + many audio files → YouTube uploads
- 🖼️ Classic letterbox and art-track layouts (blur backgrounds, typography, fine-tuning)
- 💧 Custom watermarks (badge, text, or logo) and curated or uploaded fonts
- ▶️ YouTube playlists, privacy, tags, resolution, categories
- 🔌 REST API for automation (keys under **Dashboard → Settings → API key**)
- 🤖 n8n community node [`n8n-nodes-songs2vid`](https://www.npmjs.com/package/n8n-nodes-songs2vid) + optional `webhookUrl` callbacks
- 🔓 Always-on self-hosted entitlements (no quota paywall)
## 📚 API documentation
- In-repo: [docs/api/overview.md](./docs/api/overview.md), [docs/api/endpoints.md](./docs/api/endpoints.md), [docs/n8n.md](./docs/n8n.md)
- Live site: [docs.songs2vid.com/docs/api/overview](https://docs.songs2vid.com/docs/api/overview)
- Machine-readable discovery: `GET /api/v1` (no auth; no billing routes)
- Node package source: [`integrations/n8n`](./integrations/n8n/)
## 🛠️ Local development
```bash
docker compose -f docker-compose.dev.yml up -d
cp .env.example .env # then edit secrets / Google OAuth
npm install
npm run db:migrate
npm run dev:all
```
The web app runs on http://localhost:3000. `dev:all` starts both the Next.js app and BullMQ worker.
- App: http://localhost:3000
- `dev:all` runs Next.js and the BullMQ worker
## Stack
## ⚙️ Environment
See [`.env.example`](./.env.example) for the required and optional variables. Matching guide:
[docs.songs2vid.com/docs/environment](https://docs.songs2vid.com/docs/environment).
## ⚖️ Legal (self-hosted)
In-app **Privacy** and **Terms** pages describe self-hosted responsibility: the operator of
each instance controls data and access; this software does not process payments. They are not
the paid cloud terms for [songs2vid.com](https://songs2vid.com).
## 🧱 Stack
- Next.js 15, TypeScript, Tailwind
- PostgreSQL and Prisma
- Redis and BullMQ
- NextAuth with Google OAuth
- NextAuth with Google OAuth (YouTube scopes)
- FFmpeg
- YouTube Data API v3
Documentation, including the REST API reference, is at
[docs.songs2vid.com](https://docs.songs2vid.com).
## 🔗 Links
- Docs: [docs.songs2vid.com](https://docs.songs2vid.com)
- Docker Hub: [atakanozban/songs2vid](https://hub.docker.com/r/atakanozban/songs2vid)
- Source: [git.atakanozban.com/Songs2VID](https://git.atakanozban.com/Songs2VID)
- Hosted SaaS (separate product): [songs2vid.com](https://songs2vid.com)
## 📄 License
[MIT](./LICENSE)
+14 -6
View File
@@ -1,25 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import { CURATED_FONTS, isCuratedFontKey } from "@/lib/fonts";
import { resolveCuratedFontPath } from "@/lib/fonts-server";
import { CURATED_FONTS, isCuratedFontKey, SYSTEM_FONT } from "@/lib/fonts";
import { resolveCuratedFontPath, resolveSystemFontPath } from "@/lib/fonts-server";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ key: string }> },
) {
const { key } = await params;
if (!isCuratedFontKey(key)) {
const fontPath =
key === SYSTEM_FONT.key
? resolveSystemFontPath()
: isCuratedFontKey(key)
? resolveCuratedFontPath(key)
: null;
if (!fontPath) {
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)!;
const fileName =
key === SYSTEM_FONT.key
? SYSTEM_FONT.file
: CURATED_FONTS.find((f) => f.key === key)!.file;
return new NextResponse(buf, {
headers: {
"Content-Type": "font/ttf",
"Content-Disposition": `inline; filename="${meta.file}"`,
"Content-Disposition": `inline; filename="${fileName}"`,
"Cache-Control": "public, max-age=86400, immutable",
},
});
+3
View File
@@ -35,12 +35,15 @@ export async function POST(req: NextRequest) {
const job = await createVideoJob(user, {
imagePath: body.imagePath,
items,
webhookUrl: body.webhookUrl ?? null,
});
return NextResponse.json({
jobId: job.id,
itemCount: job.items.length,
status: job.status,
statusUrl: `/api/v1/jobs/${job.id}`,
webhookUrl: body.webhookUrl ?? null,
playlist: playlist
? { id: playlist.id, title: playlist.title, privacy: playlist.privacy }
: null,
+5
View File
@@ -0,0 +1,5 @@
/**
* Alias for n8n / automation: POST|GET /api/v1/render → same as /api/v1/jobs
* OSS: no plan gate — Bearer API key + YouTube connected.
*/
export { POST, GET } from "../jobs/route";
+35 -4
View File
@@ -1,4 +1,9 @@
import { NextResponse } from "next/server";
import {
COMPOSITION_FAMILIES,
LAYOUT_TEMPLATE_LABELS,
LAYOUT_TEMPLATES,
} from "@/lib/layout";
import { API_DOCS_URL } from "@/lib/plans";
export async function GET() {
@@ -8,12 +13,31 @@ export async function GET() {
authentication: "Authorization: Bearer <api_key>",
requirements: ["YouTube account connected"],
rateLimit: "100,000 requests per minute per account",
billing: false,
notes:
"OSS self-hosted: full entitlements, no Stripe/credits/paywall. In-repo docs: docs/api/",
webhooks:
"Optional webhookUrl on job create — HTTPS POST JSON on item/job terminal states for n8n",
guidance: {
recommended:
"For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/jobs with the returned paths",
"For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/render (or /api/v1/jobs) with paths + optional webhookUrl",
batch:
"POST /api/v1/jobs/batch is for small packs only. Large multipart bodies may fail with 'failed to parse body as FormData'",
polling: "GET /api/v1/jobs/:id until status is COMPLETED, FAILED, or PARTIAL",
},
layoutTemplates: LAYOUT_TEMPLATES.map((id) => ({
id,
label: LAYOUT_TEMPLATE_LABELS[id],
})),
compositionFamilies: COMPOSITION_FAMILIES.map((family) => ({
id: family.id,
label: family.label,
defaultTemplate: family.defaultTemplate,
variants: family.variants.map((id) => ({
id,
label: LAYOUT_TEMPLATE_LABELS[id],
})),
})),
endpoints: [
{
method: "POST",
@@ -22,12 +46,19 @@ export async function GET() {
"Upload image, audio, PNG logo, or .ttf/.otf font (font ≤10MB)",
body: "multipart/form-data: file, type (image|audio|logo|font)",
},
{
method: "POST",
path: "/api/v1/render",
description:
"Alias of /api/v1/jobs — create a render job (n8n-friendly). Optional webhookUrl for callbacks.",
body: "application/json: { imagePath, webhookUrl?, items[{ audioPath, audioFilename, metadata }] }",
},
{
method: "POST",
path: "/api/v1/jobs",
description:
"Create a video job from uploaded file paths with layouts, watermark, and per-item covers",
body: "application/json: { imagePath, items[{ audioPath, audioFilename, metadata }] }",
body: "application/json: { imagePath, webhookUrl?, items[{ audioPath, audioFilename, metadata }] }",
},
{
method: "POST",
@@ -50,13 +81,13 @@ export async function GET() {
{
method: "GET",
path: "/api/v1/jobs",
description: "List recent jobs",
description: "List recent jobs (also available as GET /api/v1/render)",
query: "limit (default 20, max 100)",
},
{
method: "GET",
path: "/api/v1/jobs/:id",
description: "Get job status and item details",
description: "Get job status and item details (poll for n8n)",
},
],
docs: API_DOCS_URL,
+1 -1
View File
@@ -8,7 +8,7 @@ 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.";
"Payment-free self-hosted audio-to-video creation and YouTube uploading.";
export const metadata: Metadata = {
title: DEFAULT_TITLE,
+65 -4
View File
@@ -1,15 +1,76 @@
import { LegalPageLayout } from "@/components/LegalPageLayout";
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
import { DOCS_URL, GITEA_URL } from "@/lib/plans";
export default function PrivacyPage() {
return (
<LegalPageLayout title="Privacy Policy">
<p>
Songs2VID is self-hosted software. Your operator controls the deployment, database, uploads,
logs, and Google OAuth configuration. Songs2VID does not include payment processing.
This policy describes how a <strong>self-hosted Songs2VID</strong> instance typically
handles data. Songs2VID OSS does not include payment processing, plans, credits, or
Stripe. The person or organization that operates this deployment (the &quot;operator&quot;)
controls the server, database, uploads, logs, and Google OAuth configuration, and is
responsible for privacy compliance for their users.
</p>
<h2>1. Who is responsible</h2>
<p>
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.
For this software package as published by {LEGAL_OPERATOR.legalName}, contact:{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>. For data on{" "}
<em>this running instance</em>, contact the operator of the deployment you signed into
not necessarily the hosted SaaS at songs2vid.com, which is a separate product with its own
policies.
</p>
<h2>2. Data this software processes</h2>
<ul>
<li>Account profile from Google sign-in (email, name, avatar)</li>
<li>YouTube OAuth tokens (encrypted at rest when configured) and channel metadata</li>
<li>Uploaded cover art, audio, optional logos/fonts, and derived video files</li>
<li>Job metadata (titles, tags, privacy, layouts, watermarks)</li>
<li>API key hashes and rate-limit counters</li>
</ul>
<h2>3. Google and YouTube</h2>
<p>
Google and YouTube process account and upload data under their own terms and policies.
Operators must configure OAuth correctly and respect{" "}
<a
href="https://developers.google.com/terms/api-services-user-data-policy"
target="_blank"
rel="noopener noreferrer"
>
Google API Services User Data Policy
</a>{" "}
and{" "}
<a
href="https://developers.google.com/youtube/terms/developer-policies"
target="_blank"
rel="noopener noreferrer"
>
YouTube API Services Policies
</a>
.
</p>
<h2>4. Your choices</h2>
<p>
Use <strong>Dashboard Settings</strong> to export or delete your account data on this
instance, or ask the operator. You can also revoke Google access in your{" "}
<a
href="https://security.google.com/settings/security/permissions"
target="_blank"
rel="noopener noreferrer"
>
Google Account permissions
</a>
.
</p>
<h2>5. Documentation</h2>
<p>
Product docs: <a href={DOCS_URL}>{DOCS_URL}</a>. Source:{" "}
<a href={GITEA_URL}>{GITEA_URL}</a>.
</p>
</LegalPageLayout>
);
+7 -2
View File
@@ -1,12 +1,17 @@
import type { MetadataRoute } from "next";
function siteUrl() {
const raw = process.env.NEXTAUTH_URL?.trim() || "http://localhost:3000";
return raw.replace(/\/$/, "");
}
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/admin", "/api/admin"],
disallow: ["/api/", "/dashboard", "/jobs"],
},
sitemap: "https://songs2vid.com/sitemap.xml",
sitemap: `${siteUrl()}/sitemap.xml`,
};
}
+14 -4
View File
@@ -1,17 +1,27 @@
import type { MetadataRoute } from "next";
const SITE_URL = "https://songs2vid.com";
function siteUrl() {
const raw = process.env.NEXTAUTH_URL?.trim() || "http://localhost:3000";
return raw.replace(/\/$/, "");
}
export default function sitemap(): MetadataRoute.Sitemap {
const base = siteUrl();
return [
{
url: SITE_URL,
url: base,
lastModified: new Date(),
changeFrequency: "daily",
changeFrequency: "weekly",
priority: 1.0,
},
{
url: `${SITE_URL}/privacy`,
url: `${base}/privacy`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.3,
},
{
url: `${base}/terms`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.3,
+40 -4
View File
@@ -1,15 +1,51 @@
import { LegalPageLayout } from "@/components/LegalPageLayout";
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
import { DOCS_URL, GITEA_URL } from "@/lib/plans";
export default function TermsPage() {
return (
<LegalPageLayout title="Terms of Use">
<p>
Songs2VID is provided as open-source, self-hosted software without warranty. The operator of
each instance is responsible for availability, configuration, and user access.
Songs2VID OSS is open-source, <strong>payment-free, self-hosted software</strong>. These
terms describe use of the software and of instances that run it. They are{" "}
<strong>not</strong> the paid cloud Terms of Service for songs2vid.com (a separate hosted
product with its own billing and policies).
</p>
<h2>1. Software license and warranty</h2>
<p>
You are responsible for the media you process and upload, including compliance with
copyright law and YouTube&apos;s terms.
The source is provided under the license in the repository <code>LICENSE</code> file. The
software is provided <strong>without warranty</strong> of any kind. The operator of each
instance is responsible for availability, security, backups, configuration, and who may
sign in.
</p>
<h2>2. No paid features in this edition</h2>
<p>
This edition has no Stripe integration, subscriptions, credits, pricing pages, or paywalls.
Full layout, watermark, API, and playlist features are available to signed-in users of the
instance. Any paid offering lives only on the separate hosted SaaS product.
</p>
<h2>3. Your content and YouTube</h2>
<p>
You are solely responsible for media you upload and for compliance with copyright law and
YouTube&apos;s terms. Connecting Google/YouTube authorizes the instance to upload on your
behalf within the scopes granted.
</p>
<h2>4. Acceptable use</h2>
<p>
Do not use the software to infringe rights, abuse YouTube or Google APIs, or circumvent
another party&apos;s security. Operators may suspend access on their instances.
</p>
<h2>5. Contact</h2>
<p>
Package / project contact:{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>. Instance-specific
support: contact your operator. Docs: <a href={DOCS_URL}>{DOCS_URL}</a>. Source:{" "}
<a href={GITEA_URL}>{GITEA_URL}</a>.
</p>
</LegalPageLayout>
);
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

+2 -1
View File
@@ -58,7 +58,8 @@ 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 video jobs programmatically.
Use the REST API to upload files and create video jobs programmatically. There are no
billing endpoints in this self-hosted edition.
</p>
<p className="text-xs text-gray-500">
Rate limit: {initialRateLimit.limit} requests per {initialRateLimit.windowSeconds} seconds.
+298 -49
View File
@@ -10,6 +10,7 @@ import {
import { getVideoAttributionText } from "@/lib/branding";
import {
CURATED_FONTS,
SYSTEM_FONT,
type CuratedFontKey,
type WatermarkFontKey,
} from "@/lib/fonts";
@@ -19,15 +20,18 @@ import {
BLUR_OPACITY_DEFAULT,
BLUR_OPACITY_MAX,
BLUR_OPACITY_MIN,
COMPOSITION_FAMILIES,
DEFAULT_LAYOUT,
LAYOUT_TEMPLATE_LABELS,
LAYOUT_TEMPLATES,
TEXT_OFFSET_MAX,
TEXT_OFFSET_MIN,
TEXT_PADDING_MAX,
TEXT_PADDING_MIN,
TITLE_ARTIST_GAP_MAX,
TITLE_ARTIST_GAP_MIN,
compositionFamilyForTemplate,
isLowerCornerTemplate,
lowerCornerCoverSide,
type LayoutSettings,
type LayoutTemplate,
} from "@/lib/layout";
@@ -54,6 +58,8 @@ import {
type Props = {
locked: boolean;
previewImageUrl: string | null;
/** Optional custom blur-fill for lower-corner layouts. */
previewBackgroundUrl?: string | null;
/** On-video song title (art-track layouts). */
songTitle: string;
artist: string;
@@ -65,7 +71,10 @@ type Props = {
onWatermarkChange: (next: WatermarkSettings) => void;
onUploadLogo: (file: File) => Promise<string>;
onUploadFont: (file: File) => Promise<string>;
onUploadBackground?: (file: File) => Promise<string>;
onClearBackground?: () => void;
logoPreviewUrl?: string | null;
hasCustomBackground?: boolean;
};
const WM_POSITION_LABELS: Record<WatermarkPosition, string> = {
@@ -121,11 +130,13 @@ function watermarkOverlayStyle(
function MiniThumb({
template,
label,
active,
onClick,
disabled,
}: {
template: LayoutTemplate | null;
label: string;
active: boolean;
onClick: () => void;
disabled: boolean;
@@ -150,20 +161,14 @@ function MiniThumb({
) : (
<div className="relative h-full w-full overflow-hidden bg-gray-700/50">
<div className="absolute inset-0 scale-110 bg-gray-600/40 blur-[3px]" />
{template === "COVER_LEFT_TEXT_RIGHT" && (
{(template === "COVER_LEFT_TEXT_RIGHT" ||
template === "COVER_RIGHT_TEXT_LEFT") && (
<>
<div className="absolute left-[6%] top-[18%] h-[64%] w-[38%] bg-gray-300" />
<div className="absolute right-[8%] top-[38%] h-1 w-[40%] rounded bg-white/80" />
<div className="absolute right-[8%] top-[52%] h-0.5 w-[28%] rounded bg-white/50" />
</>
)}
{template === "COVER_RIGHT_TEXT_LEFT" && (
<>
<div className="absolute right-[6%] top-[18%] h-[64%] w-[38%] bg-gray-300" />
<div className="absolute left-[8%] top-[38%] h-1 w-[40%] rounded bg-white/80" />
<div className="absolute left-[8%] top-[52%] h-0.5 w-[28%] rounded bg-white/50" />
</>
)}
{template === "COVER_TOP_TEXT_BOTTOM" && (
<>
<div className="absolute left-[8%] top-[6%] h-[52%] w-[84%] bg-gray-300" />
@@ -178,12 +183,18 @@ function MiniThumb({
<div className="absolute left-[34%] bottom-[12%] h-0.5 w-[32%] rounded bg-white/50" />
</>
)}
{(template === "LOWER_LEFT_COVER_TEXT" ||
template === "LOWER_RIGHT_COVER_TEXT") && (
<>
<div className="absolute bottom-[8%] left-[5%] h-[36%] w-[24%] bg-gray-300" />
<div className="absolute bottom-[26%] left-[34%] h-1 w-[40%] rounded bg-white/80" />
<div className="absolute bottom-[14%] left-[34%] h-0.5 w-[28%] rounded bg-white/50" />
</>
)}
</div>
)}
</div>
<p className="px-2 py-1.5 text-[10px] font-medium text-gray-300">
{isClassic ? "Classic letterbox" : LAYOUT_TEMPLATE_LABELS[template]}
</p>
<p className="px-2 py-1.5 text-[10px] font-medium text-gray-300">{label}</p>
</button>
);
}
@@ -191,8 +202,18 @@ function MiniThumb({
function previewCoverStyle(
template: LayoutTemplate,
padPct: number,
textPadding: number,
encodeWidth: number,
): CSSProperties {
const p = `${padPct}%`;
// Equal pixel inset on both axes (not % — % of width ≠ % of height on 16:9).
const cornerEdgePx = scaleFontToPreview(textPadding, encodeWidth);
// Assume 16:9 when only width is known; side is usually width-driven for lower corner.
const encodeHeight = Math.round((encodeWidth * 9) / 16);
const cornerSidePx = scaleFontToPreview(
lowerCornerCoverSide(encodeWidth, encodeHeight, textPadding),
encodeWidth,
);
switch (template) {
case "COVER_LEFT_TEXT_RIGHT":
return {
@@ -234,6 +255,24 @@ function previewCoverStyle(
maxHeight: "38%",
objectFit: "contain",
};
case "LOWER_LEFT_COVER_TEXT":
return {
position: "absolute",
left: cornerEdgePx,
bottom: cornerEdgePx,
width: cornerSidePx,
height: cornerSidePx,
objectFit: "contain",
};
case "LOWER_RIGHT_COVER_TEXT":
return {
position: "absolute",
right: cornerEdgePx,
bottom: cornerEdgePx,
width: cornerSidePx,
height: cornerSidePx,
objectFit: "contain",
};
}
}
@@ -243,11 +282,20 @@ function previewTextStyle(
textOffsetX: number,
textOffsetY: number,
titleArtistGap: number,
textPadding: number,
encodeWidth: number,
): CSSProperties {
const p = `${padPct}%`;
const shift = {
transform: undefined as string | undefined,
};
const cornerEdgePx = scaleFontToPreview(textPadding, encodeWidth);
const coverTextGapPx = scaleFontToPreview(
Math.max(10, Math.round(textPadding * 0.55)),
encodeWidth,
);
const encodeHeight = Math.round((encodeWidth * 9) / 16);
const cornerSidePx = scaleFontToPreview(
lowerCornerCoverSide(encodeWidth, encodeHeight, textPadding),
encodeWidth,
);
const baseGap = { display: "flex", flexDirection: "column" as const, gap: `${titleArtistGap}px` };
@@ -294,13 +342,38 @@ function previewTextStyle(
textAlign: "center",
alignItems: "center",
};
case "LOWER_LEFT_COVER_TEXT":
return {
...baseGap,
position: "absolute",
left: cornerEdgePx + cornerSidePx + coverTextGapPx + textOffsetX,
right: p,
bottom: cornerEdgePx,
height: cornerSidePx,
justifyContent: "center",
textAlign: "left",
transform: textOffsetY ? `translateY(${textOffsetY}px)` : undefined,
};
case "LOWER_RIGHT_COVER_TEXT":
return {
...baseGap,
position: "absolute",
left: p,
right: cornerEdgePx + cornerSidePx + coverTextGapPx - textOffsetX,
bottom: cornerEdgePx,
height: cornerSidePx,
justifyContent: "center",
textAlign: "right",
alignItems: "flex-end",
transform: textOffsetY ? `translateY(${textOffsetY}px)` : undefined,
};
}
void shift;
}
export function LayoutStudio({
locked,
previewImageUrl,
previewBackgroundUrl = null,
songTitle,
artist,
encodeWidth = 1280,
@@ -310,24 +383,31 @@ export function LayoutStudio({
onWatermarkChange,
onUploadLogo,
onUploadFont,
onUploadBackground,
onClearBackground,
logoPreviewUrl,
hasCustomBackground = false,
}: Props) {
const [logoUploading, setLogoUploading] = useState(false);
const [logoError, setLogoError] = useState<string | null>(null);
const [fontUploading, setFontUploading] = useState(false);
const [fontError, setFontError] = useState<string | null>(null);
const [customFontObjectUrl, setCustomFontObjectUrl] = useState<string | null>(null);
const [bgUploading, setBgUploading] = useState(false);
const [bgError, setBgError] = useState<string | null>(null);
// Always coalesce HMR / older session state may omit newly added fields
const L: LayoutSettings = {
...DEFAULT_LAYOUT,
...layout,
blurFill: layout.blurFill ?? DEFAULT_LAYOUT.blurFill,
blurAmount: layout.blurAmount ?? DEFAULT_LAYOUT.blurAmount,
blurOpacity: layout.blurOpacity ?? BLUR_OPACITY_DEFAULT,
textPadding: layout.textPadding ?? DEFAULT_LAYOUT.textPadding,
titleArtistGap: layout.titleArtistGap ?? DEFAULT_LAYOUT.titleArtistGap,
textOffsetX: layout.textOffsetX ?? DEFAULT_LAYOUT.textOffsetX,
textOffsetY: layout.textOffsetY ?? DEFAULT_LAYOUT.textOffsetY,
titleBold: layout.titleBold ?? DEFAULT_LAYOUT.titleBold,
};
const W: WatermarkSettings = {
...DEFAULT_WATERMARK,
@@ -386,13 +466,34 @@ export function LayoutStudio({
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');
el.textContent = [
`@font-face {
font-family: '${SYSTEM_FONT.previewFamily}';
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}') format('truetype');
font-weight: 400;
font-display: swap;
}`,
).join("\n");
`@font-face {
font-family: '${SYSTEM_FONT.previewFamily}';
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}?weight=bold') format('truetype');
font-weight: 600;
font-display: swap;
}`,
`@font-face {
font-family: '${SYSTEM_FONT.previewFamily}';
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}?weight=bold') format('truetype');
font-weight: 700;
font-display: swap;
}`,
...CURATED_FONTS.map(
(f) => `@font-face {
font-family: 'S2VIDPreview-${f.key}';
src: url('${curatedFontApiUrl(f.key)}') format('truetype');
font-weight: 400;
font-display: swap;
}`,
),
].join("\n");
}, []);
useEffect(() => {
@@ -463,6 +564,21 @@ export function LayoutStudio({
}
}
async function handleBackground(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file || locked || !onUploadBackground) return;
setBgError(null);
setBgUploading(true);
try {
await onUploadBackground(file);
} catch (err) {
setBgError(err instanceof Error ? err.message : "Background upload failed");
} finally {
setBgUploading(false);
}
}
function setFontKey(key: WatermarkFontKey) {
if (key === "custom") {
patchWm({ fontKey: "custom", fontPath: watermark.fontPath ?? null });
@@ -472,6 +588,8 @@ export function LayoutStudio({
}
const artTrack = Boolean(L.template);
const lowerCorner = isLowerCornerTemplate(L.template);
const blurPreviewUrl = previewBackgroundUrl || previewImageUrl;
return (
<div
@@ -495,7 +613,7 @@ export function LayoutStudio({
<div className="absolute inset-0 bg-black" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewImageUrl}
src={blurPreviewUrl!}
alt=""
className="absolute inset-0 h-full w-full object-cover"
style={{
@@ -508,7 +626,12 @@ export function LayoutStudio({
<img
src={previewImageUrl}
alt=""
style={previewCoverStyle(L.template!, padPct)}
style={previewCoverStyle(
L.template!,
padPct,
L.textPadding,
encodeWidth,
)}
className="pointer-events-none"
/>
<div
@@ -518,12 +641,17 @@ export function LayoutStudio({
L.textOffsetX,
L.textOffsetY,
L.titleArtistGap,
L.textPadding,
encodeWidth,
)}
>
<p
className="max-w-full truncate font-semibold text-white drop-shadow"
className={`max-w-full truncate text-white drop-shadow ${
L.titleBold ? "font-semibold" : "font-normal"
}`}
style={{
...textFontStyle,
fontWeight: L.titleBold ? 600 : 400,
fontSize: `${titlePreviewPx}px`,
lineHeight: 1.15,
}}
@@ -543,13 +671,28 @@ export function LayoutStudio({
</div>
</>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-black">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewImageUrl}
alt=""
className="max-h-full max-w-full object-contain"
/>
<div className="absolute inset-0 bg-black">
{L.blurFill && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewImageUrl}
alt=""
className="absolute inset-0 h-full w-full object-cover"
style={{
filter: L.blurAmount > 0 ? `blur(${blurPx}px)` : undefined,
transform: "scale(1.15)",
opacity: L.blurOpacity / 100,
}}
/>
)}
<div className="absolute inset-0 flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewImageUrl}
alt=""
className="max-h-full max-w-full object-contain"
/>
</div>
</div>
)}
@@ -595,22 +738,114 @@ export function LayoutStudio({
{/* Art-track templates */}
<p className="mb-2 mt-5 text-sm font-medium text-gray-300">Composition</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
<MiniThumb
template={null}
active={L.template === null}
disabled={locked}
onClick={() => patchLayout({ template: null })}
/>
{LAYOUT_TEMPLATES.map((t) => (
<MiniThumb
key={t}
template={t}
active={L.template === t}
disabled={locked}
onClick={() => patchLayout({ template: t })}
/>
))}
{COMPOSITION_FAMILIES.map((family) => {
const active =
family.id === "classic"
? L.template === null
: family.variants.includes(L.template as LayoutTemplate);
return (
<MiniThumb
key={family.id}
template={family.thumb}
label={family.label}
active={active}
disabled={locked}
onClick={() => {
if (family.id === "classic") {
patchLayout({ template: null });
return;
}
if (
L.template &&
family.variants.includes(L.template as LayoutTemplate)
) {
return;
}
patchLayout({ template: family.defaultTemplate, blurFill: false });
}}
/>
);
})}
</div>
{(() => {
const family = compositionFamilyForTemplate(L.template);
if (family.variants.length < 2) return null;
return (
<div className="mt-2 flex flex-wrap gap-2">
{family.variants.map((variant) => (
<button
key={variant}
type="button"
disabled={locked}
onClick={() => patchLayout({ template: variant })}
className={`rounded border px-2.5 py-1.5 text-[11px] font-medium transition-colors ${
L.template === variant
? "border-accent bg-accent/15 text-white"
: "border-gray-700 bg-black/40 text-gray-300 hover:border-gray-500"
} disabled:cursor-not-allowed disabled:opacity-50`}
>
{LAYOUT_TEMPLATE_LABELS[variant]}
</button>
))}
</div>
);
})()}
{!artTrack && (
<label className="mt-3 flex cursor-pointer items-start gap-2 text-sm text-gray-300">
<input
type="checkbox"
className="mt-0.5"
disabled={locked}
checked={L.blurFill}
onChange={(e) => patchLayout({ blurFill: e.target.checked })}
/>
<span>
<span className="font-medium text-gray-200">Blurred cover background</span>
<span className="mt-0.5 block text-xs text-gray-500">
Fill letterbox bars with a blurred cover instead of black. Adjust blur and opacity
below when enabled.
</span>
</span>
</label>
)}
{lowerCorner && (
<div className="mt-3 rounded border border-gray-700 bg-black/30 p-3">
<p className="mb-2 text-sm font-medium text-gray-300">Background image</p>
<p className="mb-2 text-xs text-gray-500">
Optional. Used as the blurred full-frame fill; the cover stays the sharp corner square.
Leave empty to blur the cover itself.
</p>
<div className="flex flex-wrap items-center gap-2">
<label
className={`cursor-pointer rounded border border-gray-600 bg-surface-dark px-3 py-1.5 text-xs text-gray-200 hover:border-gray-400 ${
locked || bgUploading ? "pointer-events-none opacity-40" : ""
}`}
>
{bgUploading ? "Uploading…" : hasCustomBackground ? "Replace image" : "Upload image"}
<input
type="file"
accept="image/*"
className="hidden"
disabled={locked || bgUploading || !onUploadBackground}
onChange={handleBackground}
/>
</label>
{hasCustomBackground && (
<button
type="button"
disabled={locked}
onClick={() => onClearBackground?.()}
className="rounded border border-gray-700 px-3 py-1.5 text-xs text-gray-400 hover:border-gray-500 hover:text-gray-200 disabled:opacity-40"
>
Use cover as background
</button>
)}
</div>
{bgError && <p className="mt-2 text-xs text-red-400">{bgError}</p>}
</div>
)}
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<label className="text-sm text-gray-400">
@@ -619,7 +854,7 @@ export function LayoutStudio({
type="range"
min={BLUR_AMOUNT_MIN}
max={BLUR_AMOUNT_MAX}
disabled={locked || !artTrack}
disabled={locked || !(artTrack || L.blurFill)}
value={L.blurAmount}
onChange={(e) => patchLayout({ blurAmount: Number(e.target.value) })}
className="mt-1 w-full disabled:opacity-40"
@@ -631,7 +866,7 @@ export function LayoutStudio({
type="range"
min={BLUR_OPACITY_MIN}
max={BLUR_OPACITY_MAX}
disabled={locked || !artTrack}
disabled={locked || !(artTrack || L.blurFill)}
value={L.blurOpacity}
onChange={(e) => patchLayout({ blurOpacity: Number(e.target.value) })}
className="mt-1 w-full disabled:opacity-40"
@@ -687,6 +922,20 @@ export function LayoutStudio({
className="mt-1 w-full disabled:opacity-40"
/>
</label>
<label
className={`flex items-center gap-2 text-sm text-gray-300 sm:col-span-2 ${
locked || !artTrack ? "opacity-40" : ""
}`}
>
<input
type="checkbox"
className="rounded border-gray-600 bg-surface-dark text-accent focus:ring-accent"
disabled={locked || !artTrack}
checked={L.titleBold}
onChange={(e) => patchLayout({ titleBold: e.target.checked })}
/>
Bold song title
</label>
</div>
{/* Typography (matches FFmpeg art-track + watermark text fonts) */}
@@ -700,7 +949,7 @@ export function LayoutStudio({
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
className="input-field mt-1"
>
<option value="system">Arial (system default)</option>
<option value="system">{SYSTEM_FONT.label}</option>
{CURATED_FONTS.map((f) => (
<option key={f.key} value={f.key}>
{f.label}
+8 -5
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { DOCS_URL, GITEA_URL } from "@/lib/plans";
function LegalLink({
href,
@@ -33,7 +34,7 @@ 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} Songs2VID. All rights reserved.</span>
<span>© {year} Songs2VID. MIT licensed.</span>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
@@ -50,16 +51,18 @@ export function LegalFooter() {
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<LegalLink href="/terms">Terms of Service</LegalLink>
<LegalLink href="/terms">Terms of Use</LegalLink>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<LegalLink href="https://docs.songs2vid.com" external>Documentation</LegalLink>
<LegalLink href={DOCS_URL} external>
Documentation
</LegalLink>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<LegalLink href="https://status.atakanozban.com/status/2" external>
Service Status
<LegalLink href={GITEA_URL} external>
Source
</LegalLink>
</div>
</footer>
+2 -1
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import type { ReactNode } from "react";
import { Logo } from "@/components/Logo";
import { LEGAL_LAST_UPDATED } from "@/lib/legal/constants";
import { DOCS_URL } from "@/lib/plans";
type Props = {
title: string;
@@ -45,7 +46,7 @@ export function LegalPageLayout({ title, description, children }: Props) {
Terms
</Link>
<a
href="https://docs.songs2vid.com"
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="hover:text-gray-300"
+30 -1
View File
@@ -8,7 +8,7 @@ import { audioTagsToMetadata } from "@/lib/audio-tags";
import { resolveYouTubeTitle } from "@/lib/titles";
import { SONG_TITLE_MAX } from "@/lib/layout";
import type { ItemMetadata } from "@/lib/types";
import { DEFAULT_LAYOUT, type LayoutSettings } from "@/lib/layout";
import { DEFAULT_LAYOUT, isLowerCornerTemplate, type LayoutSettings } from "@/lib/layout";
import { DEFAULT_WATERMARK, type WatermarkSettings } from "@/lib/watermark";
import { CategorySelect } from "./CategorySelect";
import { LayoutStudio } from "./LayoutStudio";
@@ -65,6 +65,8 @@ export function UploadForm() {
const [jobWatermark, setJobWatermark] = useState<WatermarkSettings>({ ...DEFAULT_WATERMARK });
const [jobLayout, setJobLayout] = useState<LayoutSettings>({ ...DEFAULT_LAYOUT });
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
const [backgroundImagePath, setBackgroundImagePath] = useState<string | null>(null);
const [backgroundPreviewUrl, setBackgroundPreviewUrl] = useState<string | null>(null);
const [quota, setQuota] = useState<{
maxBatchSize: number;
used: number;
@@ -89,6 +91,7 @@ export function UploadForm() {
return () => {
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
if (logoPreviewUrl) URL.revokeObjectURL(logoPreviewUrl);
if (backgroundPreviewUrl) URL.revokeObjectURL(backgroundPreviewUrl);
audioItems.forEach((a) => {
if (a.itemImagePreview) URL.revokeObjectURL(a.itemImagePreview);
});
@@ -258,6 +261,21 @@ export function UploadForm() {
return uploaded.path;
}
async function handleBackgroundUpload(file: File) {
const preview = URL.createObjectURL(file);
if (backgroundPreviewUrl) URL.revokeObjectURL(backgroundPreviewUrl);
setBackgroundPreviewUrl(preview);
const uploaded = await uploadFile(file, "image");
setBackgroundImagePath(uploaded.path);
return uploaded.path;
}
function clearBackgroundImage() {
if (backgroundPreviewUrl) URL.revokeObjectURL(backgroundPreviewUrl);
setBackgroundPreviewUrl(null);
setBackgroundImagePath(null);
}
function applyWatermarkToAll(next: WatermarkSettings) {
const normalized = { ...DEFAULT_WATERMARK, ...next };
setJobWatermark(normalized);
@@ -276,6 +294,9 @@ export function UploadForm() {
function applyLayoutToAll(next: LayoutSettings) {
const normalized = { ...DEFAULT_LAYOUT, ...next };
setJobLayout(normalized);
if (!isLowerCornerTemplate(normalized.template)) {
clearBackgroundImage();
}
setAudioItems((prev) =>
prev.map((item) => ({
...item,
@@ -371,6 +392,10 @@ export function UploadForm() {
includeWatermark: effectiveWatermark.mode !== "none",
watermark: effectiveWatermark,
layout: jobLayout,
backgroundImagePath:
isLowerCornerTemplate(jobLayout.template) && backgroundImagePath
? backgroundImagePath
: null,
},
})),
}),
@@ -620,6 +645,7 @@ export function UploadForm() {
previewImageUrl={
audioItems.find((a) => a.itemImagePreview)?.itemImagePreview || imagePreviewUrl
}
previewBackgroundUrl={backgroundPreviewUrl}
songTitle={
audioItems[0]?.metadata.songTitle ||
audioItems[0]?.metadata.title ||
@@ -633,6 +659,9 @@ export function UploadForm() {
onWatermarkChange={applyWatermarkToAll}
onUploadLogo={handleLogoUpload}
onUploadFont={handleFontUpload}
onUploadBackground={handleBackgroundUpload}
onClearBackground={clearBackgroundImage}
hasCustomBackground={Boolean(backgroundImagePath)}
logoPreviewUrl={logoPreviewUrl}
/>
+33 -6
View File
@@ -11,9 +11,14 @@ import { getVideoAttributionText } from "@/lib/branding";
import {
CURATED_FONTS,
googleFontsStylesheetUrl,
SYSTEM_FONT,
type CuratedFontKey,
type WatermarkFontKey,
} from "@/lib/fonts";
import {
curatedFontApiUrl,
previewFontFamilyCss,
} from "@/lib/preview-typography";
import {
WATERMARK_OFFSET_MAX,
WATERMARK_OFFSET_MIN,
@@ -78,10 +83,7 @@ function previewStyle(
}
function previewFontFamily(fontKey: WatermarkFontKey | undefined): string {
if (!fontKey || fontKey === "system") return "ui-sans-serif, system-ui, sans-serif";
if (fontKey === "custom") return `'${CUSTOM_PREVIEW_FAMILY}', sans-serif`;
const meta = CURATED_FONTS.find((f) => f.key === fontKey);
return meta ? `'${meta.cssFamily}', sans-serif` : "sans-serif";
return previewFontFamilyCss(fontKey);
}
export function WatermarkPreview({
@@ -110,7 +112,32 @@ export function WatermarkPreview({
[value.fontKey],
);
// Load curated Google Fonts for live canvas preview
// Load bundled + curated fonts for live canvas preview (same files as FFmpeg)
useEffect(() => {
const styleId = "s2vid-watermark-preview-fonts";
let el = document.getElementById(styleId) as HTMLStyleElement | null;
if (!el) {
el = document.createElement("style");
el.id = styleId;
document.head.appendChild(el);
}
el.textContent = [
`@font-face {
font-family: '${SYSTEM_FONT.previewFamily}';
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}') format('truetype');
font-display: swap;
}`,
...CURATED_FONTS.map(
(f) => `@font-face {
font-family: 'S2VIDPreview-${f.key}';
src: url('${curatedFontApiUrl(f.key)}') format('truetype');
font-display: swap;
}`,
),
].join("\n");
}, []);
// Legacy Google Fonts link (kept for any older preview paths)
useEffect(() => {
const id = "s2vid-watermark-google-fonts";
if (document.getElementById(id)) return;
@@ -287,7 +314,7 @@ export function WatermarkPreview({
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
className="input-field mt-1"
>
<option value="system">System default</option>
<option value="system">{SYSTEM_FONT.label}</option>
{CURATED_FONTS.map((f) => (
<option key={f.key} value={f.key} style={{ fontFamily: f.cssFamily }}>
{f.label}
+2
View File
@@ -27,6 +27,7 @@ services:
retries: 10
web:
image: atakanozban/songs2vid:${S2VID_IMAGE_TAG:-latest}
build: .
restart: unless-stopped
ports:
@@ -49,6 +50,7 @@ services:
sh -c "npx prisma db push && node server.js"
worker:
image: atakanozban/songs2vid:${S2VID_IMAGE_TAG:-latest}
build: .
restart: unless-stopped
env_file:
+12
View File
@@ -0,0 +1,12 @@
# Songs2VID OSS documentation
This folder ships with the OSS repo so API reference stays available without the docs site.
| Doc | Contents |
|-----|----------|
| [API overview](./api/overview.md) | Auth, rate limits, recommended flows, discovery (`layoutTemplates`, `compositionFamilies`) |
| [API endpoints](./api/endpoints.md) | curl examples for upload, jobs, layouts (incl. lower-corner templates), playlists, errors |
Live docs (same product truth for self-host): [docs.songs2vid.com](https://docs.songs2vid.com)
Setup and env: see the root [README](../README.md) and [`.env.example`](../.env.example).
+380
View File
@@ -0,0 +1,380 @@
# Endpoints
Set these for the examples below:
```bash
export BASE_URL="http://localhost:3000" # local / self-hosted app
export API_KEY="s2yt_live_your_key_here"
```
Live HTML docs: [docs.songs2vid.com/docs/api/endpoints](https://docs.songs2vid.com/docs/api/endpoints).
## Discovery
```bash
curl "$BASE_URL/api/v1"
```
Returns the endpoint list and requirements (**no auth**). No billing routes are listed or implemented.
## Upload a file (two-step)
```bash
curl -X POST "$BASE_URL/api/v1/upload" \
-H "Authorization: Bearer $API_KEY" \
-F "file=@cover.jpg" \
-F "type=image"
```
```bash
curl -X POST "$BASE_URL/api/v1/upload" \
-H "Authorization: Bearer $API_KEY" \
-F "file=@track1.mp3" \
-F "type=audio"
```
```bash
# Optional: PNG watermark logo
curl -X POST "$BASE_URL/api/v1/upload" \
-H "Authorization: Bearer $API_KEY" \
-F "file=@logo.png" \
-F "type=logo"
```
```bash
# Optional: custom font (.ttf / .otf, max 10 MB)
curl -X POST "$BASE_URL/api/v1/upload" \
-H "Authorization: Bearer $API_KEY" \
-F "file=@Brand.ttf" \
-F "type=font"
```
### Request fields
| Field | Required | Notes |
|-------|----------|--------|
| `file` | Yes | Multipart file |
| `type` | Yes | `image` \| `audio` \| `logo` \| `font` |
### Allowed files
| `type` | Formats | Max size |
|--------|---------|----------|
| `image` | JPEG, PNG, WebP, GIF | 500 MB |
| `audio` | MP3, WAV, FLAC | 500 MB |
| `logo` | PNG only | 500 MB |
| `font` | `.ttf` / `.otf` | **10 MB** |
### Response
```json
{
"path": "/uploads/.../track.mp3",
"filename": "track.mp3",
"size": 4123456,
"audioTags": {
"title": "Song Title",
"artist": "Artist Name",
"album": "Album",
"genre": "Electronic",
"year": "2024"
}
}
```
`audioTags` is present for MP3 when tags are readable; otherwise `null`.
## Create job / render from paths (recommended)
Self-hosted unlocks per-track covers, custom watermarks/fonts, and art-track layouts. Max batch size: **100**. Watermarks are optional — nothing forces the default Songs2VID badge.
`POST /api/v1/render` is an **n8n-friendly alias** of `POST /api/v1/jobs` (identical body and response). `GET /api/v1/render` lists jobs like `GET /api/v1/jobs`.
Optional `webhookUrl` (absolute `http(s)` URL) makes Songs2VID POST JSON when items/jobs finish — preferred for n8n.
```bash
curl -X POST "$BASE_URL/api/v1/render" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"imagePath": "/uploads/.../cover.jpg",
"webhookUrl": "https://your-n8n.example/webhook/songs2vid-complete",
"items": [{
"audioPath": "/uploads/.../track.mp3",
"audioFilename": "track.mp3",
"metadata": {
"title": "My Artist - My Track (Official Audio)",
"songTitle": "My Track",
"artist": "My Artist",
"description": "",
"tags": "electronic",
"privacy": "PUBLIC",
"categoryId": "10",
"resolution": "1920x1080",
"notifySubscribers": true,
"madeForKids": false,
"embeddable": true,
"creativeCommons": false,
"includeWatermark": true,
"layout": {
"template": "LOWER_LEFT_COVER_TEXT",
"blurAmount": 60,
"blurOpacity": 85,
"textPadding": 48,
"titleArtistGap": 12,
"titleBold": true,
"textOffsetX": 0,
"textOffsetY": 0
},
"watermark": {
"mode": "default",
"fontKey": "montserrat",
"position": "bottom-right",
"offsetX": 24,
"offsetY": 24
},
"playlistId": null
}
}]
}'
```
Equivalent path: `POST $BASE_URL/api/v1/jobs` with the same JSON.
### Success response
```json
{
"jobId": "clxxxxxxxx",
"itemCount": 1,
"status": "PENDING",
"statusUrl": "/api/v1/jobs/clxxxxxxxx",
"webhookUrl": "https://your-n8n.example/webhook/songs2vid-complete",
"playlist": null
}
```
### Webhook payload
| `event` | Meaning |
|---------|---------|
| `job.item.completed` | One track finished (`youtubeVideoId` set) |
| `job.item.failed` | One track failed (`error` set) |
| `job.completed` | All items succeeded |
| `job.failed` | All items failed |
| `job.partial` | Mix of success and failure |
Community node: [`n8n-nodes-songs2vid`](https://www.npmjs.com/package/n8n-nodes-songs2vid) — see [docs/n8n.md](../n8n.md) and [docs.songs2vid.com/docs/n8n](https://docs.songs2vid.com/docs/n8n).
<details>
<summary>Legacy example without webhook (same metadata shape)</summary>
```bash
curl -X POST "$BASE_URL/api/v1/jobs" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"imagePath": "/uploads/.../cover.jpg",
"items": [{
"audioPath": "/uploads/.../track.mp3",
"audioFilename": "track.mp3",
"metadata": {
"title": "My Artist - My Track (Official Audio)",
"songTitle": "My Track",
"artist": "My Artist",
"privacy": "PUBLIC",
"categoryId": "10",
"resolution": "1920x1080",
"includeWatermark": false
}
}]
}'
```
</details>
### Metadata fields
| Field | Type | Notes |
|-------|------|--------|
| `title` | string | YouTube video title |
| `songTitle` | string \| null | On-video song title for art-track (max **120**) |
| `artist` | string \| null | On-video artist line (max **80**) |
| `description` | string | YouTube description |
| `tags` | string | Comma-separated |
| `privacy` | string | `PUBLIC` \| `PRIVATE` \| `UNLISTED` |
| `categoryId` | string | YouTube category ID |
| `resolution` | string | See resolutions below |
| `notifySubscribers` | boolean | YouTube notify flag |
| `madeForKids` | boolean | Made for kids |
| `embeddable` | boolean | Allow embedding |
| `creativeCommons` | boolean | CC vs standard YouTube license |
| `includeWatermark` | boolean | Apply watermark settings |
| `imagePath` | string \| null | Per-track cover |
| `backgroundImagePath` | string \| null | Lower-corner templates only: separate blur-fill image (else cover is blurred) |
| `playlistId` | string \| null | Existing playlist ID |
| `layout` | object | Art-track layout |
| `watermark` | object | Watermark settings |
Snake_case aliases are accepted for layout/watermark fields.
### Resolutions
| Value | Aspect |
|-------|--------|
| `1920x1080` | 16:9 |
| `1280x720` | 16:9 |
| `854x480` | 16:9 |
| `720x720` | 1:1 |
| `640x360` | 16:9 |
| `426x240` | 16:9 |
### YouTube categories
| ID | Name |
|----|------|
| `1` | Film & Animation |
| `2` | Autos & Vehicles |
| `10` | Music |
| `15` | Pets & Animals |
| `17` | Sports |
| `19` | Travel & Events |
| `20` | Gaming |
| `22` | People & Blogs |
| `23` | Comedy |
| `24` | Entertainment |
| `25` | News & Politics |
| `26` | Howto & Style |
| `27` | Education |
| `28` | Science & Technology |
| `29` | Nonprofits & Activism |
### Watermark fields
`watermark.mode`: `none` | `default` | `text` | `logo`
- `none` — no overlay
- `default` — built-in Songs2VID badge PNG (`assets/watermark.png`)
- `logo` — requires prior `type=logo` upload; set `logoPath`
- `text` — custom string (max **80**); set `text`
`watermark.position`: `top-left` | `top-right` | `bottom-left` | `bottom-right` | `center`
`watermark.offsetX` / `offsetY`: `0``200` (default `20`)
`watermark.fontKey`: `system` | `inter` | `montserrat` | `roboto` | `oswald` | `playfair` | `custom` (styles art-track text and text watermarks)
### Art-track layouts
`metadata.layout.template` (or flat `layout_template` / `layoutTemplate`). Valid enums are also listed on `GET /api/v1` as `layoutTemplates`. Mirrored pairs used by the Layout Studio composition grid are listed under `compositionFamilies` (`side`: cover beside text; `lower`: lower corner).
| Enum | Description |
|------|-------------|
| `COVER_LEFT_TEXT_RIGHT` | Cover left, title & artist right |
| `COVER_TOP_TEXT_BOTTOM` | Cover top, title & artist below |
| `COVER_RIGHT_TEXT_LEFT` | Cover right, title & artist left |
| `CENTERED_COMPACT` | Centered cover + text stack |
| `LOWER_LEFT_COVER_TEXT` | Lower-left cover; `textPadding` is equal left + bottom inset (diagonal from frame corner) with title/artist to the right |
| `LOWER_RIGHT_COVER_TEXT` | Lower-right cover; `textPadding` is equal right + bottom inset (diagonal from frame corner) with title/artist to the left |
For lower-corner templates only, optional `metadata.backgroundImagePath` (or `background_image_path`) sets a separate full-frame blur fill. Upload with `type=image` first, then pass the returned path. The cover (`imagePath` / per-item `metadata.imagePath`) stays the sharp corner square. Omit the field to blur the cover itself (default). `blurAmount` / `blurOpacity` still apply to whichever image is used as the fill.
Optional fine-tuning (clamped; camelCase or snake_case):
| Field | Range | Default | Purpose |
|-------|-------|---------|---------|
| `blurAmount` / `blur_amount` | 0100 | 55 | Background `boxblur` intensity |
| `blurOpacity` / `blur_opacity` | 0100 | 100 | Blurred fill vs black |
| `blurFill` / `blur_fill` | boolean | `false` | Classic letterbox only: fill bars with blurred cover (ignored for art-track templates) |
| `textPadding` / `text_padding` | 16120 | 48 | Edge inset for cover/text. On lower-corner templates this value is applied equally on both axes (left=bottom or right=bottom) so the cover corner sits on a true diagonal from the frame corner |
| `titleArtistGap` / `title_artist_gap` | 064 | 10 | Space between title and artist |
| `titleBold` / `title_bold` | boolean | `true` | Bold song title (preview + FFmpeg) |
| `textOffsetX` / `text_offset_x` | 120120 | 0 | Shift text block horizontally |
| `textOffsetY` / `text_offset_y` | 120120 | 0 | Shift text block vertically |
Also set `metadata.songTitle` (max 120) and `metadata.artist` (max 80) for the on-video text lines. `metadata.title` remains the YouTube title.
Omit `layout.template` for classic letterbox (black-padded cover by default). Set `layout.blurFill` / `blur_fill` to `true` to fill letterbox bars with a blurred cover; then `blurAmount` / `blurOpacity` apply. Free-form cover coordinates (`x`, `y`, `coverX`, …) and layout-level `offsetX`/`offsetY` are **rejected**.
Invalid template strings return **400**:
```json
{ "error": "Invalid layout template. Refer to API documentation for valid enum values." }
```
## YouTube playlists
```bash
curl "$BASE_URL/api/v1/playlists" \
-H "Authorization: Bearer $API_KEY"
```
```bash
curl -X POST "$BASE_URL/api/v1/playlists" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"My Album","description":"From Songs2VID","privacy":"unlisted"}'
```
Or pass `createPlaylist` on a job / batch body to create a playlist and attach all videos.
## One-shot batch (small packs only)
```bash
curl -X POST "$BASE_URL/api/v1/jobs/batch" \
-H "Authorization: Bearer $API_KEY" \
-F "image=@cover.jpg" \
-F "audio=@track1.mp3" \
-F "audio=@track2.mp3" \
-F 'metadata={"defaults":{"privacy":"PUBLIC"},"items":[{"title":"Track One"},{"title":"Track Two"}]}'
```
Prefer two-step upload + jobs for larger packs.
## Poll job status
```bash
curl "$BASE_URL/api/v1/jobs/JOB_ID" \
-H "Authorization: Bearer $API_KEY"
```
```bash
curl "$BASE_URL/api/v1/jobs?limit=10" \
-H "Authorization: Bearer $API_KEY"
```
`GET /api/v1/jobs` accepts `limit` (default **20**, max **100**).
### Job statuses
| Status | Meaning |
|--------|---------|
| `PENDING` | Queued |
| `PROCESSING` | Encoding or uploading |
| `COMPLETED` | All items succeeded |
| `FAILED` | All items failed |
| `PARTIAL` | Mix of completed and failed |
### Item statuses
| Status | Meaning |
|--------|---------|
| `PENDING` | Waiting |
| `ENCODING` | FFmpeg building video |
| `UPLOADING` | Uploading to YouTube |
| `COMPLETED` | Live (`youtubeVideoId` set) |
| `FAILED` | Failed (`error` message) |
YouTube channel daily upload caps are enforced by Google, not Songs2VID.
## HTTP errors
| Status | When |
|--------|------|
| `400` | Validation error |
| `401` | Missing or invalid API key |
| `403` | YouTube not connected, or request not allowed |
| `404` | Job not found |
| `429` | API rate limit exceeded |
There is no `402` payment / credits status in OSS.
+68
View File
@@ -0,0 +1,68 @@
# API overview
Programmatic uploads and batch jobs for self-hosted Songs2VID. Generate your API key under **Dashboard → Settings → API key**.
Keys start with `s2yt_live_` and are shown once at creation. The OSS / Docker image always allows API use — there is no plan, credit, or paywall gate.
There are **no billing endpoints** in this edition.
## Authentication
Send the key on every request:
```http
Authorization: Bearer s2yt_live_your_key_here
```
Requirements:
- YouTube channel connected (sign in with Google OAuth that includes YouTube scopes)
OAuth setup: root [README](../../README.md) and Googles [OAuth 2.0 for Web Server Applications](https://developers.google.com/identity/protocols/oauth2/web-server). YouTube scopes/API: [YouTube Data API Overview](https://developers.google.com/youtube/v3/getting-started).
## Rate limits
Self-hosted OSS uses a very high per-account ceiling (effectively unlimited for normal automation). You will rarely see `429`.
If a limit is hit, the response is **429** with `retryAfterSeconds` and a `Retry-After` header. See [Endpoints — HTTP errors](./endpoints.md#http-errors).
## Choosing a flow
### Recommended: two-step (especially 5+ audio files)
1. Upload each file with `POST /api/v1/upload`
2. Create the job with `POST /api/v1/render` (alias of `/api/v1/jobs`) — JSON paths + optional `webhookUrl`
This avoids huge multipart bodies. Max batch size is **100** tracks per job.
Prefer `webhookUrl` for n8n so long encodes do not block an HTTP Request node. Details: [n8n](../n8n.md).
### One-shot batch: small packs only
`POST /api/v1/jobs/batch` accepts one cover image and a few audio files in a single multipart request. Large bodies often fail with:
```text
failed to parse body as FormData
```
Prefer two-step for albums or long tracklists.
## Job lifecycle
1. Create job → status `PENDING`
2. Worker picks items → `ENCODING``UPLOADING``COMPLETED` or `FAILED`
3. Job rolls up to `COMPLETED`, `FAILED`, or `PARTIAL`
Poll with `GET /api/v1/jobs/:id`.
## Discovery
```http
GET /api/v1
```
Returns the endpoint list, requirements, `layoutTemplates` (every art-track enum id + label, including `LOWER_LEFT_COVER_TEXT` / `LOWER_RIGHT_COVER_TEXT`), and `compositionFamilies` (UI grouping for mirrored left/right variants). No auth required.
## Next
See [Endpoints](./endpoints.md) for curl examples. n8n: [docs/n8n.md](../n8n.md). Hosted HTML docs: [docs.songs2vid.com/docs/api/overview](https://docs.songs2vid.com/docs/api/overview).
+36
View File
@@ -0,0 +1,36 @@
# n8n integration (OSS)
Automate self-hosted Songs2VID with the community package **[`n8n-nodes-songs2vid`](https://www.npmjs.com/package/n8n-nodes-songs2vid)**.
Full hosted guide: [https://docs.songs2vid.com/docs/n8n](https://docs.songs2vid.com/docs/n8n)
## Install
In n8n → **Settings → Community nodes** → install:
```text
n8n-nodes-songs2vid
```
Or from this repo for local development:
```bash
cd integrations/n8n
npm install
npm run build
```
## Auth
1. Dashboard → **Settings → API key** → create `s2yt_live_…`
2. n8n credential **Songs2VID API**: paste key, Base URL = your instance (e.g. `http://localhost:3000`)
OSS has **no plan gate** — API keys work whenever YouTube is connected.
## Flow
1. **Upload** cover (`type=image`) and audio (`type=audio`)
2. **Create Render** (`POST /api/v1/render`) with paths + optional `webhookUrl`
3. Handle completion via n8n **Webhook** or poll `GET /api/v1/jobs/:id`
Package source: [`integrations/n8n`](../integrations/n8n/).
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
*.tsbuildinfo
.DS_Store
*.tgz
.env
.env.*
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Songs2VID
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+89
View File
@@ -0,0 +1,89 @@
# n8n-nodes-songs2vid
Official community node for **[Songs2VID](https://songs2vid.com)** — turn audio + cover art into YouTube-ready videos from [n8n](https://n8n.io/).
**Documentation:** [https://docs.songs2vid.com/docs/n8n](https://docs.songs2vid.com/docs/n8n) · in-repo [docs/n8n.md](../../docs/n8n.md)
Self-hosted OSS: set **Base URL** to your instance. API keys work without a paid plan (YouTube must be connected).
## Install
### From n8n (recommended)
1. Open n8n → **Settings → Community nodes**
2. **Install a community node**
3. Enter:
```text
n8n-nodes-songs2vid
```
4. Confirm and restart n8n if prompted
5. Search the canvas for **Songs2VID**
Self-hosted n8n needs community packages enabled (`N8N_COMMUNITY_PACKAGES_ENABLED=true`).
### From npm (manual)
```bash
cd ~/.n8n
npm install n8n-nodes-songs2vid
```
Restart n8n.
## Credentials
1. Create an API key under [Dashboard → Settings](https://songs2vid.com/dashboard/settings) (`s2yt_live_…`)
2. Cloud requires **Developer & Automation** (€15) or **Enterprise**, plus YouTube connected
3. In n8n: create a **Songs2VID API** credential
- **API Key:** your token
- **Base URL:** `https://songs2vid.com` (or your self-hosted origin, no trailing slash)
Requests use `Authorization: Bearer …` only.
## Quick start
1. **File → Upload** — cover (`type=image`) → save `path`
2. **File → Upload** — audio (`type=audio`) → save `path` / `filename`
3. **Render → Create Render** — paths + privacy + resolution
Optional: **Additional Fields → Webhook URL** for async completion
4. Handle `job.completed` / `job.item.completed` on an n8n **Webhook** node, or poll **Get Render Status**
## Operations
| Resource | Operation | API |
|----------|-----------|-----|
| Discovery | Get API Catalog | `GET /api/v1` |
| File | Upload | `POST /api/v1/upload` |
| Render | Create Render | `POST /api/v1/render` |
| Render | Create Job (alias) | `POST /api/v1/jobs` |
| Render | Create Batch | `POST /api/v1/jobs/batch` |
| Render | Get Render Status | `GET /api/v1/jobs/:id` |
| Render | List Renders | `GET /api/v1/jobs` |
| Playlist | List / Create | `/api/v1/playlists` |
| API Key | List / Create / Delete | `/api/v1/user/api-keys` |
Layout, watermark, playlist, and multi-track options are under **Additional Fields** on Create Render.
There is no Delete Render endpoint — finished videos live on YouTube.
## Links
- Guide: https://songs2vid.com/docs/n8n
- REST endpoints: https://songs2vid.com/docs/api/endpoints
- Workflow template: https://songs2vid.com/docs/n8n/workflow-template.json
- npm: https://www.npmjs.com/package/n8n-nodes-songs2vid
## Development
```bash
npm install
npm run build
```
`prepublishOnly` runs the build before `npm publish`.
## License
MIT
@@ -0,0 +1,57 @@
import type {
IAuthenticateGeneric,
ICredentialTestRequest,
ICredentialType,
INodeProperties,
} from "n8n-workflow";
/**
* Songs2VID REST auth — cloud accepts Authorization: Bearer only.
* Paste the raw key (s2yt_live_…) or a full "Bearer …" value.
*/
export class Songs2VidApi implements ICredentialType {
name = "songs2VidApi";
displayName = "Songs2VID API";
documentationUrl = "https://songs2vid.com/docs/n8n";
properties: INodeProperties[] = [
{
displayName: "API Key",
name: "apiKey",
type: "string",
typeOptions: { password: true },
default: "",
required: true,
description:
"Developer+ key from Dashboard → Settings (starts with s2yt_live_). Sent as Authorization: Bearer.",
},
{
displayName: "Base URL",
name: "baseUrl",
type: "string",
default: "https://songs2vid.com",
required: true,
description: "Cloud origin or self-hosted base (no trailing slash).",
},
];
authenticate: IAuthenticateGeneric = {
type: "generic",
properties: {
headers: {
Authorization:
'={{ $credentials.apiKey.toString().startsWith("Bearer ") ? $credentials.apiKey : "Bearer " + $credentials.apiKey }}',
},
},
};
test: ICredentialTestRequest = {
request: {
baseURL: "={{ $credentials.baseUrl.replace(/\\/$/, \"\") }}",
url: "/api/v1/user/api-keys",
method: "GET",
},
};
}
+14
View File
@@ -0,0 +1,14 @@
const { src, dest } = require("gulp");
/**
* Copy node/credential icons (+ codex JSON) into dist so n8n can resolve
* `icon: "file:songs2vid.svg"` next to the compiled .js files.
*/
function copyIcons() {
return src(
["nodes/**/*.{png,svg,json}", "credentials/**/*.{png,svg,json}"],
{ base: "." },
).pipe(dest("dist"));
}
exports["build:icons"] = copyIcons;
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env node
"use strict";
module.exports = {};
@@ -0,0 +1,18 @@
{
"node": "n8n-nodes-songs2vid.songs2Vid",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Marketing", "Productivity"],
"resources": {
"credentialDocumentation": [
{
"url": "https://songs2vid.com/docs/n8n"
}
],
"primaryDocumentation": [
{
"url": "https://songs2vid.com/docs/api/endpoints"
}
]
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" fill="none">
<rect width="60" height="60" rx="12" fill="#0a0a0a"/>
<text x="8" y="38" font-family="Arial,sans-serif" font-size="16" font-weight="700" fill="#fff">S2</text>
<text x="30" y="38" font-family="Arial,sans-serif" font-size="16" font-weight="700" fill="#f87171">VID</text>
</svg>

After

Width:  |  Height:  |  Size: 355 B

+5242
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
{
"name": "n8n-nodes-songs2vid",
"version": "1.0.0",
"description": "n8n community node for Songs2VID — upload audio/covers, create YouTube renders, poll jobs, manage playlists & API keys",
"keywords": [
"n8n-community-node-package",
"n8n-node",
"songs2vid",
"audio-to-video",
"youtube"
],
"license": "MIT",
"homepage": "https://songs2vid.com/docs/n8n",
"author": {
"name": "Songs2VID",
"url": "https://songs2vid.com"
},
"repository": {
"type": "git",
"url": "https://git.atakanozban.com/Songs2VID/songs2vid.git",
"directory": "integrations/n8n"
},
"bugs": {
"url": "https://songs2vid.com/docs/n8n"
},
"main": "index.js",
"scripts": {
"build": "tsc && gulp build:icons",
"dev": "tsc --watch",
"prepublishOnly": "npm run build"
},
"files": [
"dist"
],
"n8n": {
"n8nNodesApiVersion": 1,
"credentials": [
"dist/credentials/Songs2VidApi.credentials.js"
],
"nodes": [
"dist/nodes/Songs2Vid/Songs2Vid.node.js"
]
},
"devDependencies": {
"@types/node": "^20.11.0",
"gulp": "^4.0.2",
"n8n-workflow": "^1.70.0",
"typescript": "^5.3.3"
},
"peerDependencies": {
"n8n-workflow": "*"
},
"engines": {
"node": ">=18.10"
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2019",
"module": "commonjs",
"lib": ["ES2019"],
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["credentials/**/*", "nodes/**/*"],
"exclude": ["dist", "node_modules"]
}
@@ -0,0 +1,395 @@
{
"name": "Songs2VID — Audio → Render → YouTube (full)",
"meta": {
"templateCredsSetupCompleted": false,
"instanceId": "songs2vid-docs-template-v2"
},
"nodes": [
{
"parameters": {},
"id": "manual-trigger",
"name": "When clicking Test workflow",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
0,
0
]
},
{
"parameters": {
"path": "songs2vid-complete",
"httpMethod": "POST",
"responseMode": "onReceived",
"options": {}
},
"id": "webhook-complete",
"name": "Songs2VID Job Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
360
],
"webhookId": "songs2vid-complete"
},
{
"parameters": {
"content": "## Inputs\nProvide binary fields (`cover`, `audio`) or replace Manual Trigger with Drive/Dropbox.\n\nCredential: Header Auth → Name `Authorization`, Value `Bearer s2yt_live_…`\n\nSet env `SONGS2VID_WEBHOOK_URL` to this workflow's Production Webhook URL.",
"height": 240,
"width": 320
},
"id": "sticky-inputs",
"name": "Setup",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-320,
-40
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SONGS2VID_BASE_URL || 'https://songs2vid.com' }}/api/v1/upload",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"contentType": "multipart-form-data",
"bodyParameters": {
"parameters": [
{
"parameterType": "formBinaryData",
"name": "file",
"inputDataFieldName": "cover"
},
{
"name": "type",
"value": "image"
}
]
},
"options": {}
},
"id": "upload-cover",
"name": "Upload Cover Image",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
280,
0
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_ME",
"name": "Songs2VID API Key"
}
}
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SONGS2VID_BASE_URL || 'https://songs2vid.com' }}/api/v1/upload",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"contentType": "multipart-form-data",
"bodyParameters": {
"parameters": [
{
"parameterType": "formBinaryData",
"name": "file",
"inputDataFieldName": "audio"
},
{
"name": "type",
"value": "audio"
}
]
},
"options": {}
},
"id": "upload-audio",
"name": "Upload Audio",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
520,
0
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_ME",
"name": "Songs2VID API Key"
}
}
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SONGS2VID_BASE_URL || 'https://songs2vid.com' }}/api/v1/render",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"imagePath\": \"{{ $('Upload Cover Image').item.json.path }}\",\n \"webhookUrl\": \"{{ $env.SONGS2VID_WEBHOOK_URL || '' }}\",\n \"items\": [{\n \"audioPath\": \"{{ $('Upload Audio').item.json.path }}\",\n \"audioFilename\": \"{{ $('Upload Audio').item.json.filename }}\",\n \"metadata\": {\n \"title\": \"{{ $('Upload Audio').item.json.audioTags?.title || $('Upload Audio').item.json.filename }}\",\n \"songTitle\": \"{{ $('Upload Audio').item.json.audioTags?.title || '' }}\",\n \"artist\": \"{{ $('Upload Audio').item.json.audioTags?.artist || '' }}\",\n \"description\": \"Uploaded via n8n + Songs2VID\",\n \"tags\": \"music,songs2vid\",\n \"privacy\": \"UNLISTED\",\n \"categoryId\": \"10\",\n \"resolution\": \"1920x1080\",\n \"notifySubscribers\": false,\n \"madeForKids\": false,\n \"embeddable\": true,\n \"creativeCommons\": false,\n \"includeWatermark\": false\n }\n }]\n}",
"options": {}
},
"id": "create-render",
"name": "Create Render",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
760,
0
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_ME",
"name": "Songs2VID API Key"
}
}
},
{
"parameters": {
"method": "GET",
"url": "={{ $env.SONGS2VID_BASE_URL || 'https://songs2vid.com' }}{{ $json.statusUrl }}",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"options": {}
},
"id": "poll-status",
"name": "Get Render Status (optional poll)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1000,
0
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_ME",
"name": "Songs2VID API Key"
}
},
"disabled": true
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "job-done",
"leftValue": "={{ $json.body?.event || $json.event }}",
"rightValue": "job.completed",
"operator": {
"type": "string",
"operation": "equals"
}
},
{
"id": "item-done",
"leftValue": "={{ $json.body?.event || $json.event }}",
"rightValue": "job.item.completed",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "or"
},
"options": {}
},
"id": "if-completed",
"name": "Render Completed?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
280,
360
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "job-fail",
"leftValue": "={{ $json.body?.event || $json.event }}",
"rightValue": "job.failed",
"operator": {
"type": "string",
"operation": "equals"
}
},
{
"id": "item-fail",
"leftValue": "={{ $json.body?.event || $json.event }}",
"rightValue": "job.item.failed",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "or"
},
"options": {}
},
"id": "if-failed",
"name": "Render Failed?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
280,
560
]
},
{
"parameters": {
"content": "## YouTube live\nVideo ID: `{{ $json.body?.youtubeVideoId || $json.youtubeVideoId }}`\n\nhttps://youtu.be/{{ $json.body?.youtubeVideoId || $json.youtubeVideoId }}",
"height": 180,
"width": 360
},
"id": "sticky-success",
"name": "Success",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
560,
320
]
},
{
"parameters": {
"content": "## Failed\n`{{ $json.body?.error || $json.error }}`",
"height": 160,
"width": 360
},
"id": "sticky-fail",
"name": "Failure",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
560,
520
]
}
],
"connections": {
"When clicking Test workflow": {
"main": [
[
{
"node": "Upload Cover Image",
"type": "main",
"index": 0
}
]
]
},
"Upload Cover Image": {
"main": [
[
{
"node": "Upload Audio",
"type": "main",
"index": 0
}
]
]
},
"Upload Audio": {
"main": [
[
{
"node": "Create Render",
"type": "main",
"index": 0
}
]
]
},
"Create Render": {
"main": [
[
{
"node": "Get Render Status (optional poll)",
"type": "main",
"index": 0
}
]
]
},
"Songs2VID Job Webhook": {
"main": [
[
{
"node": "Render Completed?",
"type": "main",
"index": 0
}
]
]
},
"Render Completed?": {
"main": [
[
{
"node": "Success",
"type": "main",
"index": 0
}
],
[
{
"node": "Render Failed?",
"type": "main",
"index": 0
}
]
]
},
"Render Failed?": {
"main": [
[
{
"node": "Failure",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"tags": [
{
"name": "songs2vid"
},
{
"name": "n8n"
},
{
"name": "youtube"
}
],
"triggerCount": 0,
"updatedAt": "2026-08-08T00:00:00.000Z",
"versionId": "2"
}
+134 -16
View File
@@ -8,9 +8,12 @@ import {
isCuratedFontKey,
sanitizeFontfileForFilter,
} from "../fonts";
import { resolveCuratedFontPath } from "../fonts-server";
import { resolveCuratedFontPath, resolveSystemFontPath, assertFontFile } from "../fonts-server";
import {
buildArtTrackFilterComplex,
buildClassicBlurFillFilterComplex,
isLowerCornerTemplate,
normalizeLayoutSettings,
type LayoutSettings,
} from "../layout";
import {
@@ -61,9 +64,26 @@ export async function assertPngFile(filePath: string): Promise<void> {
async function resolveFontfileEscaped(
settings: WatermarkSettings,
weight: "regular" | "bold" = "regular",
): Promise<string | null> {
const key = settings.fontKey ?? "system";
if (key === "system") return null;
if (key === "system") {
const preferred = resolveSystemFontPath(weight);
const fallback = weight === "bold" ? resolveSystemFontPath("regular") : preferred;
for (const fontPath of weight === "bold" ? [preferred, fallback] : [preferred]) {
if (!(await fileExists(fontPath))) continue;
try {
await assertFontFile(fontPath);
return sanitizeFontfileForFilter(fontPath);
} catch (err) {
console.warn(
`[ffmpeg] system font invalid at ${fontPath}: ${err instanceof Error ? err.message : err}`,
);
}
}
console.warn(`[ffmpeg] system font missing; using FFmpeg default`);
return null;
}
if (key === "custom") {
if (!settings.fontPath) return null;
@@ -74,12 +94,19 @@ async function resolveFontfileEscaped(
}
if (isCuratedFontKey(key)) {
const fontPath = resolveCuratedFontPath(key);
if (!(await fileExists(fontPath))) {
console.warn(`[ffmpeg] curated font missing: ${key} at ${fontPath}; using system font`);
return null;
const preferred = resolveCuratedFontPath(key, weight);
const fallback = weight === "bold" ? resolveCuratedFontPath(key, "regular") : preferred;
for (const fontPath of weight === "bold" && preferred !== fallback ? [preferred, fallback] : [preferred]) {
if (!(await fileExists(fontPath))) continue;
try {
await assertFontFile(fontPath);
return sanitizeFontfileForFilter(fontPath);
} catch {
/* try next */
}
}
return sanitizeFontfileForFilter(fontPath);
console.warn(`[ffmpeg] curated font missing: ${key}; using system font`);
return null;
}
return null;
@@ -89,6 +116,38 @@ function classicScaleFilter(width: number, height: number): string {
return `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`;
}
/**
* Plan-aware audio for MP4.
* OSS / self-hosted defaults to Pro-quality 320k AAC.
*/
export type AudioEncodeOptions = {
bitrateKbps: 192 | 320;
copyWhenSafe: boolean;
audioPath: string;
};
function isCopySafeAudioPath(audioPath: string): boolean {
const ext = path.extname(audioPath).toLowerCase();
return ext === ".aac" || ext === ".m4a" || ext === ".mp4";
}
function pushAudioEncodeArgs(args: string[], audio: AudioEncodeOptions): void {
if (audio.copyWhenSafe && isCopySafeAudioPath(audio.audioPath)) {
args.push("-c:a", "copy");
return;
}
args.push(
"-c:a",
"aac",
"-b:a",
`${audio.bitrateKbps}k`,
"-ar",
"44100",
"-ac",
"2",
);
}
/**
* Art-track filter that ends at `outLabel` instead of hardcoded [laid].
*/
@@ -99,6 +158,13 @@ function artTrackFilterEndingAt(
return buildArtTrackFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`);
}
function classicBlurFillEndingAt(
opts: Parameters<typeof buildClassicBlurFillFilterComplex>[0],
outLabel: string,
): string {
return buildClassicBlurFillFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`);
}
export async function encodeVideo(options: {
imagePath: string;
audioPath: string;
@@ -110,24 +176,60 @@ export async function encodeVideo(options: {
/** On-video song title (art-track layouts). */
songTitle?: string;
artist?: string | null;
/**
* Optional full-frame background for lower-corner templates.
* When set with LOWER_LEFT / LOWER_RIGHT, blur fill uses this image; cover stays sharp.
*/
backgroundImagePath?: string | null;
/** Defaults to 320k (self-hosted / studio). */
audioBitrateKbps?: 192 | 320;
audioCopyWhenSafe?: boolean;
}): Promise<void> {
const res = getResolution(options.resolution);
if (!res) throw new Error(`Invalid resolution: ${options.resolution}`);
const audioEncode: AudioEncodeOptions = {
bitrateKbps: options.audioBitrateKbps ?? 320,
copyWhenSafe: options.audioCopyWhenSafe ?? true,
audioPath: options.audioPath,
};
await fs.mkdir(path.dirname(options.outputPath), { recursive: true });
const settings = normalizeWatermarkSettings(options.watermark, options.includeWatermark);
const layout = options.layout?.template ? options.layout : null;
const layoutSettings = options.layout
? normalizeLayoutSettings(options.layout)
: null;
const artTrack = Boolean(layoutSettings?.template);
const classicBlurFill = Boolean(layoutSettings && !artTrack && layoutSettings.blurFill);
const layout = artTrack && layoutSettings ? layoutSettings : null;
const watermarkWidth = Math.max(1, Math.round(res.width * WATERMARK_WIDTH_FRACTION));
const fontSize = watermarkFontSizeForWidth(res.width);
const fontfile = await resolveFontfileEscaped(settings);
const fontfile = await resolveFontfileEscaped(settings, "regular");
const titleFontfile =
layout?.titleBold ? await resolveFontfileEscaped(settings, "bold") : null;
// Only pass a distinct bold face when it differs from regular (avoids faux-bold when bold TTF exists).
const titleFontfileDistinct =
titleFontfile && titleFontfile !== fontfile ? titleFontfile : null;
const args = ["-y", "-loop", "1", "-r", "1", "-i", options.imagePath, "-i", options.audioPath];
let nextInput = 2;
let separateBackgroundInputIndex: number | null = null;
let logoInputIndex: number | null = null;
let defaultWmInputIndex: number | null = null;
const useSeparateBackground =
Boolean(layout && isLowerCornerTemplate(layout.template) && options.backgroundImagePath);
if (useSeparateBackground && options.backgroundImagePath) {
if (!(await fileExists(options.backgroundImagePath))) {
throw new Error("Background image file not found");
}
args.push("-loop", "1", "-r", "1", "-i", options.backgroundImagePath);
separateBackgroundInputIndex = nextInput++;
}
const needsLogo = settings.mode === "logo" && Boolean(settings.logoPath);
const defaultPath = getWatermarkPath();
const useDefaultPng =
@@ -156,16 +258,17 @@ export async function encodeVideo(options: {
? ("default-text" as const)
: ("none" as const);
// Fast path: classic letterbox, no watermark
if (!layout && applyWm === "none") {
// Fast path: classic letterbox (black bars), no watermark, no blur fill
if (!layout && !classicBlurFill && applyWm === "none") {
args.push("-vf", classicScaleFilter(res.width, res.height));
args.push(
"-c:v",
"libx264",
"-tune",
"stillimage",
"-c:a",
"copy",
);
pushAudioEncodeArgs(args, audioEncode);
args.push(
"-shortest",
"-pix_fmt",
"yuv420p",
@@ -192,6 +295,20 @@ export async function encodeVideo(options: {
titleEscaped,
artistEscaped,
fontfileEscaped: fontfile,
titleFontfileEscaped: titleFontfileDistinct,
separateBackgroundInputIndex,
},
baseLabel,
),
);
} else if (classicBlurFill && layoutSettings) {
filterParts.push(
classicBlurFillEndingAt(
{
width: res.width,
height: res.height,
blurAmount: layoutSettings.blurAmount,
blurOpacity: layoutSettings.blurOpacity,
},
baseLabel,
),
@@ -235,7 +352,7 @@ export async function encodeVideo(options: {
position: settings.position,
offsetX: settings.offsetX,
offsetY: settings.offsetY,
fontfileEscaped: null,
fontfileEscaped: fontfile,
});
filterParts.push(`[${baseLabel}]${draw}[vout]`);
}
@@ -246,8 +363,9 @@ export async function encodeVideo(options: {
"libx264",
"-tune",
"stillimage",
"-c:a",
"copy",
);
pushAudioEncodeArgs(args, audioEncode);
args.push(
"-shortest",
"-pix_fmt",
"yuv420p",
+17 -4
View File
@@ -2,19 +2,32 @@
import fs from "fs/promises";
import path from "path";
import { CURATED_FONTS, type CuratedFontKey } from "./fonts";
import { CURATED_FONTS, SYSTEM_FONT, type CuratedFontKey } from "./fonts";
export function getFontsDir(): string {
return path.join(process.cwd(), "assets", "fonts");
}
/** Resolve the bundled system (Arimo) font for FFmpeg + preview. */
export function resolveSystemFontPath(weight: "regular" | "bold" = "regular"): string {
const file = weight === "bold" ? SYSTEM_FONT.boldFile : SYSTEM_FONT.file;
const safe = file.replace(/[^a-zA-Z0-9._-]/g, "");
if (safe !== file) throw new Error("Invalid font asset name");
return path.join(getFontsDir(), safe);
}
/** Resolve a curated font file on disk (must exist under assets/fonts). */
export function resolveCuratedFontPath(key: CuratedFontKey): string {
export function resolveCuratedFontPath(
key: CuratedFontKey,
weight: "regular" | "bold" = "regular",
): string {
const meta = CURATED_FONTS.find((f) => f.key === key);
if (!meta) throw new Error("Unknown font");
const file =
weight === "bold" && meta.boldFile ? meta.boldFile : meta.file;
// Whitelist filename only never accept user-controlled path segments
const safe = meta.file.replace(/[^a-zA-Z0-9._-]/g, "");
if (safe !== meta.file) throw new Error("Invalid font asset name");
const safe = file.replace(/[^a-zA-Z0-9._-]/g, "");
if (safe !== file) throw new Error("Invalid font asset name");
return path.join(getFontsDir(), safe);
}
+14
View File
@@ -3,6 +3,15 @@
/** Max custom font upload size (10 MB). */
export const FONT_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
/** Bundled default font for preview + FFmpeg parity (Apache 2.0, Google Arimo). */
export const SYSTEM_FONT = {
key: "system",
label: "Arimo",
file: "Arimo-Regular.ttf",
boldFile: "Arimo-Bold.ttf",
previewFamily: "S2VIDPreview-system",
} as const;
export const CURATED_FONTS = [
{
key: "inter",
@@ -10,6 +19,7 @@ export const CURATED_FONTS = [
cssFamily: "Inter",
googleCss: "Inter:wght@400;600",
file: "Inter-Regular.ttf",
boldFile: null as string | null,
},
{
key: "montserrat",
@@ -17,6 +27,7 @@ export const CURATED_FONTS = [
cssFamily: "Montserrat",
googleCss: "Montserrat:wght@400;600",
file: "Montserrat-Regular.ttf",
boldFile: null as string | null,
},
{
key: "roboto",
@@ -24,6 +35,7 @@ export const CURATED_FONTS = [
cssFamily: "Roboto",
googleCss: "Roboto:wght@400;500",
file: "Roboto-Regular.ttf",
boldFile: null as string | null,
},
{
key: "oswald",
@@ -31,6 +43,7 @@ export const CURATED_FONTS = [
cssFamily: "Oswald",
googleCss: "Oswald:wght@400;500",
file: "Oswald-Regular.ttf",
boldFile: null as string | null,
},
{
key: "playfair",
@@ -38,6 +51,7 @@ export const CURATED_FONTS = [
cssFamily: "Playfair Display",
googleCss: "Playfair+Display:wght@400;600",
file: "PlayfairDisplay-Regular.ttf",
boldFile: null as string | null,
},
] as const;
+76 -13
View File
@@ -16,6 +16,7 @@ import { moveFile, writeUploadedFile } from "../fs-utils";
import {
ARTIST_MAX,
INVALID_LAYOUT_TEMPLATE_MESSAGE,
isLowerCornerTemplate,
normalizeLayoutSettings,
SONG_TITLE_MAX,
type LayoutSettings,
@@ -56,6 +57,11 @@ export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSetting
metadata.layout?.blur_amount ??
metadata.blurAmount ??
metadata.blur_amount,
blurFill:
metadata.layout?.blurFill ??
(metadata.layout as { blur_fill?: boolean } | null | undefined)?.blur_fill ??
metadata.blurFill ??
metadata.blur_fill,
blurOpacity:
metadata.layout?.blurOpacity ??
(metadata.layout as { blur_opacity?: number } | null | undefined)?.blur_opacity ??
@@ -81,6 +87,11 @@ export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSetting
(metadata.layout as { text_offset_y?: number } | null | undefined)?.text_offset_y ??
metadata.textOffsetY ??
metadata.text_offset_y,
titleBold:
metadata.layout?.titleBold ??
(metadata.layout as { title_bold?: boolean } | null | undefined)?.title_bold ??
metadata.titleBold ??
metadata.title_bold,
});
}
@@ -180,6 +191,18 @@ export async function validateJobPayload(user: { id: string }, body: CreateJobPa
}
}
const layoutForBg = resolveLayoutFromMetadata(item.metadata);
const bgRaw =
item.metadata.backgroundImagePath ?? item.metadata.background_image_path ?? null;
if (bgRaw && isLowerCornerTemplate(layoutForBg.template)) {
const bgPath = assertPathInUserUploads(user.id, bgRaw);
await fs.access(bgPath);
const st = await fs.stat(bgPath);
if (st.size > limits.maxFileSizeBytes) {
return `Background image for ${item.audioFilename} exceeds size limit`;
}
}
const wm = normalizeWatermarkSettings(
item.metadata.watermark,
item.metadata.includeWatermark,
@@ -242,28 +265,51 @@ export async function createVideoJob(
}
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
const items = body.items.map((item) => ({
...item,
audioPath: assertPathInUserUploads(user.id, item.audioPath),
itemImagePath: item.metadata.imagePath
? assertPathInUserUploads(user.id, item.metadata.imagePath)
: null,
watermarkLogoPath: item.metadata.watermark?.logoPath
? assertPathInUserUploads(user.id, item.metadata.watermark.logoPath)
: null,
watermarkFontPath:
item.metadata.watermark?.fontKey === "custom" && item.metadata.watermark?.fontPath
? assertPathInUserUploads(user.id, item.metadata.watermark.fontPath)
const items = body.items.map((item) => {
const layout = resolveLayoutFromMetadata(item.metadata);
const bgRaw =
item.metadata.backgroundImagePath ?? item.metadata.background_image_path ?? null;
const backgroundImagePath =
bgRaw && isLowerCornerTemplate(layout.template)
? assertPathInUserUploads(user.id, bgRaw)
: null;
return {
...item,
audioPath: assertPathInUserUploads(user.id, item.audioPath),
itemImagePath: item.metadata.imagePath
? assertPathInUserUploads(user.id, item.metadata.imagePath)
: null,
}));
backgroundImagePath,
watermarkLogoPath: item.metadata.watermark?.logoPath
? assertPathInUserUploads(user.id, item.metadata.watermark.logoPath)
: null,
watermarkFontPath:
item.metadata.watermark?.fontKey === "custom" && item.metadata.watermark?.fontPath
? assertPathInUserUploads(user.id, item.metadata.watermark.fontPath)
: null,
};
});
await reserveQuota(user.id, items.length);
const webhookRaw = body.webhookUrl?.trim() || null;
if (webhookRaw) {
try {
const u = new URL(webhookRaw);
if (u.protocol !== "https:" && u.protocol !== "http:") {
throw new Error("webhookUrl must be http(s)");
}
} catch {
throw new Error("Invalid webhookUrl. Provide an absolute http(s) URL for n8n callbacks.");
}
}
try {
const job = await prisma.job.create({
data: {
userId: user.id,
imagePath,
webhookUrl: webhookRaw,
items: {
create: items.map((item, index) => {
const wm = applyBrandWatermarkPolicy(
@@ -298,6 +344,7 @@ export async function createVideoJob(
creativeCommons: item.metadata.creativeCommons,
includeWatermark: wm.mode !== "none",
itemImagePath: item.itemImagePath,
backgroundImagePath: item.backgroundImagePath,
watermarkMode: wm.mode,
watermarkText: wm.mode === "text" ? wm.text?.trim() || null : null,
watermarkLogoPath: wm.mode === "logo" ? item.watermarkLogoPath : null,
@@ -309,10 +356,12 @@ export async function createVideoJob(
watermarkOffsetY: wm.offsetY,
artist: item.metadata.artist?.trim().slice(0, ARTIST_MAX) || null,
layoutTemplate: layout.template,
blurFill: layout.blurFill,
blurAmount: layout.blurAmount,
blurOpacity: layout.blurOpacity,
textPadding: layout.textPadding,
titleArtistGap: layout.titleArtistGap,
titleBold: layout.titleBold,
textOffsetX: layout.textOffsetX,
textOffsetY: layout.textOffsetY,
playlistId: item.metadata.playlistId?.trim() || null,
@@ -353,6 +402,19 @@ export async function createVideoJob(
}
}
let newBackgroundImage: string | null = null;
if (item.backgroundImagePath) {
const src = item.backgroundImagePath;
if (moved.has(src)) {
newBackgroundImage = moved.get(src)!;
} else {
const ext = path.extname(src);
newBackgroundImage = path.join(jobDir, `${item.id}-bg${ext}`);
await moveFile(src, newBackgroundImage);
moved.set(src, newBackgroundImage);
}
}
let newLogo: string | null = null;
if (item.watermarkLogoPath) {
const src = item.watermarkLogoPath;
@@ -383,6 +445,7 @@ export async function createVideoJob(
data: {
audioPath: newAudioPath,
itemImagePath: newItemImage,
backgroundImagePath: newBackgroundImage,
watermarkLogoPath: newLogo,
watermarkFontPath: newFont,
},
+246 -11
View File
@@ -8,6 +8,8 @@ export const LAYOUT_TEMPLATES = [
"COVER_TOP_TEXT_BOTTOM",
"COVER_RIGHT_TEXT_LEFT",
"CENTERED_COMPACT",
"LOWER_LEFT_COVER_TEXT",
"LOWER_RIGHT_COVER_TEXT",
] as const;
export type LayoutTemplate = (typeof LAYOUT_TEMPLATES)[number];
@@ -37,12 +39,31 @@ export const TEXT_OFFSET_MIN = -120;
export const TEXT_OFFSET_MAX = 120;
export const TEXT_OFFSET_DEFAULT = 0;
/** Lower-corner cover size as a fraction of encode frame width. */
export const LOWER_CORNER_COVER_WIDTH_FRACTION = 0.23;
export function lowerCornerCoverSide(
width: number,
height: number,
textPadding: number,
): number {
const edge = clampTextPadding(textPadding);
return Math.round(
Math.min(width * LOWER_CORNER_COVER_WIDTH_FRACTION, height - edge * 2),
);
}
export const ARTIST_MAX = 80;
export const SONG_TITLE_MAX = 120;
export type LayoutSettings = {
/** When null, classic letterbox (no art-track layout / blur fill). */
/** When null, classic letterbox (no art-track layout). */
template: LayoutTemplate | null;
/**
* Classic letterbox only: fill letterbox bars with a blurred cover.
* Ignored when `template` is set (art-track always uses a blur fill).
*/
blurFill: boolean;
/** 0100 → FFmpeg boxblur intensity. */
blurAmount: number;
/** 0100 → how visible the blurred fill is (vs black). */
@@ -55,16 +76,20 @@ export type LayoutSettings = {
textOffsetX: number;
/** Shift text block vertically within the template. */
textOffsetY: number;
/** Bold weight for the on-video song title (artist stays regular). */
titleBold: boolean;
};
export const DEFAULT_LAYOUT: LayoutSettings = {
template: null,
blurFill: false,
blurAmount: BLUR_AMOUNT_DEFAULT,
blurOpacity: BLUR_OPACITY_DEFAULT,
textPadding: TEXT_PADDING_DEFAULT,
titleArtistGap: TITLE_ARTIST_GAP_DEFAULT,
textOffsetX: TEXT_OFFSET_DEFAULT,
textOffsetY: TEXT_OFFSET_DEFAULT,
titleBold: true,
};
export function isLayoutTemplate(v: unknown): v is LayoutTemplate {
@@ -119,6 +144,8 @@ type RawLayoutInput = {
template?: unknown;
layoutTemplate?: unknown;
layout_template?: unknown;
blurFill?: unknown;
blur_fill?: unknown;
blurAmount?: unknown;
blur_amount?: unknown;
blurOpacity?: unknown;
@@ -131,6 +158,8 @@ type RawLayoutInput = {
text_offset_x?: unknown;
textOffsetY?: unknown;
text_offset_y?: unknown;
titleBold?: unknown;
title_bold?: unknown;
/** Rejected clients must not send free-form cover coordinates. */
x?: unknown;
y?: unknown;
@@ -189,15 +218,25 @@ export function normalizeLayoutSettings(
(input as RawLayoutInput).textOffsetY ??
(input as RawLayoutInput).text_offset_y ??
(input as LayoutSettings).textOffsetY;
const boldRaw =
(input as RawLayoutInput).titleBold ??
(input as RawLayoutInput).title_bold ??
(input as LayoutSettings).titleBold;
const blurFillRaw =
(input as RawLayoutInput).blurFill ??
(input as RawLayoutInput).blur_fill ??
(input as LayoutSettings).blurFill;
return {
template,
blurFill: template !== null ? false : Boolean(blurFillRaw),
blurAmount: clampBlurAmount(blurRaw, BLUR_AMOUNT_DEFAULT),
blurOpacity: clampBlurOpacity(opacityRaw, BLUR_OPACITY_DEFAULT),
textPadding: clampTextPadding(padRaw, TEXT_PADDING_DEFAULT),
titleArtistGap: clampTitleArtistGap(gapRaw, TITLE_ARTIST_GAP_DEFAULT),
textOffsetX: clampTextOffset(oxRaw, TEXT_OFFSET_DEFAULT),
textOffsetY: clampTextOffset(oyRaw, TEXT_OFFSET_DEFAULT),
titleBold: boldRaw === undefined || boldRaw === null ? true : Boolean(boldRaw),
};
}
@@ -342,6 +381,54 @@ export function computeLayoutGeometry(
};
break;
}
case "LOWER_LEFT_COVER_TEXT": {
// Equal left + bottom inset so the cover corner sits on a true diagonal from (0, H).
const edge = pad;
const side = lowerCornerCoverSide(width, height, pad);
const coverTextGap = Math.max(10, Math.round(pad * 0.55));
const textX = edge + side + coverTextGap;
const coverTop = height - edge - side;
const textBlockH = titleFontSize + lineGap + artistFontSize;
const textMidY = coverTop + Math.round(side / 2);
const titleY = Math.max(edge, textMidY - Math.round(textBlockH / 2));
base = {
coverMaxW: side,
coverMaxH: side,
coverX: String(edge),
coverY: `H-h-${edge}`,
titleFontSize,
artistFontSize,
titleX: String(textX),
titleY: String(titleY),
artistX: String(textX),
artistY: String(titleY + titleFontSize + lineGap),
};
break;
}
case "LOWER_RIGHT_COVER_TEXT": {
// Equal right + bottom inset (mirror of lower-left diagonal).
const edge = pad;
const side = lowerCornerCoverSide(width, height, pad);
const coverTextGap = Math.max(10, Math.round(pad * 0.55));
const coverTop = height - edge - side;
const textBlockH = titleFontSize + lineGap + artistFontSize;
const textMidY = coverTop + Math.round(side / 2);
const titleY = Math.max(edge, textMidY - Math.round(textBlockH / 2));
const textInset = edge + side + coverTextGap;
base = {
coverMaxW: side,
coverMaxH: side,
coverX: `W-w-${edge}`,
coverY: `H-h-${edge}`,
titleFontSize,
artistFontSize,
titleX: `W-text_w-${textInset}`,
titleY: String(titleY),
artistX: `W-text_w-${textInset}`,
artistY: String(titleY + titleFontSize + lineGap),
};
break;
}
default: {
throw new Error(INVALID_LAYOUT_TEMPLATE_MESSAGE);
}
@@ -350,13 +437,27 @@ export function computeLayoutGeometry(
return applyTextOffsets(base, textOffsetX, textOffsetY);
}
export function isLowerCornerTemplate(
template: LayoutTemplate | null | undefined,
): boolean {
return template === "LOWER_LEFT_COVER_TEXT" || template === "LOWER_RIGHT_COVER_TEXT";
}
export function buildArtTrackFilterComplex(opts: {
width: number;
height: number;
layout: LayoutSettings;
titleEscaped: string;
artistEscaped: string | null;
/** Regular-weight font for artist (and title when not bold / no bold file). */
fontfileEscaped?: string | null;
/** Bold font for title when `layout.titleBold` and a bold face is available. */
titleFontfileEscaped?: string | null;
/**
* When set, use this FFmpeg input index as the blur-fill source instead of
* splitting the cover (`[0:v]`). Cover stays on input 0 as the sharp overlay.
*/
separateBackgroundInputIndex?: number | null;
}): string {
const { width: W, height: H, layout } = opts;
if (!layout.template) {
@@ -378,15 +479,84 @@ export function buildArtTrackFilterComplex(opts: {
: `scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H}`;
const opacity = clampBlurOpacity(layout.blurOpacity, BLUR_OPACITY_DEFAULT) / 100;
const fontPart = opts.fontfileEscaped ? `:fontfile='${opts.fontfileEscaped}'` : "";
const titleDraw = `drawtext=text='${opts.titleEscaped}'${fontPart}:fontsize=${geo.titleFontSize}:fontcolor=white@0.95:x=${geo.titleX}:y=${geo.titleY}`;
const artistFontPart = opts.fontfileEscaped ? `:fontfile='${opts.fontfileEscaped}'` : "";
const titleUsesBoldFace = Boolean(layout.titleBold && opts.titleFontfileEscaped);
const titleFontPart = titleUsesBoldFace
? `:fontfile='${opts.titleFontfileEscaped}'`
: artistFontPart;
// Faux-bold stroke when bold is requested but no bold TTF is available (custom/curated).
const titleFauxBold =
layout.titleBold && !titleUsesBoldFace ? `:borderw=1:bordercolor=white@0.95` : "";
const titleDraw = `drawtext=text='${opts.titleEscaped}'${titleFontPart}${titleFauxBold}:fontsize=${geo.titleFontSize}:fontcolor=white@0.95:x=${geo.titleX}:y=${geo.titleY}`;
const artistDraw = opts.artistEscaped
? `,drawtext=text='${opts.artistEscaped}'${fontPart}:fontsize=${geo.artistFontSize}:fontcolor=white@0.75:x=${geo.artistX}:y=${geo.artistY}`
? `,drawtext=text='${opts.artistEscaped}'${artistFontPart}:fontsize=${geo.artistFontSize}:fontcolor=white@0.75:x=${geo.artistX}:y=${geo.artistY}`
: "";
const parts: string[] = [`[0:v]split=2[bg][fg]`];
const separateBgIdx =
typeof opts.separateBackgroundInputIndex === "number" &&
Number.isFinite(opts.separateBackgroundInputIndex) &&
opts.separateBackgroundInputIndex >= 0
? opts.separateBackgroundInputIndex
: null;
const parts: string[] = [];
const bgSrc = separateBgIdx !== null ? `[${separateBgIdx}:v]` : "[bg]";
if (separateBgIdx !== null) {
parts.push(
`[0:v]scale=${geo.coverMaxW}:${geo.coverMaxH}:force_original_aspect_ratio=decrease[cover]`,
);
} else {
parts.push(`[0:v]split=2[bg][fg]`);
parts.push(
`[fg]scale=${geo.coverMaxW}:${geo.coverMaxH}:force_original_aspect_ratio=decrease[cover]`,
);
}
// Fade blurred fill toward black when opacity < 100%
if (opacity >= 0.999) {
parts.push(`${bgSrc}${bgChain}[blurred]`);
} else if (opacity <= 0.001) {
parts.push(`color=c=black:s=${W}x${H}:d=1[blurred]`);
} else {
const a = opacity.toFixed(3);
const b = (1 - opacity).toFixed(3);
parts.push(
`${bgSrc}${bgChain}[blur_raw]`,
`color=c=black:s=${W}x${H}:d=1[blk]`,
`[blur_raw][blk]blend=all_expr='A*${a}+B*${b}':shortest=1[blurred]`,
);
}
parts.push(
`[blurred][cover]overlay=${geo.coverX}:${geo.coverY}[composed]`,
`[composed]${titleDraw}${artistDraw}[laid]`,
);
return parts.join(";");
}
/**
* Classic letterbox with blurred cover fill (no on-video title/artist).
* Ends at `[laid]` — same convention as art-track for encode relabeling.
*/
export function buildClassicBlurFillFilterComplex(opts: {
width: number;
height: number;
blurAmount: number;
blurOpacity: number;
}): string {
const { width: W, height: H } = opts;
const blurSeg = boxblurFilterSegment(opts.blurAmount);
const bgChain = blurSeg
? `scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H},${blurSeg}`
: `scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H}`;
const opacity = clampBlurOpacity(opts.blurOpacity, BLUR_OPACITY_DEFAULT) / 100;
const parts: string[] = [
`[0:v]split=2[bg][fg]`,
`[fg]scale=${W}:${H}:force_original_aspect_ratio=decrease[cover]`,
];
if (opacity >= 0.999) {
parts.push(`[bg]${bgChain}[blurred]`);
} else if (opacity <= 0.001) {
@@ -401,12 +571,7 @@ export function buildArtTrackFilterComplex(opts: {
);
}
parts.push(
`[fg]scale=${geo.coverMaxW}:${geo.coverMaxH}:force_original_aspect_ratio=decrease[cover]`,
`[blurred][cover]overlay=${geo.coverX}:${geo.coverY}[composed]`,
`[composed]${titleDraw}${artistDraw}[laid]`,
);
parts.push(`[blurred][cover]overlay=(W-w)/2:(H-h)/2[laid]`);
return parts.join(";");
}
@@ -415,4 +580,74 @@ export const LAYOUT_TEMPLATE_LABELS: Record<LayoutTemplate, string> = {
COVER_TOP_TEXT_BOTTOM: "Cover top · text bottom",
COVER_RIGHT_TEXT_LEFT: "Cover right · text left",
CENTERED_COMPACT: "Centered compact",
LOWER_LEFT_COVER_TEXT: "Lower left · cover + text",
LOWER_RIGHT_COVER_TEXT: "Lower right · cover + text",
};
/** Composition picker families — mirrored pairs share one tile + variant toggles. */
export type CompositionFamilyId =
| "classic"
| "side"
| "top"
| "centered"
| "lower";
export type CompositionFamily = {
id: CompositionFamilyId;
/** Tile label in the composition grid. */
label: string;
/** Which thumb art to draw (null = classic letterbox). */
thumb: LayoutTemplate | null;
/** Templates in this family; length > 1 shows variant toggles when selected. */
variants: LayoutTemplate[];
/** Default when first selecting the family. */
defaultTemplate: LayoutTemplate | null;
};
export const COMPOSITION_FAMILIES: readonly CompositionFamily[] = [
{
id: "classic",
label: "Classic letterbox",
thumb: null,
variants: [],
defaultTemplate: null,
},
{
id: "side",
label: "Cover beside text",
thumb: "COVER_LEFT_TEXT_RIGHT",
variants: ["COVER_LEFT_TEXT_RIGHT", "COVER_RIGHT_TEXT_LEFT"],
defaultTemplate: "COVER_LEFT_TEXT_RIGHT",
},
{
id: "top",
label: "Cover top · text bottom",
thumb: "COVER_TOP_TEXT_BOTTOM",
variants: ["COVER_TOP_TEXT_BOTTOM"],
defaultTemplate: "COVER_TOP_TEXT_BOTTOM",
},
{
id: "centered",
label: "Centered compact",
thumb: "CENTERED_COMPACT",
variants: ["CENTERED_COMPACT"],
defaultTemplate: "CENTERED_COMPACT",
},
{
id: "lower",
label: "Lower corner · cover + text",
thumb: "LOWER_LEFT_COVER_TEXT",
variants: ["LOWER_LEFT_COVER_TEXT", "LOWER_RIGHT_COVER_TEXT"],
defaultTemplate: "LOWER_LEFT_COVER_TEXT",
},
] as const;
export function compositionFamilyForTemplate(
template: LayoutTemplate | null,
): CompositionFamily {
if (template === null) return COMPOSITION_FAMILIES[0]!;
const found = COMPOSITION_FAMILIES.find((f) =>
f.variants.includes(template),
);
return found ?? COMPOSITION_FAMILIES[0]!;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { SUPPORT_EMAIL } from "@/lib/plans";
import { BRAND_DOMAIN, BRAND_NAME } from "@/lib/branding";
export const LEGAL_LAST_UPDATED = "July 30, 2026";
export const LEGAL_LAST_UPDATED = "August 3, 2026";
export const LEGAL_OPERATOR = {
name: BRAND_NAME,
+4 -4
View File
@@ -4,7 +4,7 @@
*/
import type { WatermarkFontKey } from "./fonts";
import { CURATED_FONTS } from "./fonts";
import { CURATED_FONTS, SYSTEM_FONT } from "./fonts";
/** Matches LayoutStudio max preview width (tailwind max-w-xl ≈ 576px; use 576 for scaling). */
export const PREVIEW_REFERENCE_WIDTH = 576;
@@ -29,13 +29,13 @@ export function scaleFontToPreview(fontPx: number, encodeWidth: number): number
export function previewFontFamilyCss(fontKey: WatermarkFontKey | undefined): string {
if (!fontKey || fontKey === "system") {
return "Arial, Helvetica, sans-serif";
return `'${SYSTEM_FONT.previewFamily}', sans-serif`;
}
if (fontKey === "custom") {
return "'S2VIDCustomWm', Arial, sans-serif";
return "'S2VIDCustomWm', sans-serif";
}
const meta = CURATED_FONTS.find((f) => f.key === fontKey);
return meta ? `'S2VIDPreview-${meta.key}', Arial, sans-serif` : "Arial, sans-serif";
return meta ? `'S2VIDPreview-${meta.key}', sans-serif` : "sans-serif";
}
export function curatedFontApiUrl(key: string): string {
+17
View File
@@ -22,6 +22,12 @@ export type ItemMetadata = {
* When omitted, the job-level shared imagePath is used.
*/
imagePath?: string | null;
/**
* Optional full-frame background for LOWER_LEFT / LOWER_RIGHT templates.
* Blur/opacity apply to this image; cover stays the sharp corner square.
*/
backgroundImagePath?: string | null;
background_image_path?: string | null;
/** Custom branding watermark settings. */
watermark?: Partial<WatermarkSettings> | null;
/** Artist line for art-track layouts (on-video only). */
@@ -31,20 +37,27 @@ export type ItemMetadata = {
/**
* Art-track layout. Prefer camelCase; snake_case aliases accepted:
* layout_template, blur_amount, text_padding.
* `template`: COVER_LEFT_TEXT_RIGHT | COVER_TOP_TEXT_BOTTOM |
* COVER_RIGHT_TEXT_LEFT | CENTERED_COMPACT | LOWER_LEFT_COVER_TEXT |
* LOWER_RIGHT_COVER_TEXT (also listed on GET /api/v1 → layoutTemplates).
*/
layout?: Partial<LayoutSettings> & {
layoutTemplate?: string | null;
layout_template?: string | null;
blur_fill?: boolean;
blur_amount?: number;
blur_opacity?: number;
text_padding?: number;
title_artist_gap?: number;
text_offset_x?: number;
text_offset_y?: number;
title_bold?: boolean;
} | null;
/** Flat aliases (also accepted). */
layoutTemplate?: string | null;
layout_template?: string | null;
blurFill?: boolean;
blur_fill?: boolean;
blurAmount?: number;
blur_amount?: number;
blurOpacity?: number;
@@ -57,6 +70,8 @@ export type ItemMetadata = {
text_offset_x?: number;
textOffsetY?: number;
text_offset_y?: number;
titleBold?: boolean;
title_bold?: boolean;
};
export type UploadedAudio = {
@@ -79,6 +94,8 @@ export type CreateJobPayload = {
audioFilename: string;
metadata: ItemMetadata;
}>;
/** Absolute http(s) URL — Songs2VID POSTs JSON on item/job terminal states (n8n). */
webhookUrl?: string | null;
/** Create a new playlist and attach its ID to every item (Pro). */
createPlaylist?: CreatePlaylistRequest | null;
};
+52
View File
@@ -0,0 +1,52 @@
/**
* Deliver optional job webhooks for n8n / automation consumers.
* Fire-and-forget; failures are logged and never fail the encode/upload path.
*/
type WebhookPayload = {
event: "job.item.completed" | "job.item.failed" | "job.completed" | "job.failed" | "job.partial";
jobId: string;
status: string;
itemId?: string;
youtubeVideoId?: string | null;
error?: string | null;
completedAt?: string | null;
};
export async function deliverJobWebhook(
webhookUrl: string | null | undefined,
payload: WebhookPayload,
): Promise<void> {
if (!webhookUrl?.trim()) return;
let url: URL;
try {
url = new URL(webhookUrl.trim());
} catch {
console.warn(`[webhook] invalid URL for job ${payload.jobId}`);
return;
}
if (url.protocol !== "https:" && url.protocol !== "http:") {
console.warn(`[webhook] refused non-http(s) URL for job ${payload.jobId}`);
return;
}
try {
const res = await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "Songs2VID-Webhook/1.0",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(8_000),
});
if (!res.ok) {
console.warn(`[webhook] ${payload.jobId}${res.status}`);
}
} catch (err) {
console.warn(
`[webhook] delivery failed for ${payload.jobId}:`,
err instanceof Error ? err.message : err,
);
}
}
+3 -2
View File
@@ -39,10 +39,11 @@ 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 Songs2VID plan quota. " +
"it may upload right now. This is YouTube's own daily limit, not a Songs2VID usage cap " +
"(self-hosted OSS does not enforce a video quota paywall). " +
"Try again later (often after 24 hours) or use a different YouTube channel.";
/** User-facing message for the dashboard (distinct from Songs2VID plan quota). */
/** User-facing message for the dashboard (distinct from YouTube's own caps). */
export function formatYouTubeErrorForUser(err: unknown): string {
const raw = extractYouTubeErrorMessage(err);
if (isYouTubeUploadLimitError(raw)) return YOUTUBE_UPLOAD_LIMIT_USER_MESSAGE;
+1
View File
@@ -3,6 +3,7 @@
"description": "Payment-free self-hosted audio-to-video app with YouTube upload",
"version": "0.1.0",
"private": true,
"license": "MIT",
"scripts": {
"dev": "next dev",
"dev:all": "concurrently -n web,worker -c blue,green \"npm run dev\" \"npm run worker\"",
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "JobItem" ADD COLUMN "backgroundImagePath" TEXT;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "JobItem" ADD COLUMN "blurFill" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Job" ADD COLUMN IF NOT EXISTS "webhookUrl" TEXT;
+7
View File
@@ -59,6 +59,8 @@ model Job {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
status JobStatus @default(PENDING)
imagePath String
/// Optional absolute http(s) URL for n8n / automation callbacks
webhookUrl String?
items JobItem[]
createdAt DateTime @default(now())
completedAt DateTime?
@@ -82,6 +84,8 @@ model JobItem {
creativeCommons Boolean @default(false)
includeWatermark Boolean @default(false)
itemImagePath String?
/// Optional full-frame background for lower-corner layouts (blur fill). Cover stays foreground.
backgroundImagePath String?
watermarkMode String @default("none")
watermarkText String?
watermarkLogoPath String?
@@ -93,10 +97,13 @@ model JobItem {
artist String?
songTitle String?
layoutTemplate String?
/// Classic letterbox: fill black bars with blurred cover
blurFill Boolean @default(false)
blurAmount Int @default(55)
blurOpacity Int @default(100)
textPadding Int @default(48)
titleArtistGap Int @default(10)
titleBold Boolean @default(true)
textOffsetX Int @default(0)
textOffsetY Int @default(0)
playlistId String?
+27 -4
View File
@@ -9,8 +9,17 @@ import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outDir = path.join(__dirname, "..", "assets", "fonts");
/** Direct TTF URLs from Google Fonts GitHub (OFL). */
/** Direct TTF URLs from Google Fonts GitHub (OFL / Apache 2.0). */
const FONTS = [
{
file: "Arimo-Regular.ttf",
// Static TTF from googlefonts/arimo (not the broken google/fonts apache/static path)
url: "https://cdn.jsdelivr.net/gh/googlefonts/arimo@main/fonts/ttf/Arimo-Regular.ttf",
},
{
file: "Arimo-Bold.ttf",
url: "https://cdn.jsdelivr.net/gh/googlefonts/arimo@main/fonts/ttf/Arimo-Bold.ttf",
},
{
file: "Inter-Regular.ttf",
url: "https://github.com/google/fonts/raw/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf",
@@ -35,12 +44,22 @@ const FONTS = [
await fs.mkdir(outDir, { recursive: true });
function looksLikeFont(buf) {
if (buf.length < 4) return false;
const asStr = buf.subarray(0, 4).toString("ascii");
if (asStr === "OTTO" || asStr === "true" || asStr === "typ1") return true;
return buf[0] === 0x00 && buf[1] === 0x01 && buf[2] === 0x00 && buf[3] === 0x00;
}
for (const font of FONTS) {
const dest = path.join(outDir, font.file);
try {
await fs.access(dest);
console.log("skip (exists)", font.file);
continue;
const existing = await fs.readFile(dest);
if (looksLikeFont(existing)) {
console.log("skip (exists)", font.file);
continue;
}
console.warn("corrupt font, re-fetching", font.file);
} catch {
/* download */
}
@@ -54,6 +73,10 @@ for (const font of FONTS) {
continue;
}
const buf = Buffer.from(await res.arrayBuffer());
if (!looksLikeFont(buf)) {
console.error("FAILED invalid font bytes", font.file, buf.subarray(0, 8).toString("hex"));
continue;
}
await fs.writeFile(dest, buf);
console.log("wrote", font.file, buf.length, "bytes");
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Build assets/watermark.png for the default Songs2VID badge overlay.
* Uses bundled Arimo + FFmpeg (same toolchain as video encode).
*
* Run: node scripts/generate-watermark-png.mjs
*/
import { spawnSync } from "child_process";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import ffmpegStatic from "ffmpeg-static";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(__dirname, "..");
const fontPath = path.join(root, "assets", "fonts", "Arimo-Regular.ttf");
const outPath = path.join(root, "assets", "watermark.png");
function ffmpegPath() {
return process.env.FFMPEG_PATH || ffmpegStatic || "ffmpeg";
}
function escFont(p) {
return p.replace(/\\/g, "/").replace(/:/g, "\\:");
}
if (!fs.existsSync(fontPath)) {
console.error("Missing font:", fontPath, "— run node scripts/fetch-watermark-fonts.mjs first");
process.exit(1);
}
const font = escFont(fontPath);
const w = 960;
const h = 88;
const filter = [
`[0:v]format=rgba,drawbox=x=0:y=0:w=iw:h=ih:color=0x000000@0.45:t=fill`,
`drawtext=fontfile='${font}':text='Uploaded through Songs2VID.com':fontsize=32:fontcolor=white@0.92:x=(w-text_w)/2:y=(h-th)/2`,
].join(",");
const args = [
"-y",
"-f",
"lavfi",
"-i",
`color=c=black@0.0:s=${w}x${h}`,
"-vf",
filter,
"-update",
"1",
outPath,
];
const res = spawnSync(ffmpegPath(), args, { stdio: "inherit" });
if (res.status !== 0) {
process.exit(res.status ?? 1);
}
console.log("wrote", outPath, fs.statSync(outPath).size, "bytes");
+106
View File
@@ -5,6 +5,7 @@ import {
blurToBoxblur,
boxblurFilterSegment,
buildArtTrackFilterComplex,
buildClassicBlurFillFilterComplex,
clampBlurAmount,
clampTextPadding,
clampTitleArtistGap,
@@ -32,8 +33,22 @@ function testEnumsAndClamp() {
function testNormalize() {
const classic = normalizeLayoutSettings(null);
assert.equal(classic.template, null);
assert.equal(classic.blurFill, false);
assert.equal(classic.titleArtistGap, 10);
assert.equal(classic.textOffsetX, 0);
assert.equal(classic.titleBold, true);
assert.equal(
normalizeLayoutSettings({ blur_fill: true }).blurFill,
true,
);
assert.equal(
normalizeLayoutSettings({
template: "CENTERED_COMPACT",
blurFill: true,
}).blurFill,
false,
);
assert.equal(
normalizeLayoutSettings({
@@ -75,26 +90,117 @@ function testGeometryAndFilter() {
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 lowerLeft = computeLayoutGeometry("LOWER_LEFT_COVER_TEXT", 1280, 720, 48, 10, 0, 0);
assert.equal(lowerLeft.coverX, "48");
assert.equal(lowerLeft.coverY, "H-h-48");
assert.ok(Number(lowerLeft.titleX) > Number(lowerLeft.coverX));
assert.ok(Number(lowerLeft.titleY) > 300, "title should sit in the lower half with the cover");
const lowerRight = computeLayoutGeometry("LOWER_RIGHT_COVER_TEXT", 1280, 720, 48, 10, 0, 0);
assert.equal(lowerRight.coverX, "W-w-48");
assert.equal(lowerRight.coverY, "H-h-48");
assert.ok(String(lowerRight.titleX).startsWith("W-text_w-"));
const fc = buildArtTrackFilterComplex({
width: 1280,
height: 720,
layout: {
template: "CENTERED_COMPACT",
blurFill: false,
blurAmount: 40,
blurOpacity: 70,
textPadding: 40,
titleArtistGap: 18,
textOffsetX: 5,
textOffsetY: -8,
titleBold: true,
},
titleEscaped: sanitizeDrawtext("Hello:World"),
artistEscaped: sanitizeDrawtext("Artist"),
fontfileEscaped: null,
titleFontfileEscaped: "C\\:/fonts/Arimo-Bold.ttf",
});
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.includes("fontfile='C\\:/fonts/Arimo-Bold.ttf'"));
assert.ok(fc.endsWith("[laid]"));
const regular = buildArtTrackFilterComplex({
width: 1280,
height: 720,
layout: {
template: "CENTERED_COMPACT",
blurFill: false,
blurAmount: 40,
blurOpacity: 70,
textPadding: 40,
titleArtistGap: 18,
textOffsetX: 0,
textOffsetY: 0,
titleBold: false,
},
titleEscaped: "Hi",
artistEscaped: null,
fontfileEscaped: "C\\:/fonts/Arimo-Regular.ttf",
});
assert.ok(regular.includes("fontfile='C\\:/fonts/Arimo-Regular.ttf'"));
assert.equal(regular.includes("borderw="), false);
const withSeparateBg = buildArtTrackFilterComplex({
width: 1280,
height: 720,
layout: {
template: "LOWER_LEFT_COVER_TEXT",
blurFill: false,
blurAmount: 40,
blurOpacity: 100,
textPadding: 48,
titleArtistGap: 10,
textOffsetX: 0,
textOffsetY: 0,
titleBold: true,
},
titleEscaped: "Song",
artistEscaped: "Artist",
separateBackgroundInputIndex: 2,
});
assert.equal(withSeparateBg.includes("[0:v]split=2"), false);
assert.ok(withSeparateBg.includes("[2:v]"));
assert.ok(withSeparateBg.includes("[0:v]scale="));
assert.ok(withSeparateBg.includes("overlay="));
const withoutSeparateBg = buildArtTrackFilterComplex({
width: 1280,
height: 720,
layout: {
template: "LOWER_LEFT_COVER_TEXT",
blurFill: false,
blurAmount: 40,
blurOpacity: 100,
textPadding: 48,
titleArtistGap: 10,
textOffsetX: 0,
textOffsetY: 0,
titleBold: true,
},
titleEscaped: "Song",
artistEscaped: null,
});
assert.ok(withoutSeparateBg.includes("[0:v]split=2"));
const classicBlur = buildClassicBlurFillFilterComplex({
width: 1280,
height: 720,
blurAmount: 55,
blurOpacity: 100,
});
assert.ok(classicBlur.includes("[0:v]split=2"));
assert.ok(classicBlur.includes("overlay=(W-w)/2:(H-h)/2"));
assert.equal(classicBlur.includes("pad="), false);
assert.equal(classicBlur.includes("drawtext="), false);
assert.ok(classicBlur.endsWith("[laid]"));
}
testEnumsAndClamp();
+1 -1
View File
@@ -19,5 +19,5 @@
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "website"]
"exclude": ["node_modules", "website", "integrations"]
}
+47 -7
View File
@@ -9,8 +9,13 @@ import { getJobDir } from "../lib/storage";
import type { VideoJobData } from "../lib/types";
import { formatYouTubeErrorForUser } from "../lib/youtube/errors";
import { uploadToYouTube } from "../lib/youtube/upload";
import { deliverJobWebhook } from "../lib/webhooks";
async function updateJobStatus(jobId: string) {
const job = await prisma.job.findUniqueOrThrow({
where: { id: jobId },
select: { webhookUrl: true },
});
const items = await prisma.jobItem.findMany({ where: { jobId } });
const completed = items.filter((i) => i.status === JobItemStatus.COMPLETED).length;
const failed = items.filter((i) => i.status === JobItemStatus.FAILED).length;
@@ -23,13 +28,29 @@ async function updateJobStatus(jobId: string) {
else status = JobStatus.PARTIAL;
}
const completedAt = completed + failed === total ? new Date() : null;
await prisma.job.update({
where: { id: jobId },
data: {
status,
completedAt: completed + failed === total ? new Date() : null,
completedAt,
},
});
if (completedAt) {
const event =
status === JobStatus.COMPLETED
? "job.completed"
: status === JobStatus.FAILED
? "job.failed"
: "job.partial";
await deliverJobWebhook(job.webhookUrl, {
event,
jobId,
status,
completedAt: completedAt.toISOString(),
});
}
}
async function processJobItem(data: VideoJobData) {
@@ -70,21 +91,25 @@ async function processJobItem(data: VideoJobData) {
includeWatermark: item.includeWatermark,
songTitle: item.songTitle || item.title,
artist: item.artist,
layout: item.layoutTemplate
? {
template: item.layoutTemplate as
layout: {
template: (item.layoutTemplate as
| "COVER_LEFT_TEXT_RIGHT"
| "COVER_TOP_TEXT_BOTTOM"
| "COVER_RIGHT_TEXT_LEFT"
| "CENTERED_COMPACT",
| "CENTERED_COMPACT"
| "LOWER_LEFT_COVER_TEXT"
| "LOWER_RIGHT_COVER_TEXT"
| null) ?? null,
blurFill: item.blurFill ?? false,
blurAmount: item.blurAmount ?? 55,
blurOpacity: item.blurOpacity ?? 100,
textPadding: item.textPadding ?? 48,
titleArtistGap: item.titleArtistGap ?? 10,
titleBold: item.titleBold ?? true,
textOffsetX: item.textOffsetX ?? 0,
textOffsetY: item.textOffsetY ?? 0,
}
: null,
},
backgroundImagePath: item.backgroundImagePath,
watermark: {
mode: (item.watermarkMode as "none" | "default" | "text" | "logo") || "default",
text: item.watermarkText,
@@ -132,6 +157,14 @@ async function processJobItem(data: VideoJobData) {
data: { createdVideoCount: { increment: 1 } },
});
await deliverJobWebhook(item.job.webhookUrl, {
event: "job.item.completed",
jobId: data.jobId,
status: "COMPLETED",
itemId: item.id,
youtubeVideoId,
});
await cleanupFiles([outputPath]);
} catch (err) {
const message = formatYouTubeErrorForUser(err);
@@ -140,6 +173,13 @@ async function processJobItem(data: VideoJobData) {
where: { id: item.id },
data: { status: JobItemStatus.FAILED, error: message },
});
await deliverJobWebhook(item.job.webhookUrl, {
event: "job.item.failed",
jobId: data.jobId,
status: "FAILED",
itemId: item.id,
error: message,
});
throw new Error(message);
} finally {
await updateJobStatus(data.jobId);