Compare commits
2
Commits
main
..
925aadae75
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
925aadae75 | ||
|
|
935abfb22e |
@@ -10,4 +10,3 @@ uploads
|
|||||||
deploy-*.tgz
|
deploy-*.tgz
|
||||||
scripts
|
scripts
|
||||||
bg-video
|
bg-video
|
||||||
integrations
|
|
||||||
|
|||||||
+6
-10
@@ -1,23 +1,19 @@
|
|||||||
# Songs2VID OSS is always self-hosted and payment-free.
|
# Songs2VID OSS is always self-hosted and has no billing configuration.
|
||||||
# Do not set Stripe, billing, credits, pricing, or S2VID_EDITION variables — they are unused.
|
|
||||||
|
|
||||||
# --- Required ---
|
|
||||||
DATABASE_URL="postgresql://songs2vid:songs2vid@localhost:5433/songs2vid"
|
DATABASE_URL="postgresql://songs2vid:songs2vid@localhost:5433/songs2vid"
|
||||||
REDIS_URL="redis://localhost:6380"
|
REDIS_URL="redis://localhost:6380"
|
||||||
NEXTAUTH_URL="http://localhost:3000"
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
NEXTAUTH_SECRET="replace-with-a-long-random-secret"
|
NEXTAUTH_SECRET="replace-with-a-long-random-secret"
|
||||||
|
# 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_ID="your-google-client-id.apps.googleusercontent.com"
|
||||||
GOOGLE_CLIENT_SECRET="your-google-client-secret"
|
GOOGLE_CLIENT_SECRET="your-google-client-secret"
|
||||||
|
|
||||||
# --- Optional ---
|
|
||||||
# TOKEN_ENCRYPTION_KEY="replace-with-another-long-random-secret" # falls back to NEXTAUTH_SECRET
|
|
||||||
UPLOAD_DIR="./uploads"
|
UPLOAD_DIR="./uploads"
|
||||||
# FFMPEG_PATH="C:/path/to/ffmpeg.exe" # override bundled / image ffmpeg
|
|
||||||
# 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_DOCS_URL="https://docs.songs2vid.com"
|
||||||
NEXT_PUBLIC_GITEA_ISSUES_URL="https://git.atakanozban.com/Songs2VID/songs2vid/issues"
|
NEXT_PUBLIC_GITEA_ISSUES_URL="https://git.atakanozban.com/Songs2VID/songs2vid/issues"
|
||||||
NEXT_PUBLIC_GITEA_URL="https://git.atakanozban.com/Songs2VID"
|
NEXT_PUBLIC_GITEA_URL="https://git.atakanozban.com/Songs2VID"
|
||||||
NEXT_PUBLIC_DOCKER_HUB_URL="https://hub.docker.com/r/atakanozban/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"
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ node_modules/
|
|||||||
.next/
|
.next/
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
*.tsbuildinfo
|
|
||||||
tsconfig.tsbuildinfo
|
|
||||||
website/node_modules/
|
website/node_modules/
|
||||||
website/build/
|
website/build/
|
||||||
website/.docusaurus/
|
website/.docusaurus/
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
# Songs2VID
|
|
||||||
|
|
||||||
Payment-free, self-hosted software that turns cover art and audio into YouTube videos (FFmpeg + BullMQ). Full entitlements in every deployment — no plans, credits, Stripe, or paywall.
|
|
||||||
|
|
||||||
**Image:** `atakanozban/songs2vid:latest`
|
|
||||||
**License:** MIT
|
|
||||||
|
|
||||||
**Docs:** https://docs.songs2vid.com
|
|
||||||
**Source:** https://git.atakanozban.com/Songs2VID/songs2vid
|
|
||||||
**Hosted SaaS (separate):** https://songs2vid.com
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
1. Clone the repo (Compose + `.env.example`) from Gitea, or reuse the sample `docker-compose.yml`.
|
|
||||||
2. Copy `.env.example` → `.env` and set:
|
|
||||||
- `NEXTAUTH_SECRET`
|
|
||||||
- `GOOGLE_CLIENT_ID`
|
|
||||||
- `GOOGLE_CLIENT_SECRET`
|
|
||||||
3. Enable **YouTube Data API v3** in Google Cloud and add OAuth redirect:
|
|
||||||
- `http://localhost:3000/api/auth/callback/google` (or your public HTTPS origin)
|
|
||||||
4. Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker pull atakanozban/songs2vid:latest
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Open http://localhost:3000 → sign in with Google → dashboard.
|
|
||||||
|
|
||||||
No Stripe, billing, or edition environment variables are required. Watermarks are optional (no free-tier badge paywall).
|
|
||||||
|
|
||||||
## What you get
|
|
||||||
|
|
||||||
- Batch encode: cover art + audio → YouTube upload
|
|
||||||
- Classic and art-track layouts, blur, typography, watermarks
|
|
||||||
- Playlists, privacy, tags, resolutions
|
|
||||||
- REST API (Dashboard → Settings → API key)
|
|
||||||
- Always-on self-hosted entitlements
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
Next.js 15 · PostgreSQL/Prisma · Redis/BullMQ · NextAuth (Google + YouTube) · FFmpeg
|
|
||||||
|
|
||||||
## Links
|
|
||||||
|
|
||||||
- Full documentation: https://docs.songs2vid.com
|
|
||||||
- Getting started: https://docs.songs2vid.com/docs/getting-started
|
|
||||||
- Environment variables: https://docs.songs2vid.com/docs/environment
|
|
||||||
- REST API: https://docs.songs2vid.com/docs/api/overview
|
|
||||||
- In-repo API notes: https://git.atakanozban.com/Songs2VID/songs2vid/src/branch/main/docs/api
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,98 +1,42 @@
|
|||||||
# 🎬 Songs2VID
|
# Songs2VID OSS
|
||||||
|
|
||||||
Payment-free, self-hosted software that turns cover art and audio into YouTube videos
|
Payment-free, self-hosted software for creating videos from cover art and audio, then uploading them
|
||||||
(FFmpeg encoding + BullMQ jobs). Every deployment has full entitlements — there are no
|
to YouTube. All features are available in every deployment; there are no plans, credits, purchases,
|
||||||
plans, credits, purchases, subscriptions, paywalls, or Stripe.
|
subscriptions, or Stripe integration.
|
||||||
|
|
||||||
Sign in with Google, open the dashboard, and create videos. Settings cover account,
|
## Quick start with Docker
|
||||||
YouTube connection, and API keys only.
|
|
||||||
|
|
||||||

|
1. Copy `.env.example` to `.env` and set `NEXTAUTH_SECRET`, `GOOGLE_CLIENT_ID`, and
|
||||||
|
`GOOGLE_CLIENT_SECRET`.
|
||||||
## 🚀 Quick start (Docker)
|
2. In Google Cloud Console, enable YouTube Data API v3 and add
|
||||||
|
`http://localhost:3000/api/auth/callback/google` as an OAuth redirect URI.
|
||||||
Published image: [`atakanozban/songs2vid:latest`](https://hub.docker.com/r/atakanozban/songs2vid)
|
3. Start the stack:
|
||||||
|
|
||||||
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
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Compose uses the published image by default (`atakanozban/songs2vid:latest`) and can also
|
Open http://localhost:3000.
|
||||||
build from this repo (`docker compose up -d --build`).
|
|
||||||
|
|
||||||
Open http://localhost:3000 → sign in → dashboard.
|
## Local development
|
||||||
|
|
||||||
No Stripe keys, billing env vars, or edition flags are required. Watermarks are optional;
|
|
||||||
OSS does not force a free-tier SaaS badge paywall.
|
|
||||||
|
|
||||||
## ✨ Features
|
|
||||||
|
|
||||||
- 📦 Batch: one cover (or per-track covers) + many audio files → YouTube uploads
|
|
||||||
- 🖼️ Classic letterbox and art-track layouts (blur backgrounds, typography, fine-tuning)
|
|
||||||
- 💧 Custom watermarks (badge, text, or logo) and curated or uploaded fonts
|
|
||||||
- ▶️ YouTube playlists, privacy, tags, resolution, categories
|
|
||||||
- 🔌 REST API for automation (keys under **Dashboard → Settings → API key**)
|
|
||||||
- 🤖 n8n community node [`n8n-nodes-songs2vid`](https://www.npmjs.com/package/n8n-nodes-songs2vid) + optional `webhookUrl` callbacks
|
|
||||||
- 🔓 Always-on self-hosted entitlements (no quota paywall)
|
|
||||||
|
|
||||||
## 📚 API documentation
|
|
||||||
|
|
||||||
- In-repo: [docs/api/overview.md](./docs/api/overview.md), [docs/api/endpoints.md](./docs/api/endpoints.md), [docs/n8n.md](./docs/n8n.md)
|
|
||||||
- Live site: [docs.songs2vid.com/docs/api/overview](https://docs.songs2vid.com/docs/api/overview)
|
|
||||||
- Machine-readable discovery: `GET /api/v1` (no auth; no billing routes)
|
|
||||||
- Node package source: [`integrations/n8n`](./integrations/n8n/)
|
|
||||||
|
|
||||||
## 🛠️ Local development
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.dev.yml up -d
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
cp .env.example .env # then edit secrets / Google OAuth
|
|
||||||
npm install
|
npm install
|
||||||
npm run db:migrate
|
npm run db:migrate
|
||||||
npm run dev:all
|
npm run dev:all
|
||||||
```
|
```
|
||||||
|
|
||||||
- App: http://localhost:3000
|
The web app runs on http://localhost:3000. `dev:all` starts both the Next.js app and BullMQ worker.
|
||||||
- `dev:all` runs Next.js and the BullMQ worker
|
|
||||||
|
|
||||||
## ⚙️ Environment
|
## Stack
|
||||||
|
|
||||||
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
|
- Next.js 15, TypeScript, Tailwind
|
||||||
- PostgreSQL and Prisma
|
- PostgreSQL and Prisma
|
||||||
- Redis and BullMQ
|
- Redis and BullMQ
|
||||||
- NextAuth with Google OAuth (YouTube scopes)
|
- NextAuth with Google OAuth
|
||||||
- FFmpeg
|
- FFmpeg
|
||||||
- YouTube Data API v3
|
- YouTube Data API v3
|
||||||
|
|
||||||
## 🔗 Links
|
Documentation, including the REST API reference, is at
|
||||||
|
[docs.songs2vid.com](https://docs.songs2vid.com).
|
||||||
- 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)
|
|
||||||
|
|||||||
@@ -1,33 +1,25 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import { CURATED_FONTS, isCuratedFontKey, SYSTEM_FONT } from "@/lib/fonts";
|
import { CURATED_FONTS, isCuratedFontKey } from "@/lib/fonts";
|
||||||
import { resolveCuratedFontPath, resolveSystemFontPath } from "@/lib/fonts-server";
|
import { resolveCuratedFontPath } from "@/lib/fonts-server";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
{ params }: { params: Promise<{ key: string }> },
|
{ params }: { params: Promise<{ key: string }> },
|
||||||
) {
|
) {
|
||||||
const { key } = await params;
|
const { key } = await params;
|
||||||
const fontPath =
|
if (!isCuratedFontKey(key)) {
|
||||||
key === SYSTEM_FONT.key
|
|
||||||
? resolveSystemFontPath()
|
|
||||||
: isCuratedFontKey(key)
|
|
||||||
? resolveCuratedFontPath(key)
|
|
||||||
: null;
|
|
||||||
if (!fontPath) {
|
|
||||||
return NextResponse.json({ error: "Unknown font" }, { status: 404 });
|
return NextResponse.json({ error: "Unknown font" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fontPath = resolveCuratedFontPath(key);
|
||||||
try {
|
try {
|
||||||
const buf = await fs.readFile(fontPath);
|
const buf = await fs.readFile(fontPath);
|
||||||
const fileName =
|
const meta = CURATED_FONTS.find((f) => f.key === key)!;
|
||||||
key === SYSTEM_FONT.key
|
|
||||||
? SYSTEM_FONT.file
|
|
||||||
: CURATED_FONTS.find((f) => f.key === key)!.file;
|
|
||||||
return new NextResponse(buf, {
|
return new NextResponse(buf, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "font/ttf",
|
"Content-Type": "font/ttf",
|
||||||
"Content-Disposition": `inline; filename="${fileName}"`,
|
"Content-Disposition": `inline; filename="${meta.file}"`,
|
||||||
"Cache-Control": "public, max-age=86400, immutable",
|
"Cache-Control": "public, max-age=86400, immutable",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,15 +35,12 @@ export async function POST(req: NextRequest) {
|
|||||||
const job = await createVideoJob(user, {
|
const job = await createVideoJob(user, {
|
||||||
imagePath: body.imagePath,
|
imagePath: body.imagePath,
|
||||||
items,
|
items,
|
||||||
webhookUrl: body.webhookUrl ?? null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
itemCount: job.items.length,
|
itemCount: job.items.length,
|
||||||
status: job.status,
|
status: job.status,
|
||||||
statusUrl: `/api/v1/jobs/${job.id}`,
|
|
||||||
webhookUrl: body.webhookUrl ?? null,
|
|
||||||
playlist: playlist
|
playlist: playlist
|
||||||
? { id: playlist.id, title: playlist.title, privacy: playlist.privacy }
|
? { id: playlist.id, title: playlist.title, privacy: playlist.privacy }
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
/**
|
|
||||||
* Alias for n8n / automation: POST|GET /api/v1/render → same as /api/v1/jobs
|
|
||||||
* OSS: no plan gate — Bearer API key + YouTube connected.
|
|
||||||
*/
|
|
||||||
export { POST, GET } from "../jobs/route";
|
|
||||||
+4
-35
@@ -1,9 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import {
|
|
||||||
COMPOSITION_FAMILIES,
|
|
||||||
LAYOUT_TEMPLATE_LABELS,
|
|
||||||
LAYOUT_TEMPLATES,
|
|
||||||
} from "@/lib/layout";
|
|
||||||
import { API_DOCS_URL } from "@/lib/plans";
|
import { API_DOCS_URL } from "@/lib/plans";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@@ -13,31 +8,12 @@ export async function GET() {
|
|||||||
authentication: "Authorization: Bearer <api_key>",
|
authentication: "Authorization: Bearer <api_key>",
|
||||||
requirements: ["YouTube account connected"],
|
requirements: ["YouTube account connected"],
|
||||||
rateLimit: "100,000 requests per minute per account",
|
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: {
|
guidance: {
|
||||||
recommended:
|
recommended:
|
||||||
"For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/render (or /api/v1/jobs) with paths + optional webhookUrl",
|
"For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/jobs with the returned paths",
|
||||||
batch:
|
batch:
|
||||||
"POST /api/v1/jobs/batch is for small packs only. Large multipart bodies may fail with 'failed to parse body as FormData'",
|
"POST /api/v1/jobs/batch is for small packs only. Large multipart bodies may fail with 'failed to parse body as FormData'",
|
||||||
polling: "GET /api/v1/jobs/:id until status is COMPLETED, FAILED, or PARTIAL",
|
|
||||||
},
|
},
|
||||||
layoutTemplates: LAYOUT_TEMPLATES.map((id) => ({
|
|
||||||
id,
|
|
||||||
label: LAYOUT_TEMPLATE_LABELS[id],
|
|
||||||
})),
|
|
||||||
compositionFamilies: COMPOSITION_FAMILIES.map((family) => ({
|
|
||||||
id: family.id,
|
|
||||||
label: family.label,
|
|
||||||
defaultTemplate: family.defaultTemplate,
|
|
||||||
variants: family.variants.map((id) => ({
|
|
||||||
id,
|
|
||||||
label: LAYOUT_TEMPLATE_LABELS[id],
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
endpoints: [
|
endpoints: [
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -46,19 +22,12 @@ export async function GET() {
|
|||||||
"Upload image, audio, PNG logo, or .ttf/.otf font (font ≤10MB)",
|
"Upload image, audio, PNG logo, or .ttf/.otf font (font ≤10MB)",
|
||||||
body: "multipart/form-data: file, type (image|audio|logo|font)",
|
body: "multipart/form-data: file, type (image|audio|logo|font)",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
path: "/api/v1/render",
|
|
||||||
description:
|
|
||||||
"Alias of /api/v1/jobs — create a render job (n8n-friendly). Optional webhookUrl for callbacks.",
|
|
||||||
body: "application/json: { imagePath, webhookUrl?, items[{ audioPath, audioFilename, metadata }] }",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/api/v1/jobs",
|
path: "/api/v1/jobs",
|
||||||
description:
|
description:
|
||||||
"Create a video job from uploaded file paths with layouts, watermark, and per-item covers",
|
"Create a video job from uploaded file paths with layouts, watermark, and per-item covers",
|
||||||
body: "application/json: { imagePath, webhookUrl?, items[{ audioPath, audioFilename, metadata }] }",
|
body: "application/json: { imagePath, items[{ audioPath, audioFilename, metadata }] }",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -81,13 +50,13 @@ export async function GET() {
|
|||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/api/v1/jobs",
|
path: "/api/v1/jobs",
|
||||||
description: "List recent jobs (also available as GET /api/v1/render)",
|
description: "List recent jobs",
|
||||||
query: "limit (default 20, max 100)",
|
query: "limit (default 20, max 100)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/api/v1/jobs/:id",
|
path: "/api/v1/jobs/:id",
|
||||||
description: "Get job status and item details (poll for n8n)",
|
description: "Get job status and item details",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
docs: API_DOCS_URL,
|
docs: API_DOCS_URL,
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ const inter = Inter({ subsets: ["latin"] });
|
|||||||
const SITE_NAME = "Songs2VID";
|
const SITE_NAME = "Songs2VID";
|
||||||
const DEFAULT_TITLE = "Songs2VID";
|
const DEFAULT_TITLE = "Songs2VID";
|
||||||
const DEFAULT_DESCRIPTION =
|
const DEFAULT_DESCRIPTION =
|
||||||
"Payment-free self-hosted audio-to-video creation and YouTube uploading.";
|
"Self-hosted audio-to-video creation and YouTube uploading.";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: DEFAULT_TITLE,
|
title: DEFAULT_TITLE,
|
||||||
|
|||||||
+4
-65
@@ -1,76 +1,15 @@
|
|||||||
import { LegalPageLayout } from "@/components/LegalPageLayout";
|
import { LegalPageLayout } from "@/components/LegalPageLayout";
|
||||||
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
|
|
||||||
import { DOCS_URL, GITEA_URL } from "@/lib/plans";
|
|
||||||
|
|
||||||
export default function PrivacyPage() {
|
export default function PrivacyPage() {
|
||||||
return (
|
return (
|
||||||
<LegalPageLayout title="Privacy Policy">
|
<LegalPageLayout title="Privacy Policy">
|
||||||
<p>
|
<p>
|
||||||
This policy describes how a <strong>self-hosted Songs2VID</strong> instance typically
|
Songs2VID is self-hosted software. Your operator controls the deployment, database, uploads,
|
||||||
handles data. Songs2VID OSS does not include payment processing, plans, credits, or
|
logs, and Google OAuth configuration. Songs2VID does not include payment processing.
|
||||||
Stripe. The person or organization that operates this deployment (the "operator")
|
|
||||||
controls the server, database, uploads, logs, and Google OAuth configuration, and is
|
|
||||||
responsible for privacy compliance for their users.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>1. Who is responsible</h2>
|
|
||||||
<p>
|
<p>
|
||||||
For this software package as published by {LEGAL_OPERATOR.legalName}, contact:{" "}
|
Google and YouTube process account and upload data according to their own policies. Contact
|
||||||
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>. For data on{" "}
|
the operator of this instance for data access or deletion requests.
|
||||||
<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>
|
</p>
|
||||||
</LegalPageLayout>
|
</LegalPageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
+2
-7
@@ -1,17 +1,12 @@
|
|||||||
import type { MetadataRoute } from "next";
|
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 {
|
export default function robots(): MetadataRoute.Robots {
|
||||||
return {
|
return {
|
||||||
rules: {
|
rules: {
|
||||||
userAgent: "*",
|
userAgent: "*",
|
||||||
allow: "/",
|
allow: "/",
|
||||||
disallow: ["/api/", "/dashboard", "/jobs"],
|
disallow: ["/admin", "/api/admin"],
|
||||||
},
|
},
|
||||||
sitemap: `${siteUrl()}/sitemap.xml`,
|
sitemap: "https://songs2vid.com/sitemap.xml",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-14
@@ -1,27 +1,17 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
function siteUrl() {
|
const SITE_URL = "https://songs2vid.com";
|
||||||
const raw = process.env.NEXTAUTH_URL?.trim() || "http://localhost:3000";
|
|
||||||
return raw.replace(/\/$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function sitemap(): MetadataRoute.Sitemap {
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
const base = siteUrl();
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: base,
|
url: SITE_URL,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: "weekly",
|
changeFrequency: "daily",
|
||||||
priority: 1.0,
|
priority: 1.0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${base}/privacy`,
|
url: `${SITE_URL}/privacy`,
|
||||||
lastModified: new Date(),
|
|
||||||
changeFrequency: "monthly",
|
|
||||||
priority: 0.3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: `${base}/terms`,
|
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: "monthly",
|
changeFrequency: "monthly",
|
||||||
priority: 0.3,
|
priority: 0.3,
|
||||||
|
|||||||
+4
-40
@@ -1,51 +1,15 @@
|
|||||||
import { LegalPageLayout } from "@/components/LegalPageLayout";
|
import { LegalPageLayout } from "@/components/LegalPageLayout";
|
||||||
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
|
|
||||||
import { DOCS_URL, GITEA_URL } from "@/lib/plans";
|
|
||||||
|
|
||||||
export default function TermsPage() {
|
export default function TermsPage() {
|
||||||
return (
|
return (
|
||||||
<LegalPageLayout title="Terms of Use">
|
<LegalPageLayout title="Terms of Use">
|
||||||
<p>
|
<p>
|
||||||
Songs2VID OSS is open-source, <strong>payment-free, self-hosted software</strong>. These
|
Songs2VID is provided as open-source, self-hosted software without warranty. The operator of
|
||||||
terms describe use of the software and of instances that run it. They are{" "}
|
each instance is responsible for availability, configuration, and user access.
|
||||||
<strong>not</strong> the paid cloud Terms of Service for songs2vid.com (a separate hosted
|
|
||||||
product with its own billing and policies).
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>1. Software license and warranty</h2>
|
|
||||||
<p>
|
<p>
|
||||||
The source is provided under the license in the repository <code>LICENSE</code> file. The
|
You are responsible for the media you process and upload, including compliance with
|
||||||
software is provided <strong>without warranty</strong> of any kind. The operator of each
|
copyright law and YouTube's terms.
|
||||||
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'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'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>
|
</p>
|
||||||
</LegalPageLayout>
|
</LegalPageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 13 KiB |
@@ -58,8 +58,7 @@ export function ApiKeySettings({ initialStatus, initialRateLimit }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
Use the REST API to upload files and create video jobs programmatically. There are no
|
Use the REST API to upload files and create video jobs programmatically.
|
||||||
billing endpoints in this self-hosted edition.
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
Rate limit: {initialRateLimit.limit} requests per {initialRateLimit.windowSeconds} seconds.
|
Rate limit: {initialRateLimit.limit} requests per {initialRateLimit.windowSeconds} seconds.
|
||||||
|
|||||||
+46
-295
@@ -10,7 +10,6 @@ import {
|
|||||||
import { getVideoAttributionText } from "@/lib/branding";
|
import { getVideoAttributionText } from "@/lib/branding";
|
||||||
import {
|
import {
|
||||||
CURATED_FONTS,
|
CURATED_FONTS,
|
||||||
SYSTEM_FONT,
|
|
||||||
type CuratedFontKey,
|
type CuratedFontKey,
|
||||||
type WatermarkFontKey,
|
type WatermarkFontKey,
|
||||||
} from "@/lib/fonts";
|
} from "@/lib/fonts";
|
||||||
@@ -20,18 +19,15 @@ import {
|
|||||||
BLUR_OPACITY_DEFAULT,
|
BLUR_OPACITY_DEFAULT,
|
||||||
BLUR_OPACITY_MAX,
|
BLUR_OPACITY_MAX,
|
||||||
BLUR_OPACITY_MIN,
|
BLUR_OPACITY_MIN,
|
||||||
COMPOSITION_FAMILIES,
|
|
||||||
DEFAULT_LAYOUT,
|
DEFAULT_LAYOUT,
|
||||||
LAYOUT_TEMPLATE_LABELS,
|
LAYOUT_TEMPLATE_LABELS,
|
||||||
|
LAYOUT_TEMPLATES,
|
||||||
TEXT_OFFSET_MAX,
|
TEXT_OFFSET_MAX,
|
||||||
TEXT_OFFSET_MIN,
|
TEXT_OFFSET_MIN,
|
||||||
TEXT_PADDING_MAX,
|
TEXT_PADDING_MAX,
|
||||||
TEXT_PADDING_MIN,
|
TEXT_PADDING_MIN,
|
||||||
TITLE_ARTIST_GAP_MAX,
|
TITLE_ARTIST_GAP_MAX,
|
||||||
TITLE_ARTIST_GAP_MIN,
|
TITLE_ARTIST_GAP_MIN,
|
||||||
compositionFamilyForTemplate,
|
|
||||||
isLowerCornerTemplate,
|
|
||||||
lowerCornerCoverSide,
|
|
||||||
type LayoutSettings,
|
type LayoutSettings,
|
||||||
type LayoutTemplate,
|
type LayoutTemplate,
|
||||||
} from "@/lib/layout";
|
} from "@/lib/layout";
|
||||||
@@ -58,8 +54,6 @@ import {
|
|||||||
type Props = {
|
type Props = {
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
previewImageUrl: string | null;
|
previewImageUrl: string | null;
|
||||||
/** Optional custom blur-fill for lower-corner layouts. */
|
|
||||||
previewBackgroundUrl?: string | null;
|
|
||||||
/** On-video song title (art-track layouts). */
|
/** On-video song title (art-track layouts). */
|
||||||
songTitle: string;
|
songTitle: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
@@ -71,10 +65,7 @@ type Props = {
|
|||||||
onWatermarkChange: (next: WatermarkSettings) => void;
|
onWatermarkChange: (next: WatermarkSettings) => void;
|
||||||
onUploadLogo: (file: File) => Promise<string>;
|
onUploadLogo: (file: File) => Promise<string>;
|
||||||
onUploadFont: (file: File) => Promise<string>;
|
onUploadFont: (file: File) => Promise<string>;
|
||||||
onUploadBackground?: (file: File) => Promise<string>;
|
|
||||||
onClearBackground?: () => void;
|
|
||||||
logoPreviewUrl?: string | null;
|
logoPreviewUrl?: string | null;
|
||||||
hasCustomBackground?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const WM_POSITION_LABELS: Record<WatermarkPosition, string> = {
|
const WM_POSITION_LABELS: Record<WatermarkPosition, string> = {
|
||||||
@@ -130,13 +121,11 @@ function watermarkOverlayStyle(
|
|||||||
|
|
||||||
function MiniThumb({
|
function MiniThumb({
|
||||||
template,
|
template,
|
||||||
label,
|
|
||||||
active,
|
active,
|
||||||
onClick,
|
onClick,
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
template: LayoutTemplate | null;
|
template: LayoutTemplate | null;
|
||||||
label: string;
|
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
@@ -161,14 +150,20 @@ function MiniThumb({
|
|||||||
) : (
|
) : (
|
||||||
<div className="relative h-full w-full overflow-hidden bg-gray-700/50">
|
<div className="relative h-full w-full overflow-hidden bg-gray-700/50">
|
||||||
<div className="absolute inset-0 scale-110 bg-gray-600/40 blur-[3px]" />
|
<div className="absolute inset-0 scale-110 bg-gray-600/40 blur-[3px]" />
|
||||||
{(template === "COVER_LEFT_TEXT_RIGHT" ||
|
{template === "COVER_LEFT_TEXT_RIGHT" && (
|
||||||
template === "COVER_RIGHT_TEXT_LEFT") && (
|
|
||||||
<>
|
<>
|
||||||
<div className="absolute left-[6%] top-[18%] h-[64%] w-[38%] bg-gray-300" />
|
<div className="absolute left-[6%] top-[18%] h-[64%] w-[38%] bg-gray-300" />
|
||||||
<div className="absolute right-[8%] top-[38%] h-1 w-[40%] rounded bg-white/80" />
|
<div className="absolute right-[8%] top-[38%] h-1 w-[40%] rounded bg-white/80" />
|
||||||
<div className="absolute right-[8%] top-[52%] h-0.5 w-[28%] rounded bg-white/50" />
|
<div className="absolute right-[8%] top-[52%] h-0.5 w-[28%] rounded bg-white/50" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{template === "COVER_RIGHT_TEXT_LEFT" && (
|
||||||
|
<>
|
||||||
|
<div className="absolute right-[6%] top-[18%] h-[64%] w-[38%] bg-gray-300" />
|
||||||
|
<div className="absolute left-[8%] top-[38%] h-1 w-[40%] rounded bg-white/80" />
|
||||||
|
<div className="absolute left-[8%] top-[52%] h-0.5 w-[28%] rounded bg-white/50" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{template === "COVER_TOP_TEXT_BOTTOM" && (
|
{template === "COVER_TOP_TEXT_BOTTOM" && (
|
||||||
<>
|
<>
|
||||||
<div className="absolute left-[8%] top-[6%] h-[52%] w-[84%] bg-gray-300" />
|
<div className="absolute left-[8%] top-[6%] h-[52%] w-[84%] bg-gray-300" />
|
||||||
@@ -183,18 +178,12 @@ function MiniThumb({
|
|||||||
<div className="absolute left-[34%] bottom-[12%] h-0.5 w-[32%] rounded bg-white/50" />
|
<div className="absolute left-[34%] bottom-[12%] h-0.5 w-[32%] rounded bg-white/50" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{(template === "LOWER_LEFT_COVER_TEXT" ||
|
|
||||||
template === "LOWER_RIGHT_COVER_TEXT") && (
|
|
||||||
<>
|
|
||||||
<div className="absolute bottom-[8%] left-[5%] h-[36%] w-[24%] bg-gray-300" />
|
|
||||||
<div className="absolute bottom-[26%] left-[34%] h-1 w-[40%] rounded bg-white/80" />
|
|
||||||
<div className="absolute bottom-[14%] left-[34%] h-0.5 w-[28%] rounded bg-white/50" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="px-2 py-1.5 text-[10px] font-medium text-gray-300">{label}</p>
|
<p className="px-2 py-1.5 text-[10px] font-medium text-gray-300">
|
||||||
|
{isClassic ? "Classic letterbox" : LAYOUT_TEMPLATE_LABELS[template]}
|
||||||
|
</p>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -202,18 +191,8 @@ function MiniThumb({
|
|||||||
function previewCoverStyle(
|
function previewCoverStyle(
|
||||||
template: LayoutTemplate,
|
template: LayoutTemplate,
|
||||||
padPct: number,
|
padPct: number,
|
||||||
textPadding: number,
|
|
||||||
encodeWidth: number,
|
|
||||||
): CSSProperties {
|
): CSSProperties {
|
||||||
const p = `${padPct}%`;
|
const p = `${padPct}%`;
|
||||||
// Equal pixel inset on both axes (not % — % of width ≠ % of height on 16:9).
|
|
||||||
const cornerEdgePx = scaleFontToPreview(textPadding, encodeWidth);
|
|
||||||
// Assume 16:9 when only width is known; side is usually width-driven for lower corner.
|
|
||||||
const encodeHeight = Math.round((encodeWidth * 9) / 16);
|
|
||||||
const cornerSidePx = scaleFontToPreview(
|
|
||||||
lowerCornerCoverSide(encodeWidth, encodeHeight, textPadding),
|
|
||||||
encodeWidth,
|
|
||||||
);
|
|
||||||
switch (template) {
|
switch (template) {
|
||||||
case "COVER_LEFT_TEXT_RIGHT":
|
case "COVER_LEFT_TEXT_RIGHT":
|
||||||
return {
|
return {
|
||||||
@@ -255,24 +234,6 @@ function previewCoverStyle(
|
|||||||
maxHeight: "38%",
|
maxHeight: "38%",
|
||||||
objectFit: "contain",
|
objectFit: "contain",
|
||||||
};
|
};
|
||||||
case "LOWER_LEFT_COVER_TEXT":
|
|
||||||
return {
|
|
||||||
position: "absolute",
|
|
||||||
left: cornerEdgePx,
|
|
||||||
bottom: cornerEdgePx,
|
|
||||||
width: cornerSidePx,
|
|
||||||
height: cornerSidePx,
|
|
||||||
objectFit: "contain",
|
|
||||||
};
|
|
||||||
case "LOWER_RIGHT_COVER_TEXT":
|
|
||||||
return {
|
|
||||||
position: "absolute",
|
|
||||||
right: cornerEdgePx,
|
|
||||||
bottom: cornerEdgePx,
|
|
||||||
width: cornerSidePx,
|
|
||||||
height: cornerSidePx,
|
|
||||||
objectFit: "contain",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,20 +243,11 @@ function previewTextStyle(
|
|||||||
textOffsetX: number,
|
textOffsetX: number,
|
||||||
textOffsetY: number,
|
textOffsetY: number,
|
||||||
titleArtistGap: number,
|
titleArtistGap: number,
|
||||||
textPadding: number,
|
|
||||||
encodeWidth: number,
|
|
||||||
): CSSProperties {
|
): CSSProperties {
|
||||||
const p = `${padPct}%`;
|
const p = `${padPct}%`;
|
||||||
const cornerEdgePx = scaleFontToPreview(textPadding, encodeWidth);
|
const shift = {
|
||||||
const coverTextGapPx = scaleFontToPreview(
|
transform: undefined as string | undefined,
|
||||||
Math.max(10, Math.round(textPadding * 0.55)),
|
};
|
||||||
encodeWidth,
|
|
||||||
);
|
|
||||||
const encodeHeight = Math.round((encodeWidth * 9) / 16);
|
|
||||||
const cornerSidePx = scaleFontToPreview(
|
|
||||||
lowerCornerCoverSide(encodeWidth, encodeHeight, textPadding),
|
|
||||||
encodeWidth,
|
|
||||||
);
|
|
||||||
|
|
||||||
const baseGap = { display: "flex", flexDirection: "column" as const, gap: `${titleArtistGap}px` };
|
const baseGap = { display: "flex", flexDirection: "column" as const, gap: `${titleArtistGap}px` };
|
||||||
|
|
||||||
@@ -342,38 +294,13 @@ function previewTextStyle(
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
};
|
};
|
||||||
case "LOWER_LEFT_COVER_TEXT":
|
|
||||||
return {
|
|
||||||
...baseGap,
|
|
||||||
position: "absolute",
|
|
||||||
left: cornerEdgePx + cornerSidePx + coverTextGapPx + textOffsetX,
|
|
||||||
right: p,
|
|
||||||
bottom: cornerEdgePx,
|
|
||||||
height: cornerSidePx,
|
|
||||||
justifyContent: "center",
|
|
||||||
textAlign: "left",
|
|
||||||
transform: textOffsetY ? `translateY(${textOffsetY}px)` : undefined,
|
|
||||||
};
|
|
||||||
case "LOWER_RIGHT_COVER_TEXT":
|
|
||||||
return {
|
|
||||||
...baseGap,
|
|
||||||
position: "absolute",
|
|
||||||
left: p,
|
|
||||||
right: cornerEdgePx + cornerSidePx + coverTextGapPx - textOffsetX,
|
|
||||||
bottom: cornerEdgePx,
|
|
||||||
height: cornerSidePx,
|
|
||||||
justifyContent: "center",
|
|
||||||
textAlign: "right",
|
|
||||||
alignItems: "flex-end",
|
|
||||||
transform: textOffsetY ? `translateY(${textOffsetY}px)` : undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
void shift;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LayoutStudio({
|
export function LayoutStudio({
|
||||||
locked,
|
locked,
|
||||||
previewImageUrl,
|
previewImageUrl,
|
||||||
previewBackgroundUrl = null,
|
|
||||||
songTitle,
|
songTitle,
|
||||||
artist,
|
artist,
|
||||||
encodeWidth = 1280,
|
encodeWidth = 1280,
|
||||||
@@ -383,31 +310,24 @@ export function LayoutStudio({
|
|||||||
onWatermarkChange,
|
onWatermarkChange,
|
||||||
onUploadLogo,
|
onUploadLogo,
|
||||||
onUploadFont,
|
onUploadFont,
|
||||||
onUploadBackground,
|
|
||||||
onClearBackground,
|
|
||||||
logoPreviewUrl,
|
logoPreviewUrl,
|
||||||
hasCustomBackground = false,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [logoUploading, setLogoUploading] = useState(false);
|
const [logoUploading, setLogoUploading] = useState(false);
|
||||||
const [logoError, setLogoError] = useState<string | null>(null);
|
const [logoError, setLogoError] = useState<string | null>(null);
|
||||||
const [fontUploading, setFontUploading] = useState(false);
|
const [fontUploading, setFontUploading] = useState(false);
|
||||||
const [fontError, setFontError] = useState<string | null>(null);
|
const [fontError, setFontError] = useState<string | null>(null);
|
||||||
const [customFontObjectUrl, setCustomFontObjectUrl] = useState<string | null>(null);
|
const [customFontObjectUrl, setCustomFontObjectUrl] = useState<string | null>(null);
|
||||||
const [bgUploading, setBgUploading] = useState(false);
|
|
||||||
const [bgError, setBgError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Always coalesce HMR / older session state may omit newly added fields
|
// Always coalesce HMR / older session state may omit newly added fields
|
||||||
const L: LayoutSettings = {
|
const L: LayoutSettings = {
|
||||||
...DEFAULT_LAYOUT,
|
...DEFAULT_LAYOUT,
|
||||||
...layout,
|
...layout,
|
||||||
blurFill: layout.blurFill ?? DEFAULT_LAYOUT.blurFill,
|
|
||||||
blurAmount: layout.blurAmount ?? DEFAULT_LAYOUT.blurAmount,
|
blurAmount: layout.blurAmount ?? DEFAULT_LAYOUT.blurAmount,
|
||||||
blurOpacity: layout.blurOpacity ?? BLUR_OPACITY_DEFAULT,
|
blurOpacity: layout.blurOpacity ?? BLUR_OPACITY_DEFAULT,
|
||||||
textPadding: layout.textPadding ?? DEFAULT_LAYOUT.textPadding,
|
textPadding: layout.textPadding ?? DEFAULT_LAYOUT.textPadding,
|
||||||
titleArtistGap: layout.titleArtistGap ?? DEFAULT_LAYOUT.titleArtistGap,
|
titleArtistGap: layout.titleArtistGap ?? DEFAULT_LAYOUT.titleArtistGap,
|
||||||
textOffsetX: layout.textOffsetX ?? DEFAULT_LAYOUT.textOffsetX,
|
textOffsetX: layout.textOffsetX ?? DEFAULT_LAYOUT.textOffsetX,
|
||||||
textOffsetY: layout.textOffsetY ?? DEFAULT_LAYOUT.textOffsetY,
|
textOffsetY: layout.textOffsetY ?? DEFAULT_LAYOUT.textOffsetY,
|
||||||
titleBold: layout.titleBold ?? DEFAULT_LAYOUT.titleBold,
|
|
||||||
};
|
};
|
||||||
const W: WatermarkSettings = {
|
const W: WatermarkSettings = {
|
||||||
...DEFAULT_WATERMARK,
|
...DEFAULT_WATERMARK,
|
||||||
@@ -466,34 +386,13 @@ export function LayoutStudio({
|
|||||||
el.id = styleId;
|
el.id = styleId;
|
||||||
document.head.appendChild(el);
|
document.head.appendChild(el);
|
||||||
}
|
}
|
||||||
el.textContent = [
|
el.textContent = CURATED_FONTS.map(
|
||||||
`@font-face {
|
(f) => `@font-face {
|
||||||
font-family: '${SYSTEM_FONT.previewFamily}';
|
|
||||||
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}') format('truetype');
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
}`,
|
|
||||||
`@font-face {
|
|
||||||
font-family: '${SYSTEM_FONT.previewFamily}';
|
|
||||||
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}?weight=bold') format('truetype');
|
|
||||||
font-weight: 600;
|
|
||||||
font-display: swap;
|
|
||||||
}`,
|
|
||||||
`@font-face {
|
|
||||||
font-family: '${SYSTEM_FONT.previewFamily}';
|
|
||||||
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}?weight=bold') format('truetype');
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
}`,
|
|
||||||
...CURATED_FONTS.map(
|
|
||||||
(f) => `@font-face {
|
|
||||||
font-family: 'S2VIDPreview-${f.key}';
|
font-family: 'S2VIDPreview-${f.key}';
|
||||||
src: url('${curatedFontApiUrl(f.key)}') format('truetype');
|
src: url('${curatedFontApiUrl(f.key)}') format('truetype');
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}`,
|
}`,
|
||||||
),
|
).join("\n");
|
||||||
].join("\n");
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -564,21 +463,6 @@ export function LayoutStudio({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleBackground(e: ChangeEvent<HTMLInputElement>) {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
e.target.value = "";
|
|
||||||
if (!file || locked || !onUploadBackground) return;
|
|
||||||
setBgError(null);
|
|
||||||
setBgUploading(true);
|
|
||||||
try {
|
|
||||||
await onUploadBackground(file);
|
|
||||||
} catch (err) {
|
|
||||||
setBgError(err instanceof Error ? err.message : "Background upload failed");
|
|
||||||
} finally {
|
|
||||||
setBgUploading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setFontKey(key: WatermarkFontKey) {
|
function setFontKey(key: WatermarkFontKey) {
|
||||||
if (key === "custom") {
|
if (key === "custom") {
|
||||||
patchWm({ fontKey: "custom", fontPath: watermark.fontPath ?? null });
|
patchWm({ fontKey: "custom", fontPath: watermark.fontPath ?? null });
|
||||||
@@ -588,8 +472,6 @@ export function LayoutStudio({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const artTrack = Boolean(L.template);
|
const artTrack = Boolean(L.template);
|
||||||
const lowerCorner = isLowerCornerTemplate(L.template);
|
|
||||||
const blurPreviewUrl = previewBackgroundUrl || previewImageUrl;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -613,7 +495,7 @@ export function LayoutStudio({
|
|||||||
<div className="absolute inset-0 bg-black" />
|
<div className="absolute inset-0 bg-black" />
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={blurPreviewUrl!}
|
src={previewImageUrl}
|
||||||
alt=""
|
alt=""
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
style={{
|
style={{
|
||||||
@@ -626,12 +508,7 @@ export function LayoutStudio({
|
|||||||
<img
|
<img
|
||||||
src={previewImageUrl}
|
src={previewImageUrl}
|
||||||
alt=""
|
alt=""
|
||||||
style={previewCoverStyle(
|
style={previewCoverStyle(L.template!, padPct)}
|
||||||
L.template!,
|
|
||||||
padPct,
|
|
||||||
L.textPadding,
|
|
||||||
encodeWidth,
|
|
||||||
)}
|
|
||||||
className="pointer-events-none"
|
className="pointer-events-none"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@@ -641,17 +518,12 @@ export function LayoutStudio({
|
|||||||
L.textOffsetX,
|
L.textOffsetX,
|
||||||
L.textOffsetY,
|
L.textOffsetY,
|
||||||
L.titleArtistGap,
|
L.titleArtistGap,
|
||||||
L.textPadding,
|
|
||||||
encodeWidth,
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
className={`max-w-full truncate text-white drop-shadow ${
|
className="max-w-full truncate font-semibold text-white drop-shadow"
|
||||||
L.titleBold ? "font-semibold" : "font-normal"
|
|
||||||
}`}
|
|
||||||
style={{
|
style={{
|
||||||
...textFontStyle,
|
...textFontStyle,
|
||||||
fontWeight: L.titleBold ? 600 : 400,
|
|
||||||
fontSize: `${titlePreviewPx}px`,
|
fontSize: `${titlePreviewPx}px`,
|
||||||
lineHeight: 1.15,
|
lineHeight: 1.15,
|
||||||
}}
|
}}
|
||||||
@@ -671,28 +543,13 @@ export function LayoutStudio({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 bg-black">
|
<div className="absolute inset-0 flex items-center justify-center bg-black">
|
||||||
{L.blurFill && (
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
<img
|
||||||
<img
|
src={previewImageUrl}
|
||||||
src={previewImageUrl}
|
alt=""
|
||||||
alt=""
|
className="max-h-full max-w-full object-contain"
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
/>
|
||||||
style={{
|
|
||||||
filter: L.blurAmount > 0 ? `blur(${blurPx}px)` : undefined,
|
|
||||||
transform: "scale(1.15)",
|
|
||||||
opacity: L.blurOpacity / 100,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={previewImageUrl}
|
|
||||||
alt=""
|
|
||||||
className="max-h-full max-w-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -738,114 +595,22 @@ export function LayoutStudio({
|
|||||||
{/* Art-track templates */}
|
{/* Art-track templates */}
|
||||||
<p className="mb-2 mt-5 text-sm font-medium text-gray-300">Composition</p>
|
<p className="mb-2 mt-5 text-sm font-medium text-gray-300">Composition</p>
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
{COMPOSITION_FAMILIES.map((family) => {
|
<MiniThumb
|
||||||
const active =
|
template={null}
|
||||||
family.id === "classic"
|
active={L.template === null}
|
||||||
? L.template === null
|
disabled={locked}
|
||||||
: family.variants.includes(L.template as LayoutTemplate);
|
onClick={() => patchLayout({ template: null })}
|
||||||
return (
|
/>
|
||||||
<MiniThumb
|
{LAYOUT_TEMPLATES.map((t) => (
|
||||||
key={family.id}
|
<MiniThumb
|
||||||
template={family.thumb}
|
key={t}
|
||||||
label={family.label}
|
template={t}
|
||||||
active={active}
|
active={L.template === t}
|
||||||
disabled={locked}
|
|
||||||
onClick={() => {
|
|
||||||
if (family.id === "classic") {
|
|
||||||
patchLayout({ template: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
L.template &&
|
|
||||||
family.variants.includes(L.template as LayoutTemplate)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
patchLayout({ template: family.defaultTemplate, blurFill: false });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{(() => {
|
|
||||||
const family = compositionFamilyForTemplate(L.template);
|
|
||||||
if (family.variants.length < 2) return null;
|
|
||||||
return (
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
|
||||||
{family.variants.map((variant) => (
|
|
||||||
<button
|
|
||||||
key={variant}
|
|
||||||
type="button"
|
|
||||||
disabled={locked}
|
|
||||||
onClick={() => patchLayout({ template: variant })}
|
|
||||||
className={`rounded border px-2.5 py-1.5 text-[11px] font-medium transition-colors ${
|
|
||||||
L.template === variant
|
|
||||||
? "border-accent bg-accent/15 text-white"
|
|
||||||
: "border-gray-700 bg-black/40 text-gray-300 hover:border-gray-500"
|
|
||||||
} disabled:cursor-not-allowed disabled:opacity-50`}
|
|
||||||
>
|
|
||||||
{LAYOUT_TEMPLATE_LABELS[variant]}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{!artTrack && (
|
|
||||||
<label className="mt-3 flex cursor-pointer items-start gap-2 text-sm text-gray-300">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="mt-0.5"
|
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
checked={L.blurFill}
|
onClick={() => patchLayout({ template: t })}
|
||||||
onChange={(e) => patchLayout({ blurFill: e.target.checked })}
|
|
||||||
/>
|
/>
|
||||||
<span>
|
))}
|
||||||
<span className="font-medium text-gray-200">Blurred cover background</span>
|
</div>
|
||||||
<span className="mt-0.5 block text-xs text-gray-500">
|
|
||||||
Fill letterbox bars with a blurred cover instead of black. Adjust blur and opacity
|
|
||||||
below when enabled.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{lowerCorner && (
|
|
||||||
<div className="mt-3 rounded border border-gray-700 bg-black/30 p-3">
|
|
||||||
<p className="mb-2 text-sm font-medium text-gray-300">Background image</p>
|
|
||||||
<p className="mb-2 text-xs text-gray-500">
|
|
||||||
Optional. Used as the blurred full-frame fill; the cover stays the sharp corner square.
|
|
||||||
Leave empty to blur the cover itself.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<label
|
|
||||||
className={`cursor-pointer rounded border border-gray-600 bg-surface-dark px-3 py-1.5 text-xs text-gray-200 hover:border-gray-400 ${
|
|
||||||
locked || bgUploading ? "pointer-events-none opacity-40" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{bgUploading ? "Uploading…" : hasCustomBackground ? "Replace image" : "Upload image"}
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
disabled={locked || bgUploading || !onUploadBackground}
|
|
||||||
onChange={handleBackground}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{hasCustomBackground && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={locked}
|
|
||||||
onClick={() => onClearBackground?.()}
|
|
||||||
className="rounded border border-gray-700 px-3 py-1.5 text-xs text-gray-400 hover:border-gray-500 hover:text-gray-200 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
Use cover as background
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{bgError && <p className="mt-2 text-xs text-red-400">{bgError}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
<label className="text-sm text-gray-400">
|
<label className="text-sm text-gray-400">
|
||||||
@@ -854,7 +619,7 @@ export function LayoutStudio({
|
|||||||
type="range"
|
type="range"
|
||||||
min={BLUR_AMOUNT_MIN}
|
min={BLUR_AMOUNT_MIN}
|
||||||
max={BLUR_AMOUNT_MAX}
|
max={BLUR_AMOUNT_MAX}
|
||||||
disabled={locked || !(artTrack || L.blurFill)}
|
disabled={locked || !artTrack}
|
||||||
value={L.blurAmount}
|
value={L.blurAmount}
|
||||||
onChange={(e) => patchLayout({ blurAmount: Number(e.target.value) })}
|
onChange={(e) => patchLayout({ blurAmount: Number(e.target.value) })}
|
||||||
className="mt-1 w-full disabled:opacity-40"
|
className="mt-1 w-full disabled:opacity-40"
|
||||||
@@ -866,7 +631,7 @@ export function LayoutStudio({
|
|||||||
type="range"
|
type="range"
|
||||||
min={BLUR_OPACITY_MIN}
|
min={BLUR_OPACITY_MIN}
|
||||||
max={BLUR_OPACITY_MAX}
|
max={BLUR_OPACITY_MAX}
|
||||||
disabled={locked || !(artTrack || L.blurFill)}
|
disabled={locked || !artTrack}
|
||||||
value={L.blurOpacity}
|
value={L.blurOpacity}
|
||||||
onChange={(e) => patchLayout({ blurOpacity: Number(e.target.value) })}
|
onChange={(e) => patchLayout({ blurOpacity: Number(e.target.value) })}
|
||||||
className="mt-1 w-full disabled:opacity-40"
|
className="mt-1 w-full disabled:opacity-40"
|
||||||
@@ -922,20 +687,6 @@ export function LayoutStudio({
|
|||||||
className="mt-1 w-full disabled:opacity-40"
|
className="mt-1 w-full disabled:opacity-40"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label
|
|
||||||
className={`flex items-center gap-2 text-sm text-gray-300 sm:col-span-2 ${
|
|
||||||
locked || !artTrack ? "opacity-40" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="rounded border-gray-600 bg-surface-dark text-accent focus:ring-accent"
|
|
||||||
disabled={locked || !artTrack}
|
|
||||||
checked={L.titleBold}
|
|
||||||
onChange={(e) => patchLayout({ titleBold: e.target.checked })}
|
|
||||||
/>
|
|
||||||
Bold song title
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Typography (matches FFmpeg art-track + watermark text fonts) */}
|
{/* Typography (matches FFmpeg art-track + watermark text fonts) */}
|
||||||
@@ -949,7 +700,7 @@ export function LayoutStudio({
|
|||||||
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
|
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
|
||||||
className="input-field mt-1"
|
className="input-field mt-1"
|
||||||
>
|
>
|
||||||
<option value="system">{SYSTEM_FONT.label}</option>
|
<option value="system">Arial (system default)</option>
|
||||||
{CURATED_FONTS.map((f) => (
|
{CURATED_FONTS.map((f) => (
|
||||||
<option key={f.key} value={f.key}>
|
<option key={f.key} value={f.key}>
|
||||||
{f.label}
|
{f.label}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { DOCS_URL, GITEA_URL } from "@/lib/plans";
|
|
||||||
|
|
||||||
function LegalLink({
|
function LegalLink({
|
||||||
href,
|
href,
|
||||||
@@ -34,7 +33,7 @@ export function LegalFooter() {
|
|||||||
return (
|
return (
|
||||||
<footer className="mt-auto w-full border-t border-gray-800 bg-black/40 py-8 text-xs text-gray-500">
|
<footer className="mt-auto w-full border-t border-gray-800 bg-black/40 py-8 text-xs text-gray-500">
|
||||||
<div className="mx-auto flex max-w-4xl flex-wrap items-center justify-center gap-x-4 gap-y-2 px-6 md:justify-start">
|
<div className="mx-auto flex max-w-4xl flex-wrap items-center justify-center gap-x-4 gap-y-2 px-6 md:justify-start">
|
||||||
<span>© {year} Songs2VID. MIT licensed.</span>
|
<span>© {year} Songs2VID. All rights reserved.</span>
|
||||||
<span aria-hidden="true" className="hidden sm:inline">
|
<span aria-hidden="true" className="hidden sm:inline">
|
||||||
|
|
|
|
||||||
</span>
|
</span>
|
||||||
@@ -51,18 +50,16 @@ export function LegalFooter() {
|
|||||||
<span aria-hidden="true" className="hidden sm:inline">
|
<span aria-hidden="true" className="hidden sm:inline">
|
||||||
|
|
|
|
||||||
</span>
|
</span>
|
||||||
<LegalLink href="/terms">Terms of Use</LegalLink>
|
<LegalLink href="/terms">Terms of Service</LegalLink>
|
||||||
<span aria-hidden="true" className="hidden sm:inline">
|
<span aria-hidden="true" className="hidden sm:inline">
|
||||||
|
|
|
|
||||||
</span>
|
</span>
|
||||||
<LegalLink href={DOCS_URL} external>
|
<LegalLink href="https://docs.songs2vid.com" external>Documentation</LegalLink>
|
||||||
Documentation
|
|
||||||
</LegalLink>
|
|
||||||
<span aria-hidden="true" className="hidden sm:inline">
|
<span aria-hidden="true" className="hidden sm:inline">
|
||||||
|
|
|
|
||||||
</span>
|
</span>
|
||||||
<LegalLink href={GITEA_URL} external>
|
<LegalLink href="https://status.atakanozban.com/status/2" external>
|
||||||
Source
|
Service Status
|
||||||
</LegalLink>
|
</LegalLink>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import Link from "next/link";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
import { LEGAL_LAST_UPDATED } from "@/lib/legal/constants";
|
import { LEGAL_LAST_UPDATED } from "@/lib/legal/constants";
|
||||||
import { DOCS_URL } from "@/lib/plans";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -46,7 +45,7 @@ export function LegalPageLayout({ title, description, children }: Props) {
|
|||||||
Terms
|
Terms
|
||||||
</Link>
|
</Link>
|
||||||
<a
|
<a
|
||||||
href={DOCS_URL}
|
href="https://docs.songs2vid.com"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="hover:text-gray-300"
|
className="hover:text-gray-300"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { audioTagsToMetadata } from "@/lib/audio-tags";
|
|||||||
import { resolveYouTubeTitle } from "@/lib/titles";
|
import { resolveYouTubeTitle } from "@/lib/titles";
|
||||||
import { SONG_TITLE_MAX } from "@/lib/layout";
|
import { SONG_TITLE_MAX } from "@/lib/layout";
|
||||||
import type { ItemMetadata } from "@/lib/types";
|
import type { ItemMetadata } from "@/lib/types";
|
||||||
import { DEFAULT_LAYOUT, isLowerCornerTemplate, type LayoutSettings } from "@/lib/layout";
|
import { DEFAULT_LAYOUT, type LayoutSettings } from "@/lib/layout";
|
||||||
import { DEFAULT_WATERMARK, type WatermarkSettings } from "@/lib/watermark";
|
import { DEFAULT_WATERMARK, type WatermarkSettings } from "@/lib/watermark";
|
||||||
import { CategorySelect } from "./CategorySelect";
|
import { CategorySelect } from "./CategorySelect";
|
||||||
import { LayoutStudio } from "./LayoutStudio";
|
import { LayoutStudio } from "./LayoutStudio";
|
||||||
@@ -65,8 +65,6 @@ export function UploadForm() {
|
|||||||
const [jobWatermark, setJobWatermark] = useState<WatermarkSettings>({ ...DEFAULT_WATERMARK });
|
const [jobWatermark, setJobWatermark] = useState<WatermarkSettings>({ ...DEFAULT_WATERMARK });
|
||||||
const [jobLayout, setJobLayout] = useState<LayoutSettings>({ ...DEFAULT_LAYOUT });
|
const [jobLayout, setJobLayout] = useState<LayoutSettings>({ ...DEFAULT_LAYOUT });
|
||||||
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
|
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
|
||||||
const [backgroundImagePath, setBackgroundImagePath] = useState<string | null>(null);
|
|
||||||
const [backgroundPreviewUrl, setBackgroundPreviewUrl] = useState<string | null>(null);
|
|
||||||
const [quota, setQuota] = useState<{
|
const [quota, setQuota] = useState<{
|
||||||
maxBatchSize: number;
|
maxBatchSize: number;
|
||||||
used: number;
|
used: number;
|
||||||
@@ -91,7 +89,6 @@ export function UploadForm() {
|
|||||||
return () => {
|
return () => {
|
||||||
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
|
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
|
||||||
if (logoPreviewUrl) URL.revokeObjectURL(logoPreviewUrl);
|
if (logoPreviewUrl) URL.revokeObjectURL(logoPreviewUrl);
|
||||||
if (backgroundPreviewUrl) URL.revokeObjectURL(backgroundPreviewUrl);
|
|
||||||
audioItems.forEach((a) => {
|
audioItems.forEach((a) => {
|
||||||
if (a.itemImagePreview) URL.revokeObjectURL(a.itemImagePreview);
|
if (a.itemImagePreview) URL.revokeObjectURL(a.itemImagePreview);
|
||||||
});
|
});
|
||||||
@@ -261,21 +258,6 @@ export function UploadForm() {
|
|||||||
return uploaded.path;
|
return uploaded.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleBackgroundUpload(file: File) {
|
|
||||||
const preview = URL.createObjectURL(file);
|
|
||||||
if (backgroundPreviewUrl) URL.revokeObjectURL(backgroundPreviewUrl);
|
|
||||||
setBackgroundPreviewUrl(preview);
|
|
||||||
const uploaded = await uploadFile(file, "image");
|
|
||||||
setBackgroundImagePath(uploaded.path);
|
|
||||||
return uploaded.path;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearBackgroundImage() {
|
|
||||||
if (backgroundPreviewUrl) URL.revokeObjectURL(backgroundPreviewUrl);
|
|
||||||
setBackgroundPreviewUrl(null);
|
|
||||||
setBackgroundImagePath(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyWatermarkToAll(next: WatermarkSettings) {
|
function applyWatermarkToAll(next: WatermarkSettings) {
|
||||||
const normalized = { ...DEFAULT_WATERMARK, ...next };
|
const normalized = { ...DEFAULT_WATERMARK, ...next };
|
||||||
setJobWatermark(normalized);
|
setJobWatermark(normalized);
|
||||||
@@ -294,9 +276,6 @@ export function UploadForm() {
|
|||||||
function applyLayoutToAll(next: LayoutSettings) {
|
function applyLayoutToAll(next: LayoutSettings) {
|
||||||
const normalized = { ...DEFAULT_LAYOUT, ...next };
|
const normalized = { ...DEFAULT_LAYOUT, ...next };
|
||||||
setJobLayout(normalized);
|
setJobLayout(normalized);
|
||||||
if (!isLowerCornerTemplate(normalized.template)) {
|
|
||||||
clearBackgroundImage();
|
|
||||||
}
|
|
||||||
setAudioItems((prev) =>
|
setAudioItems((prev) =>
|
||||||
prev.map((item) => ({
|
prev.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
@@ -392,10 +371,6 @@ export function UploadForm() {
|
|||||||
includeWatermark: effectiveWatermark.mode !== "none",
|
includeWatermark: effectiveWatermark.mode !== "none",
|
||||||
watermark: effectiveWatermark,
|
watermark: effectiveWatermark,
|
||||||
layout: jobLayout,
|
layout: jobLayout,
|
||||||
backgroundImagePath:
|
|
||||||
isLowerCornerTemplate(jobLayout.template) && backgroundImagePath
|
|
||||||
? backgroundImagePath
|
|
||||||
: null,
|
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
@@ -645,7 +620,6 @@ export function UploadForm() {
|
|||||||
previewImageUrl={
|
previewImageUrl={
|
||||||
audioItems.find((a) => a.itemImagePreview)?.itemImagePreview || imagePreviewUrl
|
audioItems.find((a) => a.itemImagePreview)?.itemImagePreview || imagePreviewUrl
|
||||||
}
|
}
|
||||||
previewBackgroundUrl={backgroundPreviewUrl}
|
|
||||||
songTitle={
|
songTitle={
|
||||||
audioItems[0]?.metadata.songTitle ||
|
audioItems[0]?.metadata.songTitle ||
|
||||||
audioItems[0]?.metadata.title ||
|
audioItems[0]?.metadata.title ||
|
||||||
@@ -659,9 +633,6 @@ export function UploadForm() {
|
|||||||
onWatermarkChange={applyWatermarkToAll}
|
onWatermarkChange={applyWatermarkToAll}
|
||||||
onUploadLogo={handleLogoUpload}
|
onUploadLogo={handleLogoUpload}
|
||||||
onUploadFont={handleFontUpload}
|
onUploadFont={handleFontUpload}
|
||||||
onUploadBackground={handleBackgroundUpload}
|
|
||||||
onClearBackground={clearBackgroundImage}
|
|
||||||
hasCustomBackground={Boolean(backgroundImagePath)}
|
|
||||||
logoPreviewUrl={logoPreviewUrl}
|
logoPreviewUrl={logoPreviewUrl}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -11,14 +11,9 @@ import { getVideoAttributionText } from "@/lib/branding";
|
|||||||
import {
|
import {
|
||||||
CURATED_FONTS,
|
CURATED_FONTS,
|
||||||
googleFontsStylesheetUrl,
|
googleFontsStylesheetUrl,
|
||||||
SYSTEM_FONT,
|
|
||||||
type CuratedFontKey,
|
type CuratedFontKey,
|
||||||
type WatermarkFontKey,
|
type WatermarkFontKey,
|
||||||
} from "@/lib/fonts";
|
} from "@/lib/fonts";
|
||||||
import {
|
|
||||||
curatedFontApiUrl,
|
|
||||||
previewFontFamilyCss,
|
|
||||||
} from "@/lib/preview-typography";
|
|
||||||
import {
|
import {
|
||||||
WATERMARK_OFFSET_MAX,
|
WATERMARK_OFFSET_MAX,
|
||||||
WATERMARK_OFFSET_MIN,
|
WATERMARK_OFFSET_MIN,
|
||||||
@@ -83,7 +78,10 @@ function previewStyle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function previewFontFamily(fontKey: WatermarkFontKey | undefined): string {
|
function previewFontFamily(fontKey: WatermarkFontKey | undefined): string {
|
||||||
return previewFontFamilyCss(fontKey);
|
if (!fontKey || fontKey === "system") return "ui-sans-serif, system-ui, sans-serif";
|
||||||
|
if (fontKey === "custom") return `'${CUSTOM_PREVIEW_FAMILY}', sans-serif`;
|
||||||
|
const meta = CURATED_FONTS.find((f) => f.key === fontKey);
|
||||||
|
return meta ? `'${meta.cssFamily}', sans-serif` : "sans-serif";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WatermarkPreview({
|
export function WatermarkPreview({
|
||||||
@@ -112,32 +110,7 @@ export function WatermarkPreview({
|
|||||||
[value.fontKey],
|
[value.fontKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load bundled + curated fonts for live canvas preview (same files as FFmpeg)
|
// Load curated Google Fonts for live canvas preview
|
||||||
useEffect(() => {
|
|
||||||
const styleId = "s2vid-watermark-preview-fonts";
|
|
||||||
let el = document.getElementById(styleId) as HTMLStyleElement | null;
|
|
||||||
if (!el) {
|
|
||||||
el = document.createElement("style");
|
|
||||||
el.id = styleId;
|
|
||||||
document.head.appendChild(el);
|
|
||||||
}
|
|
||||||
el.textContent = [
|
|
||||||
`@font-face {
|
|
||||||
font-family: '${SYSTEM_FONT.previewFamily}';
|
|
||||||
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}') format('truetype');
|
|
||||||
font-display: swap;
|
|
||||||
}`,
|
|
||||||
...CURATED_FONTS.map(
|
|
||||||
(f) => `@font-face {
|
|
||||||
font-family: 'S2VIDPreview-${f.key}';
|
|
||||||
src: url('${curatedFontApiUrl(f.key)}') format('truetype');
|
|
||||||
font-display: swap;
|
|
||||||
}`,
|
|
||||||
),
|
|
||||||
].join("\n");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Legacy Google Fonts link (kept for any older preview paths)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = "s2vid-watermark-google-fonts";
|
const id = "s2vid-watermark-google-fonts";
|
||||||
if (document.getElementById(id)) return;
|
if (document.getElementById(id)) return;
|
||||||
@@ -314,7 +287,7 @@ export function WatermarkPreview({
|
|||||||
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
|
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
|
||||||
className="input-field mt-1"
|
className="input-field mt-1"
|
||||||
>
|
>
|
||||||
<option value="system">{SYSTEM_FONT.label}</option>
|
<option value="system">System default</option>
|
||||||
{CURATED_FONTS.map((f) => (
|
{CURATED_FONTS.map((f) => (
|
||||||
<option key={f.key} value={f.key} style={{ fontFamily: f.cssFamily }}>
|
<option key={f.key} value={f.key} style={{ fontFamily: f.cssFamily }}>
|
||||||
{f.label}
|
{f.label}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: atakanozban/songs2vid:${S2VID_IMAGE_TAG:-latest}
|
|
||||||
build: .
|
build: .
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -50,7 +49,6 @@ services:
|
|||||||
sh -c "npx prisma db push && node server.js"
|
sh -c "npx prisma db push && node server.js"
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
image: atakanozban/songs2vid:${S2VID_IMAGE_TAG:-latest}
|
|
||||||
build: .
|
build: .
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
# Songs2VID OSS documentation
|
|
||||||
|
|
||||||
This folder ships with the OSS repo so API reference stays available without the docs site.
|
|
||||||
|
|
||||||
| Doc | Contents |
|
|
||||||
|-----|----------|
|
|
||||||
| [API overview](./api/overview.md) | Auth, rate limits, recommended flows, discovery (`layoutTemplates`, `compositionFamilies`) |
|
|
||||||
| [API endpoints](./api/endpoints.md) | curl examples for upload, jobs, layouts (incl. lower-corner templates), playlists, errors |
|
|
||||||
|
|
||||||
Live docs (same product truth for self-host): [docs.songs2vid.com](https://docs.songs2vid.com)
|
|
||||||
|
|
||||||
Setup and env: see the root [README](../README.md) and [`.env.example`](../.env.example).
|
|
||||||
@@ -1,380 +0,0 @@
|
|||||||
# Endpoints
|
|
||||||
|
|
||||||
Set these for the examples below:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export BASE_URL="http://localhost:3000" # local / self-hosted app
|
|
||||||
export API_KEY="s2yt_live_your_key_here"
|
|
||||||
```
|
|
||||||
|
|
||||||
Live HTML docs: [docs.songs2vid.com/docs/api/endpoints](https://docs.songs2vid.com/docs/api/endpoints).
|
|
||||||
|
|
||||||
## Discovery
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl "$BASE_URL/api/v1"
|
|
||||||
```
|
|
||||||
|
|
||||||
Returns the endpoint list and requirements (**no auth**). No billing routes are listed or implemented.
|
|
||||||
|
|
||||||
## Upload a file (two-step)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "$BASE_URL/api/v1/upload" \
|
|
||||||
-H "Authorization: Bearer $API_KEY" \
|
|
||||||
-F "file=@cover.jpg" \
|
|
||||||
-F "type=image"
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "$BASE_URL/api/v1/upload" \
|
|
||||||
-H "Authorization: Bearer $API_KEY" \
|
|
||||||
-F "file=@track1.mp3" \
|
|
||||||
-F "type=audio"
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Optional: PNG watermark logo
|
|
||||||
curl -X POST "$BASE_URL/api/v1/upload" \
|
|
||||||
-H "Authorization: Bearer $API_KEY" \
|
|
||||||
-F "file=@logo.png" \
|
|
||||||
-F "type=logo"
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Optional: custom font (.ttf / .otf, max 10 MB)
|
|
||||||
curl -X POST "$BASE_URL/api/v1/upload" \
|
|
||||||
-H "Authorization: Bearer $API_KEY" \
|
|
||||||
-F "file=@Brand.ttf" \
|
|
||||||
-F "type=font"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Request fields
|
|
||||||
|
|
||||||
| Field | Required | Notes |
|
|
||||||
|-------|----------|--------|
|
|
||||||
| `file` | Yes | Multipart file |
|
|
||||||
| `type` | Yes | `image` \| `audio` \| `logo` \| `font` |
|
|
||||||
|
|
||||||
### Allowed files
|
|
||||||
|
|
||||||
| `type` | Formats | Max size |
|
|
||||||
|--------|---------|----------|
|
|
||||||
| `image` | JPEG, PNG, WebP, GIF | 500 MB |
|
|
||||||
| `audio` | MP3, WAV, FLAC | 500 MB |
|
|
||||||
| `logo` | PNG only | 500 MB |
|
|
||||||
| `font` | `.ttf` / `.otf` | **10 MB** |
|
|
||||||
|
|
||||||
### Response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"path": "/uploads/.../track.mp3",
|
|
||||||
"filename": "track.mp3",
|
|
||||||
"size": 4123456,
|
|
||||||
"audioTags": {
|
|
||||||
"title": "Song Title",
|
|
||||||
"artist": "Artist Name",
|
|
||||||
"album": "Album",
|
|
||||||
"genre": "Electronic",
|
|
||||||
"year": "2024"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`audioTags` is present for MP3 when tags are readable; otherwise `null`.
|
|
||||||
|
|
||||||
## Create job / render from paths (recommended)
|
|
||||||
|
|
||||||
Self-hosted unlocks per-track covers, custom watermarks/fonts, and art-track layouts. Max batch size: **100**. Watermarks are optional — nothing forces the default Songs2VID badge.
|
|
||||||
|
|
||||||
`POST /api/v1/render` is an **n8n-friendly alias** of `POST /api/v1/jobs` (identical body and response). `GET /api/v1/render` lists jobs like `GET /api/v1/jobs`.
|
|
||||||
|
|
||||||
Optional `webhookUrl` (absolute `http(s)` URL) makes Songs2VID POST JSON when items/jobs finish — preferred for n8n.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "$BASE_URL/api/v1/render" \
|
|
||||||
-H "Authorization: Bearer $API_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"imagePath": "/uploads/.../cover.jpg",
|
|
||||||
"webhookUrl": "https://your-n8n.example/webhook/songs2vid-complete",
|
|
||||||
"items": [{
|
|
||||||
"audioPath": "/uploads/.../track.mp3",
|
|
||||||
"audioFilename": "track.mp3",
|
|
||||||
"metadata": {
|
|
||||||
"title": "My Artist - My Track (Official Audio)",
|
|
||||||
"songTitle": "My Track",
|
|
||||||
"artist": "My Artist",
|
|
||||||
"description": "",
|
|
||||||
"tags": "electronic",
|
|
||||||
"privacy": "PUBLIC",
|
|
||||||
"categoryId": "10",
|
|
||||||
"resolution": "1920x1080",
|
|
||||||
"notifySubscribers": true,
|
|
||||||
"madeForKids": false,
|
|
||||||
"embeddable": true,
|
|
||||||
"creativeCommons": false,
|
|
||||||
"includeWatermark": true,
|
|
||||||
"layout": {
|
|
||||||
"template": "LOWER_LEFT_COVER_TEXT",
|
|
||||||
"blurAmount": 60,
|
|
||||||
"blurOpacity": 85,
|
|
||||||
"textPadding": 48,
|
|
||||||
"titleArtistGap": 12,
|
|
||||||
"titleBold": true,
|
|
||||||
"textOffsetX": 0,
|
|
||||||
"textOffsetY": 0
|
|
||||||
},
|
|
||||||
"watermark": {
|
|
||||||
"mode": "default",
|
|
||||||
"fontKey": "montserrat",
|
|
||||||
"position": "bottom-right",
|
|
||||||
"offsetX": 24,
|
|
||||||
"offsetY": 24
|
|
||||||
},
|
|
||||||
"playlistId": null
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Equivalent path: `POST $BASE_URL/api/v1/jobs` with the same JSON.
|
|
||||||
|
|
||||||
### Success response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"jobId": "clxxxxxxxx",
|
|
||||||
"itemCount": 1,
|
|
||||||
"status": "PENDING",
|
|
||||||
"statusUrl": "/api/v1/jobs/clxxxxxxxx",
|
|
||||||
"webhookUrl": "https://your-n8n.example/webhook/songs2vid-complete",
|
|
||||||
"playlist": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Webhook payload
|
|
||||||
|
|
||||||
| `event` | Meaning |
|
|
||||||
|---------|---------|
|
|
||||||
| `job.item.completed` | One track finished (`youtubeVideoId` set) |
|
|
||||||
| `job.item.failed` | One track failed (`error` set) |
|
|
||||||
| `job.completed` | All items succeeded |
|
|
||||||
| `job.failed` | All items failed |
|
|
||||||
| `job.partial` | Mix of success and failure |
|
|
||||||
|
|
||||||
Community node: [`n8n-nodes-songs2vid`](https://www.npmjs.com/package/n8n-nodes-songs2vid) — see [docs/n8n.md](../n8n.md) and [docs.songs2vid.com/docs/n8n](https://docs.songs2vid.com/docs/n8n).
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Legacy example without webhook (same metadata shape)</summary>
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "$BASE_URL/api/v1/jobs" \
|
|
||||||
-H "Authorization: Bearer $API_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"imagePath": "/uploads/.../cover.jpg",
|
|
||||||
"items": [{
|
|
||||||
"audioPath": "/uploads/.../track.mp3",
|
|
||||||
"audioFilename": "track.mp3",
|
|
||||||
"metadata": {
|
|
||||||
"title": "My Artist - My Track (Official Audio)",
|
|
||||||
"songTitle": "My Track",
|
|
||||||
"artist": "My Artist",
|
|
||||||
"privacy": "PUBLIC",
|
|
||||||
"categoryId": "10",
|
|
||||||
"resolution": "1920x1080",
|
|
||||||
"includeWatermark": false
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
### Metadata fields
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|-------|------|--------|
|
|
||||||
| `title` | string | YouTube video title |
|
|
||||||
| `songTitle` | string \| null | On-video song title for art-track (max **120**) |
|
|
||||||
| `artist` | string \| null | On-video artist line (max **80**) |
|
|
||||||
| `description` | string | YouTube description |
|
|
||||||
| `tags` | string | Comma-separated |
|
|
||||||
| `privacy` | string | `PUBLIC` \| `PRIVATE` \| `UNLISTED` |
|
|
||||||
| `categoryId` | string | YouTube category ID |
|
|
||||||
| `resolution` | string | See resolutions below |
|
|
||||||
| `notifySubscribers` | boolean | YouTube notify flag |
|
|
||||||
| `madeForKids` | boolean | Made for kids |
|
|
||||||
| `embeddable` | boolean | Allow embedding |
|
|
||||||
| `creativeCommons` | boolean | CC vs standard YouTube license |
|
|
||||||
| `includeWatermark` | boolean | Apply watermark settings |
|
|
||||||
| `imagePath` | string \| null | Per-track cover |
|
|
||||||
| `backgroundImagePath` | string \| null | Lower-corner templates only: separate blur-fill image (else cover is blurred) |
|
|
||||||
| `playlistId` | string \| null | Existing playlist ID |
|
|
||||||
| `layout` | object | Art-track layout |
|
|
||||||
| `watermark` | object | Watermark settings |
|
|
||||||
|
|
||||||
Snake_case aliases are accepted for layout/watermark fields.
|
|
||||||
|
|
||||||
### Resolutions
|
|
||||||
|
|
||||||
| Value | Aspect |
|
|
||||||
|-------|--------|
|
|
||||||
| `1920x1080` | 16:9 |
|
|
||||||
| `1280x720` | 16:9 |
|
|
||||||
| `854x480` | 16:9 |
|
|
||||||
| `720x720` | 1:1 |
|
|
||||||
| `640x360` | 16:9 |
|
|
||||||
| `426x240` | 16:9 |
|
|
||||||
|
|
||||||
### YouTube categories
|
|
||||||
|
|
||||||
| ID | Name |
|
|
||||||
|----|------|
|
|
||||||
| `1` | Film & Animation |
|
|
||||||
| `2` | Autos & Vehicles |
|
|
||||||
| `10` | Music |
|
|
||||||
| `15` | Pets & Animals |
|
|
||||||
| `17` | Sports |
|
|
||||||
| `19` | Travel & Events |
|
|
||||||
| `20` | Gaming |
|
|
||||||
| `22` | People & Blogs |
|
|
||||||
| `23` | Comedy |
|
|
||||||
| `24` | Entertainment |
|
|
||||||
| `25` | News & Politics |
|
|
||||||
| `26` | Howto & Style |
|
|
||||||
| `27` | Education |
|
|
||||||
| `28` | Science & Technology |
|
|
||||||
| `29` | Nonprofits & Activism |
|
|
||||||
|
|
||||||
### Watermark fields
|
|
||||||
|
|
||||||
`watermark.mode`: `none` | `default` | `text` | `logo`
|
|
||||||
|
|
||||||
- `none` — no overlay
|
|
||||||
- `default` — built-in Songs2VID badge PNG (`assets/watermark.png`)
|
|
||||||
- `logo` — requires prior `type=logo` upload; set `logoPath`
|
|
||||||
- `text` — custom string (max **80**); set `text`
|
|
||||||
|
|
||||||
`watermark.position`: `top-left` | `top-right` | `bottom-left` | `bottom-right` | `center`
|
|
||||||
|
|
||||||
`watermark.offsetX` / `offsetY`: `0`–`200` (default `20`)
|
|
||||||
|
|
||||||
`watermark.fontKey`: `system` | `inter` | `montserrat` | `roboto` | `oswald` | `playfair` | `custom` (styles art-track text and text watermarks)
|
|
||||||
|
|
||||||
### Art-track layouts
|
|
||||||
|
|
||||||
`metadata.layout.template` (or flat `layout_template` / `layoutTemplate`). Valid enums are also listed on `GET /api/v1` as `layoutTemplates`. Mirrored pairs used by the Layout Studio composition grid are listed under `compositionFamilies` (`side`: cover beside text; `lower`: lower corner).
|
|
||||||
|
|
||||||
| Enum | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `COVER_LEFT_TEXT_RIGHT` | Cover left, title & artist right |
|
|
||||||
| `COVER_TOP_TEXT_BOTTOM` | Cover top, title & artist below |
|
|
||||||
| `COVER_RIGHT_TEXT_LEFT` | Cover right, title & artist left |
|
|
||||||
| `CENTERED_COMPACT` | Centered cover + text stack |
|
|
||||||
| `LOWER_LEFT_COVER_TEXT` | Lower-left cover; `textPadding` is equal left + bottom inset (diagonal from frame corner) with title/artist to the right |
|
|
||||||
| `LOWER_RIGHT_COVER_TEXT` | Lower-right cover; `textPadding` is equal right + bottom inset (diagonal from frame corner) with title/artist to the left |
|
|
||||||
|
|
||||||
For lower-corner templates only, optional `metadata.backgroundImagePath` (or `background_image_path`) sets a separate full-frame blur fill. Upload with `type=image` first, then pass the returned path. The cover (`imagePath` / per-item `metadata.imagePath`) stays the sharp corner square. Omit the field to blur the cover itself (default). `blurAmount` / `blurOpacity` still apply to whichever image is used as the fill.
|
|
||||||
|
|
||||||
Optional fine-tuning (clamped; camelCase or snake_case):
|
|
||||||
|
|
||||||
| Field | Range | Default | Purpose |
|
|
||||||
|-------|-------|---------|---------|
|
|
||||||
| `blurAmount` / `blur_amount` | 0–100 | 55 | Background `boxblur` intensity |
|
|
||||||
| `blurOpacity` / `blur_opacity` | 0–100 | 100 | Blurred fill vs black |
|
|
||||||
| `blurFill` / `blur_fill` | boolean | `false` | Classic letterbox only: fill bars with blurred cover (ignored for art-track templates) |
|
|
||||||
| `textPadding` / `text_padding` | 16–120 | 48 | Edge inset for cover/text. On lower-corner templates this value is applied equally on both axes (left=bottom or right=bottom) so the cover corner sits on a true diagonal from the frame corner |
|
|
||||||
| `titleArtistGap` / `title_artist_gap` | 0–64 | 10 | Space between title and artist |
|
|
||||||
| `titleBold` / `title_bold` | boolean | `true` | Bold song title (preview + FFmpeg) |
|
|
||||||
| `textOffsetX` / `text_offset_x` | −120–120 | 0 | Shift text block horizontally |
|
|
||||||
| `textOffsetY` / `text_offset_y` | −120–120 | 0 | Shift text block vertically |
|
|
||||||
|
|
||||||
Also set `metadata.songTitle` (max 120) and `metadata.artist` (max 80) for the on-video text lines. `metadata.title` remains the YouTube title.
|
|
||||||
|
|
||||||
Omit `layout.template` for classic letterbox (black-padded cover by default). Set `layout.blurFill` / `blur_fill` to `true` to fill letterbox bars with a blurred cover; then `blurAmount` / `blurOpacity` apply. Free-form cover coordinates (`x`, `y`, `coverX`, …) and layout-level `offsetX`/`offsetY` are **rejected**.
|
|
||||||
|
|
||||||
Invalid template strings return **400**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "error": "Invalid layout template. Refer to API documentation for valid enum values." }
|
|
||||||
```
|
|
||||||
|
|
||||||
## YouTube playlists
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl "$BASE_URL/api/v1/playlists" \
|
|
||||||
-H "Authorization: Bearer $API_KEY"
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "$BASE_URL/api/v1/playlists" \
|
|
||||||
-H "Authorization: Bearer $API_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"title":"My Album","description":"From Songs2VID","privacy":"unlisted"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Or pass `createPlaylist` on a job / batch body to create a playlist and attach all videos.
|
|
||||||
|
|
||||||
## One-shot batch (small packs only)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "$BASE_URL/api/v1/jobs/batch" \
|
|
||||||
-H "Authorization: Bearer $API_KEY" \
|
|
||||||
-F "image=@cover.jpg" \
|
|
||||||
-F "audio=@track1.mp3" \
|
|
||||||
-F "audio=@track2.mp3" \
|
|
||||||
-F 'metadata={"defaults":{"privacy":"PUBLIC"},"items":[{"title":"Track One"},{"title":"Track Two"}]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Prefer two-step upload + jobs for larger packs.
|
|
||||||
|
|
||||||
## Poll job status
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl "$BASE_URL/api/v1/jobs/JOB_ID" \
|
|
||||||
-H "Authorization: Bearer $API_KEY"
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl "$BASE_URL/api/v1/jobs?limit=10" \
|
|
||||||
-H "Authorization: Bearer $API_KEY"
|
|
||||||
```
|
|
||||||
|
|
||||||
`GET /api/v1/jobs` accepts `limit` (default **20**, max **100**).
|
|
||||||
|
|
||||||
### Job statuses
|
|
||||||
|
|
||||||
| Status | Meaning |
|
|
||||||
|--------|---------|
|
|
||||||
| `PENDING` | Queued |
|
|
||||||
| `PROCESSING` | Encoding or uploading |
|
|
||||||
| `COMPLETED` | All items succeeded |
|
|
||||||
| `FAILED` | All items failed |
|
|
||||||
| `PARTIAL` | Mix of completed and failed |
|
|
||||||
|
|
||||||
### Item statuses
|
|
||||||
|
|
||||||
| Status | Meaning |
|
|
||||||
|--------|---------|
|
|
||||||
| `PENDING` | Waiting |
|
|
||||||
| `ENCODING` | FFmpeg building video |
|
|
||||||
| `UPLOADING` | Uploading to YouTube |
|
|
||||||
| `COMPLETED` | Live (`youtubeVideoId` set) |
|
|
||||||
| `FAILED` | Failed (`error` message) |
|
|
||||||
|
|
||||||
YouTube channel daily upload caps are enforced by Google, not Songs2VID.
|
|
||||||
|
|
||||||
## HTTP errors
|
|
||||||
|
|
||||||
| Status | When |
|
|
||||||
|--------|------|
|
|
||||||
| `400` | Validation error |
|
|
||||||
| `401` | Missing or invalid API key |
|
|
||||||
| `403` | YouTube not connected, or request not allowed |
|
|
||||||
| `404` | Job not found |
|
|
||||||
| `429` | API rate limit exceeded |
|
|
||||||
|
|
||||||
There is no `402` payment / credits status in OSS.
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# API overview
|
|
||||||
|
|
||||||
Programmatic uploads and batch jobs for self-hosted Songs2VID. Generate your API key under **Dashboard → Settings → API key**.
|
|
||||||
|
|
||||||
Keys start with `s2yt_live_` and are shown once at creation. The OSS / Docker image always allows API use — there is no plan, credit, or paywall gate.
|
|
||||||
|
|
||||||
There are **no billing endpoints** in this edition.
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
Send the key on every request:
|
|
||||||
|
|
||||||
```http
|
|
||||||
Authorization: Bearer s2yt_live_your_key_here
|
|
||||||
```
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
|
|
||||||
- YouTube channel connected (sign in with Google OAuth that includes YouTube scopes)
|
|
||||||
|
|
||||||
OAuth setup: root [README](../../README.md) and Google’s [OAuth 2.0 for Web Server Applications](https://developers.google.com/identity/protocols/oauth2/web-server). YouTube scopes/API: [YouTube Data API Overview](https://developers.google.com/youtube/v3/getting-started).
|
|
||||||
|
|
||||||
## Rate limits
|
|
||||||
|
|
||||||
Self-hosted OSS uses a very high per-account ceiling (effectively unlimited for normal automation). You will rarely see `429`.
|
|
||||||
|
|
||||||
If a limit is hit, the response is **429** with `retryAfterSeconds` and a `Retry-After` header. See [Endpoints — HTTP errors](./endpoints.md#http-errors).
|
|
||||||
|
|
||||||
## Choosing a flow
|
|
||||||
|
|
||||||
### Recommended: two-step (especially 5+ audio files)
|
|
||||||
|
|
||||||
1. Upload each file with `POST /api/v1/upload`
|
|
||||||
2. Create the job with `POST /api/v1/render` (alias of `/api/v1/jobs`) — JSON paths + optional `webhookUrl`
|
|
||||||
|
|
||||||
This avoids huge multipart bodies. Max batch size is **100** tracks per job.
|
|
||||||
|
|
||||||
Prefer `webhookUrl` for n8n so long encodes do not block an HTTP Request node. Details: [n8n](../n8n.md).
|
|
||||||
|
|
||||||
### One-shot batch: small packs only
|
|
||||||
|
|
||||||
`POST /api/v1/jobs/batch` accepts one cover image and a few audio files in a single multipart request. Large bodies often fail with:
|
|
||||||
|
|
||||||
```text
|
|
||||||
failed to parse body as FormData
|
|
||||||
```
|
|
||||||
|
|
||||||
Prefer two-step for albums or long tracklists.
|
|
||||||
|
|
||||||
## Job lifecycle
|
|
||||||
|
|
||||||
1. Create job → status `PENDING`
|
|
||||||
2. Worker picks items → `ENCODING` → `UPLOADING` → `COMPLETED` or `FAILED`
|
|
||||||
3. Job rolls up to `COMPLETED`, `FAILED`, or `PARTIAL`
|
|
||||||
|
|
||||||
Poll with `GET /api/v1/jobs/:id`.
|
|
||||||
|
|
||||||
## Discovery
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
Returns the endpoint list, requirements, `layoutTemplates` (every art-track enum id + label, including `LOWER_LEFT_COVER_TEXT` / `LOWER_RIGHT_COVER_TEXT`), and `compositionFamilies` (UI grouping for mirrored left/right variants). No auth required.
|
|
||||||
|
|
||||||
## Next
|
|
||||||
|
|
||||||
See [Endpoints](./endpoints.md) for curl examples. n8n: [docs/n8n.md](../n8n.md). Hosted HTML docs: [docs.songs2vid.com/docs/api/overview](https://docs.songs2vid.com/docs/api/overview).
|
|
||||||
-36
@@ -1,36 +0,0 @@
|
|||||||
# n8n integration (OSS)
|
|
||||||
|
|
||||||
Automate self-hosted Songs2VID with the community package **[`n8n-nodes-songs2vid`](https://www.npmjs.com/package/n8n-nodes-songs2vid)**.
|
|
||||||
|
|
||||||
Full hosted guide: [https://docs.songs2vid.com/docs/n8n](https://docs.songs2vid.com/docs/n8n)
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
In n8n → **Settings → Community nodes** → install:
|
|
||||||
|
|
||||||
```text
|
|
||||||
n8n-nodes-songs2vid
|
|
||||||
```
|
|
||||||
|
|
||||||
Or from this repo for local development:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd integrations/n8n
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Auth
|
|
||||||
|
|
||||||
1. Dashboard → **Settings → API key** → create `s2yt_live_…`
|
|
||||||
2. n8n credential **Songs2VID API**: paste key, Base URL = your instance (e.g. `http://localhost:3000`)
|
|
||||||
|
|
||||||
OSS has **no plan gate** — API keys work whenever YouTube is connected.
|
|
||||||
|
|
||||||
## Flow
|
|
||||||
|
|
||||||
1. **Upload** cover (`type=image`) and audio (`type=audio`)
|
|
||||||
2. **Create Render** (`POST /api/v1/render`) with paths + optional `webhookUrl`
|
|
||||||
3. Handle completion via n8n **Webhook** or poll `GET /api/v1/jobs/:id`
|
|
||||||
|
|
||||||
Package source: [`integrations/n8n`](../integrations/n8n/).
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
*.tsbuildinfo
|
|
||||||
.DS_Store
|
|
||||||
*.tgz
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 Songs2VID
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# n8n-nodes-songs2vid
|
|
||||||
|
|
||||||
Official community node for **[Songs2VID](https://songs2vid.com)** — turn audio + cover art into YouTube-ready videos from [n8n](https://n8n.io/).
|
|
||||||
|
|
||||||
**Documentation:** [https://docs.songs2vid.com/docs/n8n](https://docs.songs2vid.com/docs/n8n) · in-repo [docs/n8n.md](../../docs/n8n.md)
|
|
||||||
|
|
||||||
Self-hosted OSS: set **Base URL** to your instance. API keys work without a paid plan (YouTube must be connected).
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
### From n8n (recommended)
|
|
||||||
|
|
||||||
1. Open n8n → **Settings → Community nodes**
|
|
||||||
2. **Install a community node**
|
|
||||||
3. Enter:
|
|
||||||
|
|
||||||
```text
|
|
||||||
n8n-nodes-songs2vid
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Confirm and restart n8n if prompted
|
|
||||||
5. Search the canvas for **Songs2VID**
|
|
||||||
|
|
||||||
Self-hosted n8n needs community packages enabled (`N8N_COMMUNITY_PACKAGES_ENABLED=true`).
|
|
||||||
|
|
||||||
### From npm (manual)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/.n8n
|
|
||||||
npm install n8n-nodes-songs2vid
|
|
||||||
```
|
|
||||||
|
|
||||||
Restart n8n.
|
|
||||||
|
|
||||||
## Credentials
|
|
||||||
|
|
||||||
1. Create an API key under [Dashboard → Settings](https://songs2vid.com/dashboard/settings) (`s2yt_live_…`)
|
|
||||||
2. Cloud requires **Developer & Automation** (€15) or **Enterprise**, plus YouTube connected
|
|
||||||
3. In n8n: create a **Songs2VID API** credential
|
|
||||||
- **API Key:** your token
|
|
||||||
- **Base URL:** `https://songs2vid.com` (or your self-hosted origin, no trailing slash)
|
|
||||||
|
|
||||||
Requests use `Authorization: Bearer …` only.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
1. **File → Upload** — cover (`type=image`) → save `path`
|
|
||||||
2. **File → Upload** — audio (`type=audio`) → save `path` / `filename`
|
|
||||||
3. **Render → Create Render** — paths + privacy + resolution
|
|
||||||
Optional: **Additional Fields → Webhook URL** for async completion
|
|
||||||
4. Handle `job.completed` / `job.item.completed` on an n8n **Webhook** node, or poll **Get Render Status**
|
|
||||||
|
|
||||||
## Operations
|
|
||||||
|
|
||||||
| Resource | Operation | API |
|
|
||||||
|----------|-----------|-----|
|
|
||||||
| Discovery | Get API Catalog | `GET /api/v1` |
|
|
||||||
| File | Upload | `POST /api/v1/upload` |
|
|
||||||
| Render | Create Render | `POST /api/v1/render` |
|
|
||||||
| Render | Create Job (alias) | `POST /api/v1/jobs` |
|
|
||||||
| Render | Create Batch | `POST /api/v1/jobs/batch` |
|
|
||||||
| Render | Get Render Status | `GET /api/v1/jobs/:id` |
|
|
||||||
| Render | List Renders | `GET /api/v1/jobs` |
|
|
||||||
| Playlist | List / Create | `/api/v1/playlists` |
|
|
||||||
| API Key | List / Create / Delete | `/api/v1/user/api-keys` |
|
|
||||||
|
|
||||||
Layout, watermark, playlist, and multi-track options are under **Additional Fields** on Create Render.
|
|
||||||
|
|
||||||
There is no Delete Render endpoint — finished videos live on YouTube.
|
|
||||||
|
|
||||||
## Links
|
|
||||||
|
|
||||||
- Guide: https://songs2vid.com/docs/n8n
|
|
||||||
- REST endpoints: https://songs2vid.com/docs/api/endpoints
|
|
||||||
- Workflow template: https://songs2vid.com/docs/n8n/workflow-template.json
|
|
||||||
- npm: https://www.npmjs.com/package/n8n-nodes-songs2vid
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
`prepublishOnly` runs the build before `npm publish`.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import type {
|
|
||||||
IAuthenticateGeneric,
|
|
||||||
ICredentialTestRequest,
|
|
||||||
ICredentialType,
|
|
||||||
INodeProperties,
|
|
||||||
} from "n8n-workflow";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Songs2VID REST auth — cloud accepts Authorization: Bearer only.
|
|
||||||
* Paste the raw key (s2yt_live_…) or a full "Bearer …" value.
|
|
||||||
*/
|
|
||||||
export class Songs2VidApi implements ICredentialType {
|
|
||||||
name = "songs2VidApi";
|
|
||||||
|
|
||||||
displayName = "Songs2VID API";
|
|
||||||
|
|
||||||
documentationUrl = "https://songs2vid.com/docs/n8n";
|
|
||||||
|
|
||||||
properties: INodeProperties[] = [
|
|
||||||
{
|
|
||||||
displayName: "API Key",
|
|
||||||
name: "apiKey",
|
|
||||||
type: "string",
|
|
||||||
typeOptions: { password: true },
|
|
||||||
default: "",
|
|
||||||
required: true,
|
|
||||||
description:
|
|
||||||
"Developer+ key from Dashboard → Settings (starts with s2yt_live_). Sent as Authorization: Bearer.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: "Base URL",
|
|
||||||
name: "baseUrl",
|
|
||||||
type: "string",
|
|
||||||
default: "https://songs2vid.com",
|
|
||||||
required: true,
|
|
||||||
description: "Cloud origin or self-hosted base (no trailing slash).",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
authenticate: IAuthenticateGeneric = {
|
|
||||||
type: "generic",
|
|
||||||
properties: {
|
|
||||||
headers: {
|
|
||||||
Authorization:
|
|
||||||
'={{ $credentials.apiKey.toString().startsWith("Bearer ") ? $credentials.apiKey : "Bearer " + $credentials.apiKey }}',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
test: ICredentialTestRequest = {
|
|
||||||
request: {
|
|
||||||
baseURL: "={{ $credentials.baseUrl.replace(/\\/$/, \"\") }}",
|
|
||||||
url: "/api/v1/user/api-keys",
|
|
||||||
method: "GET",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
const { src, dest } = require("gulp");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy node/credential icons (+ codex JSON) into dist so n8n can resolve
|
|
||||||
* `icon: "file:songs2vid.svg"` next to the compiled .js files.
|
|
||||||
*/
|
|
||||||
function copyIcons() {
|
|
||||||
return src(
|
|
||||||
["nodes/**/*.{png,svg,json}", "credentials/**/*.{png,svg,json}"],
|
|
||||||
{ base: "." },
|
|
||||||
).pipe(dest("dist"));
|
|
||||||
}
|
|
||||||
|
|
||||||
exports["build:icons"] = copyIcons;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
"use strict";
|
|
||||||
module.exports = {};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"node": "n8n-nodes-songs2vid.songs2Vid",
|
|
||||||
"nodeVersion": "1.0",
|
|
||||||
"codexVersion": "1.0",
|
|
||||||
"categories": ["Marketing", "Productivity"],
|
|
||||||
"resources": {
|
|
||||||
"credentialDocumentation": [
|
|
||||||
{
|
|
||||||
"url": "https://songs2vid.com/docs/n8n"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryDocumentation": [
|
|
||||||
{
|
|
||||||
"url": "https://songs2vid.com/docs/api/endpoints"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" fill="none">
|
|
||||||
<rect width="60" height="60" rx="12" fill="#0a0a0a"/>
|
|
||||||
<text x="8" y="38" font-family="Arial,sans-serif" font-size="16" font-weight="700" fill="#fff">S2</text>
|
|
||||||
<text x="30" y="38" font-family="Arial,sans-serif" font-size="16" font-weight="700" fill="#f87171">VID</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 355 B |
Generated
-5242
File diff suppressed because it is too large
Load Diff
@@ -1,56 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "n8n-nodes-songs2vid",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "n8n community node for Songs2VID — upload audio/covers, create YouTube renders, poll jobs, manage playlists & API keys",
|
|
||||||
"keywords": [
|
|
||||||
"n8n-community-node-package",
|
|
||||||
"n8n-node",
|
|
||||||
"songs2vid",
|
|
||||||
"audio-to-video",
|
|
||||||
"youtube"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"homepage": "https://songs2vid.com/docs/n8n",
|
|
||||||
"author": {
|
|
||||||
"name": "Songs2VID",
|
|
||||||
"url": "https://songs2vid.com"
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://git.atakanozban.com/Songs2VID/songs2vid.git",
|
|
||||||
"directory": "integrations/n8n"
|
|
||||||
},
|
|
||||||
"bugs": {
|
|
||||||
"url": "https://songs2vid.com/docs/n8n"
|
|
||||||
},
|
|
||||||
"main": "index.js",
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc && gulp build:icons",
|
|
||||||
"dev": "tsc --watch",
|
|
||||||
"prepublishOnly": "npm run build"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"n8n": {
|
|
||||||
"n8nNodesApiVersion": 1,
|
|
||||||
"credentials": [
|
|
||||||
"dist/credentials/Songs2VidApi.credentials.js"
|
|
||||||
],
|
|
||||||
"nodes": [
|
|
||||||
"dist/nodes/Songs2Vid/Songs2Vid.node.js"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^20.11.0",
|
|
||||||
"gulp": "^4.0.2",
|
|
||||||
"n8n-workflow": "^1.70.0",
|
|
||||||
"typescript": "^5.3.3"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"n8n-workflow": "*"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.10"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2019",
|
|
||||||
"module": "commonjs",
|
|
||||||
"lib": ["ES2019"],
|
|
||||||
"declaration": true,
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": ".",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true
|
|
||||||
},
|
|
||||||
"include": ["credentials/**/*", "nodes/**/*"],
|
|
||||||
"exclude": ["dist", "node_modules"]
|
|
||||||
}
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Songs2VID — Audio → Render → YouTube (full)",
|
|
||||||
"meta": {
|
|
||||||
"templateCredsSetupCompleted": false,
|
|
||||||
"instanceId": "songs2vid-docs-template-v2"
|
|
||||||
},
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"parameters": {},
|
|
||||||
"id": "manual-trigger",
|
|
||||||
"name": "When clicking ‘Test workflow’",
|
|
||||||
"type": "n8n-nodes-base.manualTrigger",
|
|
||||||
"typeVersion": 1,
|
|
||||||
"position": [
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"path": "songs2vid-complete",
|
|
||||||
"httpMethod": "POST",
|
|
||||||
"responseMode": "onReceived",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"id": "webhook-complete",
|
|
||||||
"name": "Songs2VID Job Webhook",
|
|
||||||
"type": "n8n-nodes-base.webhook",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
0,
|
|
||||||
360
|
|
||||||
],
|
|
||||||
"webhookId": "songs2vid-complete"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"content": "## Inputs\nProvide binary fields (`cover`, `audio`) or replace Manual Trigger with Drive/Dropbox.\n\nCredential: Header Auth → Name `Authorization`, Value `Bearer s2yt_live_…`\n\nSet env `SONGS2VID_WEBHOOK_URL` to this workflow's Production Webhook URL.",
|
|
||||||
"height": 240,
|
|
||||||
"width": 320
|
|
||||||
},
|
|
||||||
"id": "sticky-inputs",
|
|
||||||
"name": "Setup",
|
|
||||||
"type": "n8n-nodes-base.stickyNote",
|
|
||||||
"typeVersion": 1,
|
|
||||||
"position": [
|
|
||||||
-320,
|
|
||||||
-40
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"method": "POST",
|
|
||||||
"url": "={{ $env.SONGS2VID_BASE_URL || 'https://songs2vid.com' }}/api/v1/upload",
|
|
||||||
"authentication": "genericCredentialType",
|
|
||||||
"genericAuthType": "httpHeaderAuth",
|
|
||||||
"sendBody": true,
|
|
||||||
"contentType": "multipart-form-data",
|
|
||||||
"bodyParameters": {
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"parameterType": "formBinaryData",
|
|
||||||
"name": "file",
|
|
||||||
"inputDataFieldName": "cover"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "type",
|
|
||||||
"value": "image"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"id": "upload-cover",
|
|
||||||
"name": "Upload Cover Image",
|
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
|
||||||
"typeVersion": 4.2,
|
|
||||||
"position": [
|
|
||||||
280,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"credentials": {
|
|
||||||
"httpHeaderAuth": {
|
|
||||||
"id": "REPLACE_ME",
|
|
||||||
"name": "Songs2VID API Key"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"method": "POST",
|
|
||||||
"url": "={{ $env.SONGS2VID_BASE_URL || 'https://songs2vid.com' }}/api/v1/upload",
|
|
||||||
"authentication": "genericCredentialType",
|
|
||||||
"genericAuthType": "httpHeaderAuth",
|
|
||||||
"sendBody": true,
|
|
||||||
"contentType": "multipart-form-data",
|
|
||||||
"bodyParameters": {
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"parameterType": "formBinaryData",
|
|
||||||
"name": "file",
|
|
||||||
"inputDataFieldName": "audio"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "type",
|
|
||||||
"value": "audio"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"id": "upload-audio",
|
|
||||||
"name": "Upload Audio",
|
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
|
||||||
"typeVersion": 4.2,
|
|
||||||
"position": [
|
|
||||||
520,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"credentials": {
|
|
||||||
"httpHeaderAuth": {
|
|
||||||
"id": "REPLACE_ME",
|
|
||||||
"name": "Songs2VID API Key"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"method": "POST",
|
|
||||||
"url": "={{ $env.SONGS2VID_BASE_URL || 'https://songs2vid.com' }}/api/v1/render",
|
|
||||||
"authentication": "genericCredentialType",
|
|
||||||
"genericAuthType": "httpHeaderAuth",
|
|
||||||
"sendBody": true,
|
|
||||||
"specifyBody": "json",
|
|
||||||
"jsonBody": "={\n \"imagePath\": \"{{ $('Upload Cover Image').item.json.path }}\",\n \"webhookUrl\": \"{{ $env.SONGS2VID_WEBHOOK_URL || '' }}\",\n \"items\": [{\n \"audioPath\": \"{{ $('Upload Audio').item.json.path }}\",\n \"audioFilename\": \"{{ $('Upload Audio').item.json.filename }}\",\n \"metadata\": {\n \"title\": \"{{ $('Upload Audio').item.json.audioTags?.title || $('Upload Audio').item.json.filename }}\",\n \"songTitle\": \"{{ $('Upload Audio').item.json.audioTags?.title || '' }}\",\n \"artist\": \"{{ $('Upload Audio').item.json.audioTags?.artist || '' }}\",\n \"description\": \"Uploaded via n8n + Songs2VID\",\n \"tags\": \"music,songs2vid\",\n \"privacy\": \"UNLISTED\",\n \"categoryId\": \"10\",\n \"resolution\": \"1920x1080\",\n \"notifySubscribers\": false,\n \"madeForKids\": false,\n \"embeddable\": true,\n \"creativeCommons\": false,\n \"includeWatermark\": false\n }\n }]\n}",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"id": "create-render",
|
|
||||||
"name": "Create Render",
|
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
|
||||||
"typeVersion": 4.2,
|
|
||||||
"position": [
|
|
||||||
760,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"credentials": {
|
|
||||||
"httpHeaderAuth": {
|
|
||||||
"id": "REPLACE_ME",
|
|
||||||
"name": "Songs2VID API Key"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"method": "GET",
|
|
||||||
"url": "={{ $env.SONGS2VID_BASE_URL || 'https://songs2vid.com' }}{{ $json.statusUrl }}",
|
|
||||||
"authentication": "genericCredentialType",
|
|
||||||
"genericAuthType": "httpHeaderAuth",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"id": "poll-status",
|
|
||||||
"name": "Get Render Status (optional poll)",
|
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
|
||||||
"typeVersion": 4.2,
|
|
||||||
"position": [
|
|
||||||
1000,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"credentials": {
|
|
||||||
"httpHeaderAuth": {
|
|
||||||
"id": "REPLACE_ME",
|
|
||||||
"name": "Songs2VID API Key"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"disabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"conditions": {
|
|
||||||
"options": {
|
|
||||||
"caseSensitive": true,
|
|
||||||
"leftValue": "",
|
|
||||||
"typeValidation": "strict"
|
|
||||||
},
|
|
||||||
"conditions": [
|
|
||||||
{
|
|
||||||
"id": "job-done",
|
|
||||||
"leftValue": "={{ $json.body?.event || $json.event }}",
|
|
||||||
"rightValue": "job.completed",
|
|
||||||
"operator": {
|
|
||||||
"type": "string",
|
|
||||||
"operation": "equals"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "item-done",
|
|
||||||
"leftValue": "={{ $json.body?.event || $json.event }}",
|
|
||||||
"rightValue": "job.item.completed",
|
|
||||||
"operator": {
|
|
||||||
"type": "string",
|
|
||||||
"operation": "equals"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"combinator": "or"
|
|
||||||
},
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"id": "if-completed",
|
|
||||||
"name": "Render Completed?",
|
|
||||||
"type": "n8n-nodes-base.if",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
280,
|
|
||||||
360
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"conditions": {
|
|
||||||
"options": {
|
|
||||||
"caseSensitive": true,
|
|
||||||
"leftValue": "",
|
|
||||||
"typeValidation": "strict"
|
|
||||||
},
|
|
||||||
"conditions": [
|
|
||||||
{
|
|
||||||
"id": "job-fail",
|
|
||||||
"leftValue": "={{ $json.body?.event || $json.event }}",
|
|
||||||
"rightValue": "job.failed",
|
|
||||||
"operator": {
|
|
||||||
"type": "string",
|
|
||||||
"operation": "equals"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "item-fail",
|
|
||||||
"leftValue": "={{ $json.body?.event || $json.event }}",
|
|
||||||
"rightValue": "job.item.failed",
|
|
||||||
"operator": {
|
|
||||||
"type": "string",
|
|
||||||
"operation": "equals"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"combinator": "or"
|
|
||||||
},
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"id": "if-failed",
|
|
||||||
"name": "Render Failed?",
|
|
||||||
"type": "n8n-nodes-base.if",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
280,
|
|
||||||
560
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"content": "## YouTube live\nVideo ID: `{{ $json.body?.youtubeVideoId || $json.youtubeVideoId }}`\n\nhttps://youtu.be/{{ $json.body?.youtubeVideoId || $json.youtubeVideoId }}",
|
|
||||||
"height": 180,
|
|
||||||
"width": 360
|
|
||||||
},
|
|
||||||
"id": "sticky-success",
|
|
||||||
"name": "Success",
|
|
||||||
"type": "n8n-nodes-base.stickyNote",
|
|
||||||
"typeVersion": 1,
|
|
||||||
"position": [
|
|
||||||
560,
|
|
||||||
320
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"content": "## Failed\n`{{ $json.body?.error || $json.error }}`",
|
|
||||||
"height": 160,
|
|
||||||
"width": 360
|
|
||||||
},
|
|
||||||
"id": "sticky-fail",
|
|
||||||
"name": "Failure",
|
|
||||||
"type": "n8n-nodes-base.stickyNote",
|
|
||||||
"typeVersion": 1,
|
|
||||||
"position": [
|
|
||||||
560,
|
|
||||||
520
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"connections": {
|
|
||||||
"When clicking ‘Test workflow’": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Upload Cover Image",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Upload Cover Image": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Upload Audio",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Upload Audio": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Create Render",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Create Render": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Get Render Status (optional poll)",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Songs2VID Job Webhook": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Render Completed?",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Render Completed?": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Success",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Render Failed?",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Render Failed?": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Failure",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pinData": {},
|
|
||||||
"settings": {
|
|
||||||
"executionOrder": "v1"
|
|
||||||
},
|
|
||||||
"staticData": null,
|
|
||||||
"tags": [
|
|
||||||
{
|
|
||||||
"name": "songs2vid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "n8n"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "youtube"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"triggerCount": 0,
|
|
||||||
"updatedAt": "2026-08-08T00:00:00.000Z",
|
|
||||||
"versionId": "2"
|
|
||||||
}
|
|
||||||
+16
-134
@@ -8,12 +8,9 @@ import {
|
|||||||
isCuratedFontKey,
|
isCuratedFontKey,
|
||||||
sanitizeFontfileForFilter,
|
sanitizeFontfileForFilter,
|
||||||
} from "../fonts";
|
} from "../fonts";
|
||||||
import { resolveCuratedFontPath, resolveSystemFontPath, assertFontFile } from "../fonts-server";
|
import { resolveCuratedFontPath } from "../fonts-server";
|
||||||
import {
|
import {
|
||||||
buildArtTrackFilterComplex,
|
buildArtTrackFilterComplex,
|
||||||
buildClassicBlurFillFilterComplex,
|
|
||||||
isLowerCornerTemplate,
|
|
||||||
normalizeLayoutSettings,
|
|
||||||
type LayoutSettings,
|
type LayoutSettings,
|
||||||
} from "../layout";
|
} from "../layout";
|
||||||
import {
|
import {
|
||||||
@@ -64,26 +61,9 @@ export async function assertPngFile(filePath: string): Promise<void> {
|
|||||||
|
|
||||||
async function resolveFontfileEscaped(
|
async function resolveFontfileEscaped(
|
||||||
settings: WatermarkSettings,
|
settings: WatermarkSettings,
|
||||||
weight: "regular" | "bold" = "regular",
|
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const key = settings.fontKey ?? "system";
|
const key = settings.fontKey ?? "system";
|
||||||
if (key === "system") {
|
if (key === "system") return null;
|
||||||
const preferred = resolveSystemFontPath(weight);
|
|
||||||
const fallback = weight === "bold" ? resolveSystemFontPath("regular") : preferred;
|
|
||||||
for (const fontPath of weight === "bold" ? [preferred, fallback] : [preferred]) {
|
|
||||||
if (!(await fileExists(fontPath))) continue;
|
|
||||||
try {
|
|
||||||
await assertFontFile(fontPath);
|
|
||||||
return sanitizeFontfileForFilter(fontPath);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(
|
|
||||||
`[ffmpeg] system font invalid at ${fontPath}: ${err instanceof Error ? err.message : err}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.warn(`[ffmpeg] system font missing; using FFmpeg default`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === "custom") {
|
if (key === "custom") {
|
||||||
if (!settings.fontPath) return null;
|
if (!settings.fontPath) return null;
|
||||||
@@ -94,19 +74,12 @@ async function resolveFontfileEscaped(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isCuratedFontKey(key)) {
|
if (isCuratedFontKey(key)) {
|
||||||
const preferred = resolveCuratedFontPath(key, weight);
|
const fontPath = resolveCuratedFontPath(key);
|
||||||
const fallback = weight === "bold" ? resolveCuratedFontPath(key, "regular") : preferred;
|
if (!(await fileExists(fontPath))) {
|
||||||
for (const fontPath of weight === "bold" && preferred !== fallback ? [preferred, fallback] : [preferred]) {
|
console.warn(`[ffmpeg] curated font missing: ${key} at ${fontPath}; using system font`);
|
||||||
if (!(await fileExists(fontPath))) continue;
|
return null;
|
||||||
try {
|
|
||||||
await assertFontFile(fontPath);
|
|
||||||
return sanitizeFontfileForFilter(fontPath);
|
|
||||||
} catch {
|
|
||||||
/* try next */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
console.warn(`[ffmpeg] curated font missing: ${key}; using system font`);
|
return sanitizeFontfileForFilter(fontPath);
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -116,38 +89,6 @@ function classicScaleFilter(width: number, height: number): string {
|
|||||||
return `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`;
|
return `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Plan-aware audio for MP4.
|
|
||||||
* OSS / self-hosted defaults to Pro-quality 320k AAC.
|
|
||||||
*/
|
|
||||||
export type AudioEncodeOptions = {
|
|
||||||
bitrateKbps: 192 | 320;
|
|
||||||
copyWhenSafe: boolean;
|
|
||||||
audioPath: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isCopySafeAudioPath(audioPath: string): boolean {
|
|
||||||
const ext = path.extname(audioPath).toLowerCase();
|
|
||||||
return ext === ".aac" || ext === ".m4a" || ext === ".mp4";
|
|
||||||
}
|
|
||||||
|
|
||||||
function pushAudioEncodeArgs(args: string[], audio: AudioEncodeOptions): void {
|
|
||||||
if (audio.copyWhenSafe && isCopySafeAudioPath(audio.audioPath)) {
|
|
||||||
args.push("-c:a", "copy");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
args.push(
|
|
||||||
"-c:a",
|
|
||||||
"aac",
|
|
||||||
"-b:a",
|
|
||||||
`${audio.bitrateKbps}k`,
|
|
||||||
"-ar",
|
|
||||||
"44100",
|
|
||||||
"-ac",
|
|
||||||
"2",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Art-track filter that ends at `outLabel` instead of hardcoded [laid].
|
* Art-track filter that ends at `outLabel` instead of hardcoded [laid].
|
||||||
*/
|
*/
|
||||||
@@ -158,13 +99,6 @@ function artTrackFilterEndingAt(
|
|||||||
return buildArtTrackFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`);
|
return buildArtTrackFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function classicBlurFillEndingAt(
|
|
||||||
opts: Parameters<typeof buildClassicBlurFillFilterComplex>[0],
|
|
||||||
outLabel: string,
|
|
||||||
): string {
|
|
||||||
return buildClassicBlurFillFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function encodeVideo(options: {
|
export async function encodeVideo(options: {
|
||||||
imagePath: string;
|
imagePath: string;
|
||||||
audioPath: string;
|
audioPath: string;
|
||||||
@@ -176,60 +110,24 @@ export async function encodeVideo(options: {
|
|||||||
/** On-video song title (art-track layouts). */
|
/** On-video song title (art-track layouts). */
|
||||||
songTitle?: string;
|
songTitle?: string;
|
||||||
artist?: string | null;
|
artist?: string | null;
|
||||||
/**
|
|
||||||
* Optional full-frame background for lower-corner templates.
|
|
||||||
* When set with LOWER_LEFT / LOWER_RIGHT, blur fill uses this image; cover stays sharp.
|
|
||||||
*/
|
|
||||||
backgroundImagePath?: string | null;
|
|
||||||
/** Defaults to 320k (self-hosted / studio). */
|
|
||||||
audioBitrateKbps?: 192 | 320;
|
|
||||||
audioCopyWhenSafe?: boolean;
|
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const res = getResolution(options.resolution);
|
const res = getResolution(options.resolution);
|
||||||
if (!res) throw new Error(`Invalid resolution: ${options.resolution}`);
|
if (!res) throw new Error(`Invalid resolution: ${options.resolution}`);
|
||||||
|
|
||||||
const audioEncode: AudioEncodeOptions = {
|
|
||||||
bitrateKbps: options.audioBitrateKbps ?? 320,
|
|
||||||
copyWhenSafe: options.audioCopyWhenSafe ?? true,
|
|
||||||
audioPath: options.audioPath,
|
|
||||||
};
|
|
||||||
|
|
||||||
await fs.mkdir(path.dirname(options.outputPath), { recursive: true });
|
await fs.mkdir(path.dirname(options.outputPath), { recursive: true });
|
||||||
|
|
||||||
const settings = normalizeWatermarkSettings(options.watermark, options.includeWatermark);
|
const settings = normalizeWatermarkSettings(options.watermark, options.includeWatermark);
|
||||||
const layoutSettings = options.layout
|
const layout = options.layout?.template ? options.layout : null;
|
||||||
? normalizeLayoutSettings(options.layout)
|
|
||||||
: 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 watermarkWidth = Math.max(1, Math.round(res.width * WATERMARK_WIDTH_FRACTION));
|
||||||
const fontSize = watermarkFontSizeForWidth(res.width);
|
const fontSize = watermarkFontSizeForWidth(res.width);
|
||||||
const fontfile = await resolveFontfileEscaped(settings, "regular");
|
const fontfile = await resolveFontfileEscaped(settings);
|
||||||
const titleFontfile =
|
|
||||||
layout?.titleBold ? await resolveFontfileEscaped(settings, "bold") : null;
|
|
||||||
// Only pass a distinct bold face when it differs from regular (avoids faux-bold when bold TTF exists).
|
|
||||||
const titleFontfileDistinct =
|
|
||||||
titleFontfile && titleFontfile !== fontfile ? titleFontfile : null;
|
|
||||||
|
|
||||||
const args = ["-y", "-loop", "1", "-r", "1", "-i", options.imagePath, "-i", options.audioPath];
|
const args = ["-y", "-loop", "1", "-r", "1", "-i", options.imagePath, "-i", options.audioPath];
|
||||||
|
|
||||||
let nextInput = 2;
|
let nextInput = 2;
|
||||||
let separateBackgroundInputIndex: number | null = null;
|
|
||||||
let logoInputIndex: number | null = null;
|
let logoInputIndex: number | null = null;
|
||||||
let defaultWmInputIndex: number | null = null;
|
let defaultWmInputIndex: number | null = null;
|
||||||
|
|
||||||
const useSeparateBackground =
|
|
||||||
Boolean(layout && isLowerCornerTemplate(layout.template) && options.backgroundImagePath);
|
|
||||||
|
|
||||||
if (useSeparateBackground && options.backgroundImagePath) {
|
|
||||||
if (!(await fileExists(options.backgroundImagePath))) {
|
|
||||||
throw new Error("Background image file not found");
|
|
||||||
}
|
|
||||||
args.push("-loop", "1", "-r", "1", "-i", options.backgroundImagePath);
|
|
||||||
separateBackgroundInputIndex = nextInput++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const needsLogo = settings.mode === "logo" && Boolean(settings.logoPath);
|
const needsLogo = settings.mode === "logo" && Boolean(settings.logoPath);
|
||||||
const defaultPath = getWatermarkPath();
|
const defaultPath = getWatermarkPath();
|
||||||
const useDefaultPng =
|
const useDefaultPng =
|
||||||
@@ -258,17 +156,16 @@ export async function encodeVideo(options: {
|
|||||||
? ("default-text" as const)
|
? ("default-text" as const)
|
||||||
: ("none" as const);
|
: ("none" as const);
|
||||||
|
|
||||||
// Fast path: classic letterbox (black bars), no watermark, no blur fill
|
// Fast path: classic letterbox, no watermark
|
||||||
if (!layout && !classicBlurFill && applyWm === "none") {
|
if (!layout && applyWm === "none") {
|
||||||
args.push("-vf", classicScaleFilter(res.width, res.height));
|
args.push("-vf", classicScaleFilter(res.width, res.height));
|
||||||
args.push(
|
args.push(
|
||||||
"-c:v",
|
"-c:v",
|
||||||
"libx264",
|
"libx264",
|
||||||
"-tune",
|
"-tune",
|
||||||
"stillimage",
|
"stillimage",
|
||||||
);
|
"-c:a",
|
||||||
pushAudioEncodeArgs(args, audioEncode);
|
"copy",
|
||||||
args.push(
|
|
||||||
"-shortest",
|
"-shortest",
|
||||||
"-pix_fmt",
|
"-pix_fmt",
|
||||||
"yuv420p",
|
"yuv420p",
|
||||||
@@ -295,20 +192,6 @@ export async function encodeVideo(options: {
|
|||||||
titleEscaped,
|
titleEscaped,
|
||||||
artistEscaped,
|
artistEscaped,
|
||||||
fontfileEscaped: fontfile,
|
fontfileEscaped: fontfile,
|
||||||
titleFontfileEscaped: titleFontfileDistinct,
|
|
||||||
separateBackgroundInputIndex,
|
|
||||||
},
|
|
||||||
baseLabel,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (classicBlurFill && layoutSettings) {
|
|
||||||
filterParts.push(
|
|
||||||
classicBlurFillEndingAt(
|
|
||||||
{
|
|
||||||
width: res.width,
|
|
||||||
height: res.height,
|
|
||||||
blurAmount: layoutSettings.blurAmount,
|
|
||||||
blurOpacity: layoutSettings.blurOpacity,
|
|
||||||
},
|
},
|
||||||
baseLabel,
|
baseLabel,
|
||||||
),
|
),
|
||||||
@@ -352,7 +235,7 @@ export async function encodeVideo(options: {
|
|||||||
position: settings.position,
|
position: settings.position,
|
||||||
offsetX: settings.offsetX,
|
offsetX: settings.offsetX,
|
||||||
offsetY: settings.offsetY,
|
offsetY: settings.offsetY,
|
||||||
fontfileEscaped: fontfile,
|
fontfileEscaped: null,
|
||||||
});
|
});
|
||||||
filterParts.push(`[${baseLabel}]${draw}[vout]`);
|
filterParts.push(`[${baseLabel}]${draw}[vout]`);
|
||||||
}
|
}
|
||||||
@@ -363,9 +246,8 @@ export async function encodeVideo(options: {
|
|||||||
"libx264",
|
"libx264",
|
||||||
"-tune",
|
"-tune",
|
||||||
"stillimage",
|
"stillimage",
|
||||||
);
|
"-c:a",
|
||||||
pushAudioEncodeArgs(args, audioEncode);
|
"copy",
|
||||||
args.push(
|
|
||||||
"-shortest",
|
"-shortest",
|
||||||
"-pix_fmt",
|
"-pix_fmt",
|
||||||
"yuv420p",
|
"yuv420p",
|
||||||
|
|||||||
+4
-17
@@ -2,32 +2,19 @@
|
|||||||
|
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { CURATED_FONTS, SYSTEM_FONT, type CuratedFontKey } from "./fonts";
|
import { CURATED_FONTS, type CuratedFontKey } from "./fonts";
|
||||||
|
|
||||||
export function getFontsDir(): string {
|
export function getFontsDir(): string {
|
||||||
return path.join(process.cwd(), "assets", "fonts");
|
return path.join(process.cwd(), "assets", "fonts");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the bundled system (Arimo) font for FFmpeg + preview. */
|
|
||||||
export function resolveSystemFontPath(weight: "regular" | "bold" = "regular"): string {
|
|
||||||
const file = weight === "bold" ? SYSTEM_FONT.boldFile : SYSTEM_FONT.file;
|
|
||||||
const safe = file.replace(/[^a-zA-Z0-9._-]/g, "");
|
|
||||||
if (safe !== file) throw new Error("Invalid font asset name");
|
|
||||||
return path.join(getFontsDir(), safe);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolve a curated font file on disk (must exist under assets/fonts). */
|
/** Resolve a curated font file on disk (must exist under assets/fonts). */
|
||||||
export function resolveCuratedFontPath(
|
export function resolveCuratedFontPath(key: CuratedFontKey): string {
|
||||||
key: CuratedFontKey,
|
|
||||||
weight: "regular" | "bold" = "regular",
|
|
||||||
): string {
|
|
||||||
const meta = CURATED_FONTS.find((f) => f.key === key);
|
const meta = CURATED_FONTS.find((f) => f.key === key);
|
||||||
if (!meta) throw new Error("Unknown font");
|
if (!meta) throw new Error("Unknown font");
|
||||||
const file =
|
|
||||||
weight === "bold" && meta.boldFile ? meta.boldFile : meta.file;
|
|
||||||
// Whitelist filename only never accept user-controlled path segments
|
// Whitelist filename only never accept user-controlled path segments
|
||||||
const safe = file.replace(/[^a-zA-Z0-9._-]/g, "");
|
const safe = meta.file.replace(/[^a-zA-Z0-9._-]/g, "");
|
||||||
if (safe !== file) throw new Error("Invalid font asset name");
|
if (safe !== meta.file) throw new Error("Invalid font asset name");
|
||||||
return path.join(getFontsDir(), safe);
|
return path.join(getFontsDir(), safe);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,6 @@
|
|||||||
/** Max custom font upload size (10 MB). */
|
/** Max custom font upload size (10 MB). */
|
||||||
export const FONT_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
|
export const FONT_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
/** Bundled default font for preview + FFmpeg parity (Apache 2.0, Google Arimo). */
|
|
||||||
export const SYSTEM_FONT = {
|
|
||||||
key: "system",
|
|
||||||
label: "Arimo",
|
|
||||||
file: "Arimo-Regular.ttf",
|
|
||||||
boldFile: "Arimo-Bold.ttf",
|
|
||||||
previewFamily: "S2VIDPreview-system",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const CURATED_FONTS = [
|
export const CURATED_FONTS = [
|
||||||
{
|
{
|
||||||
key: "inter",
|
key: "inter",
|
||||||
@@ -19,7 +10,6 @@ export const CURATED_FONTS = [
|
|||||||
cssFamily: "Inter",
|
cssFamily: "Inter",
|
||||||
googleCss: "Inter:wght@400;600",
|
googleCss: "Inter:wght@400;600",
|
||||||
file: "Inter-Regular.ttf",
|
file: "Inter-Regular.ttf",
|
||||||
boldFile: null as string | null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "montserrat",
|
key: "montserrat",
|
||||||
@@ -27,7 +17,6 @@ export const CURATED_FONTS = [
|
|||||||
cssFamily: "Montserrat",
|
cssFamily: "Montserrat",
|
||||||
googleCss: "Montserrat:wght@400;600",
|
googleCss: "Montserrat:wght@400;600",
|
||||||
file: "Montserrat-Regular.ttf",
|
file: "Montserrat-Regular.ttf",
|
||||||
boldFile: null as string | null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "roboto",
|
key: "roboto",
|
||||||
@@ -35,7 +24,6 @@ export const CURATED_FONTS = [
|
|||||||
cssFamily: "Roboto",
|
cssFamily: "Roboto",
|
||||||
googleCss: "Roboto:wght@400;500",
|
googleCss: "Roboto:wght@400;500",
|
||||||
file: "Roboto-Regular.ttf",
|
file: "Roboto-Regular.ttf",
|
||||||
boldFile: null as string | null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "oswald",
|
key: "oswald",
|
||||||
@@ -43,7 +31,6 @@ export const CURATED_FONTS = [
|
|||||||
cssFamily: "Oswald",
|
cssFamily: "Oswald",
|
||||||
googleCss: "Oswald:wght@400;500",
|
googleCss: "Oswald:wght@400;500",
|
||||||
file: "Oswald-Regular.ttf",
|
file: "Oswald-Regular.ttf",
|
||||||
boldFile: null as string | null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "playfair",
|
key: "playfair",
|
||||||
@@ -51,7 +38,6 @@ export const CURATED_FONTS = [
|
|||||||
cssFamily: "Playfair Display",
|
cssFamily: "Playfair Display",
|
||||||
googleCss: "Playfair+Display:wght@400;600",
|
googleCss: "Playfair+Display:wght@400;600",
|
||||||
file: "PlayfairDisplay-Regular.ttf",
|
file: "PlayfairDisplay-Regular.ttf",
|
||||||
boldFile: null as string | null,
|
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
+13
-76
@@ -16,7 +16,6 @@ import { moveFile, writeUploadedFile } from "../fs-utils";
|
|||||||
import {
|
import {
|
||||||
ARTIST_MAX,
|
ARTIST_MAX,
|
||||||
INVALID_LAYOUT_TEMPLATE_MESSAGE,
|
INVALID_LAYOUT_TEMPLATE_MESSAGE,
|
||||||
isLowerCornerTemplate,
|
|
||||||
normalizeLayoutSettings,
|
normalizeLayoutSettings,
|
||||||
SONG_TITLE_MAX,
|
SONG_TITLE_MAX,
|
||||||
type LayoutSettings,
|
type LayoutSettings,
|
||||||
@@ -57,11 +56,6 @@ export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSetting
|
|||||||
metadata.layout?.blur_amount ??
|
metadata.layout?.blur_amount ??
|
||||||
metadata.blurAmount ??
|
metadata.blurAmount ??
|
||||||
metadata.blur_amount,
|
metadata.blur_amount,
|
||||||
blurFill:
|
|
||||||
metadata.layout?.blurFill ??
|
|
||||||
(metadata.layout as { blur_fill?: boolean } | null | undefined)?.blur_fill ??
|
|
||||||
metadata.blurFill ??
|
|
||||||
metadata.blur_fill,
|
|
||||||
blurOpacity:
|
blurOpacity:
|
||||||
metadata.layout?.blurOpacity ??
|
metadata.layout?.blurOpacity ??
|
||||||
(metadata.layout as { blur_opacity?: number } | null | undefined)?.blur_opacity ??
|
(metadata.layout as { blur_opacity?: number } | null | undefined)?.blur_opacity ??
|
||||||
@@ -87,11 +81,6 @@ export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSetting
|
|||||||
(metadata.layout as { text_offset_y?: number } | null | undefined)?.text_offset_y ??
|
(metadata.layout as { text_offset_y?: number } | null | undefined)?.text_offset_y ??
|
||||||
metadata.textOffsetY ??
|
metadata.textOffsetY ??
|
||||||
metadata.text_offset_y,
|
metadata.text_offset_y,
|
||||||
titleBold:
|
|
||||||
metadata.layout?.titleBold ??
|
|
||||||
(metadata.layout as { title_bold?: boolean } | null | undefined)?.title_bold ??
|
|
||||||
metadata.titleBold ??
|
|
||||||
metadata.title_bold,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,18 +180,6 @@ export async function validateJobPayload(user: { id: string }, body: CreateJobPa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const layoutForBg = resolveLayoutFromMetadata(item.metadata);
|
|
||||||
const bgRaw =
|
|
||||||
item.metadata.backgroundImagePath ?? item.metadata.background_image_path ?? null;
|
|
||||||
if (bgRaw && isLowerCornerTemplate(layoutForBg.template)) {
|
|
||||||
const bgPath = assertPathInUserUploads(user.id, bgRaw);
|
|
||||||
await fs.access(bgPath);
|
|
||||||
const st = await fs.stat(bgPath);
|
|
||||||
if (st.size > limits.maxFileSizeBytes) {
|
|
||||||
return `Background image for ${item.audioFilename} exceeds size limit`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const wm = normalizeWatermarkSettings(
|
const wm = normalizeWatermarkSettings(
|
||||||
item.metadata.watermark,
|
item.metadata.watermark,
|
||||||
item.metadata.includeWatermark,
|
item.metadata.includeWatermark,
|
||||||
@@ -265,51 +242,28 @@ export async function createVideoJob(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
|
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
|
||||||
const items = body.items.map((item) => {
|
const items = body.items.map((item) => ({
|
||||||
const layout = resolveLayoutFromMetadata(item.metadata);
|
...item,
|
||||||
const bgRaw =
|
audioPath: assertPathInUserUploads(user.id, item.audioPath),
|
||||||
item.metadata.backgroundImagePath ?? item.metadata.background_image_path ?? null;
|
itemImagePath: item.metadata.imagePath
|
||||||
const backgroundImagePath =
|
? assertPathInUserUploads(user.id, item.metadata.imagePath)
|
||||||
bgRaw && isLowerCornerTemplate(layout.template)
|
: null,
|
||||||
? assertPathInUserUploads(user.id, bgRaw)
|
watermarkLogoPath: item.metadata.watermark?.logoPath
|
||||||
: null;
|
? assertPathInUserUploads(user.id, item.metadata.watermark.logoPath)
|
||||||
return {
|
: null,
|
||||||
...item,
|
watermarkFontPath:
|
||||||
audioPath: assertPathInUserUploads(user.id, item.audioPath),
|
item.metadata.watermark?.fontKey === "custom" && item.metadata.watermark?.fontPath
|
||||||
itemImagePath: item.metadata.imagePath
|
? assertPathInUserUploads(user.id, item.metadata.watermark.fontPath)
|
||||||
? assertPathInUserUploads(user.id, item.metadata.imagePath)
|
|
||||||
: null,
|
: null,
|
||||||
backgroundImagePath,
|
}));
|
||||||
watermarkLogoPath: item.metadata.watermark?.logoPath
|
|
||||||
? assertPathInUserUploads(user.id, item.metadata.watermark.logoPath)
|
|
||||||
: null,
|
|
||||||
watermarkFontPath:
|
|
||||||
item.metadata.watermark?.fontKey === "custom" && item.metadata.watermark?.fontPath
|
|
||||||
? assertPathInUserUploads(user.id, item.metadata.watermark.fontPath)
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
await reserveQuota(user.id, items.length);
|
await reserveQuota(user.id, items.length);
|
||||||
|
|
||||||
const webhookRaw = body.webhookUrl?.trim() || null;
|
|
||||||
if (webhookRaw) {
|
|
||||||
try {
|
|
||||||
const u = new URL(webhookRaw);
|
|
||||||
if (u.protocol !== "https:" && u.protocol !== "http:") {
|
|
||||||
throw new Error("webhookUrl must be http(s)");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
throw new Error("Invalid webhookUrl. Provide an absolute http(s) URL for n8n callbacks.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const job = await prisma.job.create({
|
const job = await prisma.job.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
imagePath,
|
imagePath,
|
||||||
webhookUrl: webhookRaw,
|
|
||||||
items: {
|
items: {
|
||||||
create: items.map((item, index) => {
|
create: items.map((item, index) => {
|
||||||
const wm = applyBrandWatermarkPolicy(
|
const wm = applyBrandWatermarkPolicy(
|
||||||
@@ -344,7 +298,6 @@ export async function createVideoJob(
|
|||||||
creativeCommons: item.metadata.creativeCommons,
|
creativeCommons: item.metadata.creativeCommons,
|
||||||
includeWatermark: wm.mode !== "none",
|
includeWatermark: wm.mode !== "none",
|
||||||
itemImagePath: item.itemImagePath,
|
itemImagePath: item.itemImagePath,
|
||||||
backgroundImagePath: item.backgroundImagePath,
|
|
||||||
watermarkMode: wm.mode,
|
watermarkMode: wm.mode,
|
||||||
watermarkText: wm.mode === "text" ? wm.text?.trim() || null : null,
|
watermarkText: wm.mode === "text" ? wm.text?.trim() || null : null,
|
||||||
watermarkLogoPath: wm.mode === "logo" ? item.watermarkLogoPath : null,
|
watermarkLogoPath: wm.mode === "logo" ? item.watermarkLogoPath : null,
|
||||||
@@ -356,12 +309,10 @@ export async function createVideoJob(
|
|||||||
watermarkOffsetY: wm.offsetY,
|
watermarkOffsetY: wm.offsetY,
|
||||||
artist: item.metadata.artist?.trim().slice(0, ARTIST_MAX) || null,
|
artist: item.metadata.artist?.trim().slice(0, ARTIST_MAX) || null,
|
||||||
layoutTemplate: layout.template,
|
layoutTemplate: layout.template,
|
||||||
blurFill: layout.blurFill,
|
|
||||||
blurAmount: layout.blurAmount,
|
blurAmount: layout.blurAmount,
|
||||||
blurOpacity: layout.blurOpacity,
|
blurOpacity: layout.blurOpacity,
|
||||||
textPadding: layout.textPadding,
|
textPadding: layout.textPadding,
|
||||||
titleArtistGap: layout.titleArtistGap,
|
titleArtistGap: layout.titleArtistGap,
|
||||||
titleBold: layout.titleBold,
|
|
||||||
textOffsetX: layout.textOffsetX,
|
textOffsetX: layout.textOffsetX,
|
||||||
textOffsetY: layout.textOffsetY,
|
textOffsetY: layout.textOffsetY,
|
||||||
playlistId: item.metadata.playlistId?.trim() || null,
|
playlistId: item.metadata.playlistId?.trim() || null,
|
||||||
@@ -402,19 +353,6 @@ export async function createVideoJob(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let newBackgroundImage: string | null = null;
|
|
||||||
if (item.backgroundImagePath) {
|
|
||||||
const src = item.backgroundImagePath;
|
|
||||||
if (moved.has(src)) {
|
|
||||||
newBackgroundImage = moved.get(src)!;
|
|
||||||
} else {
|
|
||||||
const ext = path.extname(src);
|
|
||||||
newBackgroundImage = path.join(jobDir, `${item.id}-bg${ext}`);
|
|
||||||
await moveFile(src, newBackgroundImage);
|
|
||||||
moved.set(src, newBackgroundImage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let newLogo: string | null = null;
|
let newLogo: string | null = null;
|
||||||
if (item.watermarkLogoPath) {
|
if (item.watermarkLogoPath) {
|
||||||
const src = item.watermarkLogoPath;
|
const src = item.watermarkLogoPath;
|
||||||
@@ -445,7 +383,6 @@ export async function createVideoJob(
|
|||||||
data: {
|
data: {
|
||||||
audioPath: newAudioPath,
|
audioPath: newAudioPath,
|
||||||
itemImagePath: newItemImage,
|
itemImagePath: newItemImage,
|
||||||
backgroundImagePath: newBackgroundImage,
|
|
||||||
watermarkLogoPath: newLogo,
|
watermarkLogoPath: newLogo,
|
||||||
watermarkFontPath: newFont,
|
watermarkFontPath: newFont,
|
||||||
},
|
},
|
||||||
|
|||||||
+11
-246
@@ -8,8 +8,6 @@ export const LAYOUT_TEMPLATES = [
|
|||||||
"COVER_TOP_TEXT_BOTTOM",
|
"COVER_TOP_TEXT_BOTTOM",
|
||||||
"COVER_RIGHT_TEXT_LEFT",
|
"COVER_RIGHT_TEXT_LEFT",
|
||||||
"CENTERED_COMPACT",
|
"CENTERED_COMPACT",
|
||||||
"LOWER_LEFT_COVER_TEXT",
|
|
||||||
"LOWER_RIGHT_COVER_TEXT",
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type LayoutTemplate = (typeof LAYOUT_TEMPLATES)[number];
|
export type LayoutTemplate = (typeof LAYOUT_TEMPLATES)[number];
|
||||||
@@ -39,31 +37,12 @@ export const TEXT_OFFSET_MIN = -120;
|
|||||||
export const TEXT_OFFSET_MAX = 120;
|
export const TEXT_OFFSET_MAX = 120;
|
||||||
export const TEXT_OFFSET_DEFAULT = 0;
|
export const TEXT_OFFSET_DEFAULT = 0;
|
||||||
|
|
||||||
/** Lower-corner cover size as a fraction of encode frame width. */
|
|
||||||
export const LOWER_CORNER_COVER_WIDTH_FRACTION = 0.23;
|
|
||||||
|
|
||||||
export function lowerCornerCoverSide(
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
textPadding: number,
|
|
||||||
): number {
|
|
||||||
const edge = clampTextPadding(textPadding);
|
|
||||||
return Math.round(
|
|
||||||
Math.min(width * LOWER_CORNER_COVER_WIDTH_FRACTION, height - edge * 2),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ARTIST_MAX = 80;
|
export const ARTIST_MAX = 80;
|
||||||
export const SONG_TITLE_MAX = 120;
|
export const SONG_TITLE_MAX = 120;
|
||||||
|
|
||||||
export type LayoutSettings = {
|
export type LayoutSettings = {
|
||||||
/** When null, classic letterbox (no art-track layout). */
|
/** When null, classic letterbox (no art-track layout / blur fill). */
|
||||||
template: LayoutTemplate | null;
|
template: LayoutTemplate | null;
|
||||||
/**
|
|
||||||
* Classic letterbox only: fill letterbox bars with a blurred cover.
|
|
||||||
* Ignored when `template` is set (art-track always uses a blur fill).
|
|
||||||
*/
|
|
||||||
blurFill: boolean;
|
|
||||||
/** 0–100 → FFmpeg boxblur intensity. */
|
/** 0–100 → FFmpeg boxblur intensity. */
|
||||||
blurAmount: number;
|
blurAmount: number;
|
||||||
/** 0–100 → how visible the blurred fill is (vs black). */
|
/** 0–100 → how visible the blurred fill is (vs black). */
|
||||||
@@ -76,20 +55,16 @@ export type LayoutSettings = {
|
|||||||
textOffsetX: number;
|
textOffsetX: number;
|
||||||
/** Shift text block vertically within the template. */
|
/** Shift text block vertically within the template. */
|
||||||
textOffsetY: number;
|
textOffsetY: number;
|
||||||
/** Bold weight for the on-video song title (artist stays regular). */
|
|
||||||
titleBold: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_LAYOUT: LayoutSettings = {
|
export const DEFAULT_LAYOUT: LayoutSettings = {
|
||||||
template: null,
|
template: null,
|
||||||
blurFill: false,
|
|
||||||
blurAmount: BLUR_AMOUNT_DEFAULT,
|
blurAmount: BLUR_AMOUNT_DEFAULT,
|
||||||
blurOpacity: BLUR_OPACITY_DEFAULT,
|
blurOpacity: BLUR_OPACITY_DEFAULT,
|
||||||
textPadding: TEXT_PADDING_DEFAULT,
|
textPadding: TEXT_PADDING_DEFAULT,
|
||||||
titleArtistGap: TITLE_ARTIST_GAP_DEFAULT,
|
titleArtistGap: TITLE_ARTIST_GAP_DEFAULT,
|
||||||
textOffsetX: TEXT_OFFSET_DEFAULT,
|
textOffsetX: TEXT_OFFSET_DEFAULT,
|
||||||
textOffsetY: TEXT_OFFSET_DEFAULT,
|
textOffsetY: TEXT_OFFSET_DEFAULT,
|
||||||
titleBold: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isLayoutTemplate(v: unknown): v is LayoutTemplate {
|
export function isLayoutTemplate(v: unknown): v is LayoutTemplate {
|
||||||
@@ -144,8 +119,6 @@ type RawLayoutInput = {
|
|||||||
template?: unknown;
|
template?: unknown;
|
||||||
layoutTemplate?: unknown;
|
layoutTemplate?: unknown;
|
||||||
layout_template?: unknown;
|
layout_template?: unknown;
|
||||||
blurFill?: unknown;
|
|
||||||
blur_fill?: unknown;
|
|
||||||
blurAmount?: unknown;
|
blurAmount?: unknown;
|
||||||
blur_amount?: unknown;
|
blur_amount?: unknown;
|
||||||
blurOpacity?: unknown;
|
blurOpacity?: unknown;
|
||||||
@@ -158,8 +131,6 @@ type RawLayoutInput = {
|
|||||||
text_offset_x?: unknown;
|
text_offset_x?: unknown;
|
||||||
textOffsetY?: unknown;
|
textOffsetY?: unknown;
|
||||||
text_offset_y?: unknown;
|
text_offset_y?: unknown;
|
||||||
titleBold?: unknown;
|
|
||||||
title_bold?: unknown;
|
|
||||||
/** Rejected clients must not send free-form cover coordinates. */
|
/** Rejected clients must not send free-form cover coordinates. */
|
||||||
x?: unknown;
|
x?: unknown;
|
||||||
y?: unknown;
|
y?: unknown;
|
||||||
@@ -218,25 +189,15 @@ export function normalizeLayoutSettings(
|
|||||||
(input as RawLayoutInput).textOffsetY ??
|
(input as RawLayoutInput).textOffsetY ??
|
||||||
(input as RawLayoutInput).text_offset_y ??
|
(input as RawLayoutInput).text_offset_y ??
|
||||||
(input as LayoutSettings).textOffsetY;
|
(input as LayoutSettings).textOffsetY;
|
||||||
const boldRaw =
|
|
||||||
(input as RawLayoutInput).titleBold ??
|
|
||||||
(input as RawLayoutInput).title_bold ??
|
|
||||||
(input as LayoutSettings).titleBold;
|
|
||||||
const blurFillRaw =
|
|
||||||
(input as RawLayoutInput).blurFill ??
|
|
||||||
(input as RawLayoutInput).blur_fill ??
|
|
||||||
(input as LayoutSettings).blurFill;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
template,
|
template,
|
||||||
blurFill: template !== null ? false : Boolean(blurFillRaw),
|
|
||||||
blurAmount: clampBlurAmount(blurRaw, BLUR_AMOUNT_DEFAULT),
|
blurAmount: clampBlurAmount(blurRaw, BLUR_AMOUNT_DEFAULT),
|
||||||
blurOpacity: clampBlurOpacity(opacityRaw, BLUR_OPACITY_DEFAULT),
|
blurOpacity: clampBlurOpacity(opacityRaw, BLUR_OPACITY_DEFAULT),
|
||||||
textPadding: clampTextPadding(padRaw, TEXT_PADDING_DEFAULT),
|
textPadding: clampTextPadding(padRaw, TEXT_PADDING_DEFAULT),
|
||||||
titleArtistGap: clampTitleArtistGap(gapRaw, TITLE_ARTIST_GAP_DEFAULT),
|
titleArtistGap: clampTitleArtistGap(gapRaw, TITLE_ARTIST_GAP_DEFAULT),
|
||||||
textOffsetX: clampTextOffset(oxRaw, TEXT_OFFSET_DEFAULT),
|
textOffsetX: clampTextOffset(oxRaw, TEXT_OFFSET_DEFAULT),
|
||||||
textOffsetY: clampTextOffset(oyRaw, TEXT_OFFSET_DEFAULT),
|
textOffsetY: clampTextOffset(oyRaw, TEXT_OFFSET_DEFAULT),
|
||||||
titleBold: boldRaw === undefined || boldRaw === null ? true : Boolean(boldRaw),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,54 +342,6 @@ export function computeLayoutGeometry(
|
|||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "LOWER_LEFT_COVER_TEXT": {
|
|
||||||
// Equal left + bottom inset so the cover corner sits on a true diagonal from (0, H).
|
|
||||||
const edge = pad;
|
|
||||||
const side = lowerCornerCoverSide(width, height, pad);
|
|
||||||
const coverTextGap = Math.max(10, Math.round(pad * 0.55));
|
|
||||||
const textX = edge + side + coverTextGap;
|
|
||||||
const coverTop = height - edge - side;
|
|
||||||
const textBlockH = titleFontSize + lineGap + artistFontSize;
|
|
||||||
const textMidY = coverTop + Math.round(side / 2);
|
|
||||||
const titleY = Math.max(edge, textMidY - Math.round(textBlockH / 2));
|
|
||||||
base = {
|
|
||||||
coverMaxW: side,
|
|
||||||
coverMaxH: side,
|
|
||||||
coverX: String(edge),
|
|
||||||
coverY: `H-h-${edge}`,
|
|
||||||
titleFontSize,
|
|
||||||
artistFontSize,
|
|
||||||
titleX: String(textX),
|
|
||||||
titleY: String(titleY),
|
|
||||||
artistX: String(textX),
|
|
||||||
artistY: String(titleY + titleFontSize + lineGap),
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "LOWER_RIGHT_COVER_TEXT": {
|
|
||||||
// Equal right + bottom inset (mirror of lower-left diagonal).
|
|
||||||
const edge = pad;
|
|
||||||
const side = lowerCornerCoverSide(width, height, pad);
|
|
||||||
const coverTextGap = Math.max(10, Math.round(pad * 0.55));
|
|
||||||
const coverTop = height - edge - side;
|
|
||||||
const textBlockH = titleFontSize + lineGap + artistFontSize;
|
|
||||||
const textMidY = coverTop + Math.round(side / 2);
|
|
||||||
const titleY = Math.max(edge, textMidY - Math.round(textBlockH / 2));
|
|
||||||
const textInset = edge + side + coverTextGap;
|
|
||||||
base = {
|
|
||||||
coverMaxW: side,
|
|
||||||
coverMaxH: side,
|
|
||||||
coverX: `W-w-${edge}`,
|
|
||||||
coverY: `H-h-${edge}`,
|
|
||||||
titleFontSize,
|
|
||||||
artistFontSize,
|
|
||||||
titleX: `W-text_w-${textInset}`,
|
|
||||||
titleY: String(titleY),
|
|
||||||
artistX: `W-text_w-${textInset}`,
|
|
||||||
artistY: String(titleY + titleFontSize + lineGap),
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: {
|
default: {
|
||||||
throw new Error(INVALID_LAYOUT_TEMPLATE_MESSAGE);
|
throw new Error(INVALID_LAYOUT_TEMPLATE_MESSAGE);
|
||||||
}
|
}
|
||||||
@@ -437,27 +350,13 @@ export function computeLayoutGeometry(
|
|||||||
return applyTextOffsets(base, textOffsetX, textOffsetY);
|
return applyTextOffsets(base, textOffsetX, textOffsetY);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLowerCornerTemplate(
|
|
||||||
template: LayoutTemplate | null | undefined,
|
|
||||||
): boolean {
|
|
||||||
return template === "LOWER_LEFT_COVER_TEXT" || template === "LOWER_RIGHT_COVER_TEXT";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildArtTrackFilterComplex(opts: {
|
export function buildArtTrackFilterComplex(opts: {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
layout: LayoutSettings;
|
layout: LayoutSettings;
|
||||||
titleEscaped: string;
|
titleEscaped: string;
|
||||||
artistEscaped: string | null;
|
artistEscaped: string | null;
|
||||||
/** Regular-weight font for artist (and title when not bold / no bold file). */
|
|
||||||
fontfileEscaped?: string | null;
|
fontfileEscaped?: string | null;
|
||||||
/** Bold font for title when `layout.titleBold` and a bold face is available. */
|
|
||||||
titleFontfileEscaped?: string | null;
|
|
||||||
/**
|
|
||||||
* When set, use this FFmpeg input index as the blur-fill source instead of
|
|
||||||
* splitting the cover (`[0:v]`). Cover stays on input 0 as the sharp overlay.
|
|
||||||
*/
|
|
||||||
separateBackgroundInputIndex?: number | null;
|
|
||||||
}): string {
|
}): string {
|
||||||
const { width: W, height: H, layout } = opts;
|
const { width: W, height: H, layout } = opts;
|
||||||
if (!layout.template) {
|
if (!layout.template) {
|
||||||
@@ -479,84 +378,15 @@ export function buildArtTrackFilterComplex(opts: {
|
|||||||
: `scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H}`;
|
: `scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H}`;
|
||||||
|
|
||||||
const opacity = clampBlurOpacity(layout.blurOpacity, BLUR_OPACITY_DEFAULT) / 100;
|
const opacity = clampBlurOpacity(layout.blurOpacity, BLUR_OPACITY_DEFAULT) / 100;
|
||||||
const artistFontPart = opts.fontfileEscaped ? `:fontfile='${opts.fontfileEscaped}'` : "";
|
const fontPart = opts.fontfileEscaped ? `:fontfile='${opts.fontfileEscaped}'` : "";
|
||||||
const titleUsesBoldFace = Boolean(layout.titleBold && opts.titleFontfileEscaped);
|
const titleDraw = `drawtext=text='${opts.titleEscaped}'${fontPart}:fontsize=${geo.titleFontSize}:fontcolor=white@0.95:x=${geo.titleX}:y=${geo.titleY}`;
|
||||||
const titleFontPart = titleUsesBoldFace
|
|
||||||
? `:fontfile='${opts.titleFontfileEscaped}'`
|
|
||||||
: artistFontPart;
|
|
||||||
// Faux-bold stroke when bold is requested but no bold TTF is available (custom/curated).
|
|
||||||
const titleFauxBold =
|
|
||||||
layout.titleBold && !titleUsesBoldFace ? `:borderw=1:bordercolor=white@0.95` : "";
|
|
||||||
const titleDraw = `drawtext=text='${opts.titleEscaped}'${titleFontPart}${titleFauxBold}:fontsize=${geo.titleFontSize}:fontcolor=white@0.95:x=${geo.titleX}:y=${geo.titleY}`;
|
|
||||||
const artistDraw = opts.artistEscaped
|
const artistDraw = opts.artistEscaped
|
||||||
? `,drawtext=text='${opts.artistEscaped}'${artistFontPart}:fontsize=${geo.artistFontSize}:fontcolor=white@0.75:x=${geo.artistX}:y=${geo.artistY}`
|
? `,drawtext=text='${opts.artistEscaped}'${fontPart}:fontsize=${geo.artistFontSize}:fontcolor=white@0.75:x=${geo.artistX}:y=${geo.artistY}`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const separateBgIdx =
|
const parts: string[] = [`[0:v]split=2[bg][fg]`];
|
||||||
typeof opts.separateBackgroundInputIndex === "number" &&
|
|
||||||
Number.isFinite(opts.separateBackgroundInputIndex) &&
|
|
||||||
opts.separateBackgroundInputIndex >= 0
|
|
||||||
? opts.separateBackgroundInputIndex
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const parts: string[] = [];
|
|
||||||
const bgSrc = separateBgIdx !== null ? `[${separateBgIdx}:v]` : "[bg]";
|
|
||||||
if (separateBgIdx !== null) {
|
|
||||||
parts.push(
|
|
||||||
`[0:v]scale=${geo.coverMaxW}:${geo.coverMaxH}:force_original_aspect_ratio=decrease[cover]`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
parts.push(`[0:v]split=2[bg][fg]`);
|
|
||||||
parts.push(
|
|
||||||
`[fg]scale=${geo.coverMaxW}:${geo.coverMaxH}:force_original_aspect_ratio=decrease[cover]`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fade blurred fill toward black when opacity < 100%
|
// Fade blurred fill toward black when opacity < 100%
|
||||||
if (opacity >= 0.999) {
|
|
||||||
parts.push(`${bgSrc}${bgChain}[blurred]`);
|
|
||||||
} else if (opacity <= 0.001) {
|
|
||||||
parts.push(`color=c=black:s=${W}x${H}:d=1[blurred]`);
|
|
||||||
} else {
|
|
||||||
const a = opacity.toFixed(3);
|
|
||||||
const b = (1 - opacity).toFixed(3);
|
|
||||||
parts.push(
|
|
||||||
`${bgSrc}${bgChain}[blur_raw]`,
|
|
||||||
`color=c=black:s=${W}x${H}:d=1[blk]`,
|
|
||||||
`[blur_raw][blk]blend=all_expr='A*${a}+B*${b}':shortest=1[blurred]`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
parts.push(
|
|
||||||
`[blurred][cover]overlay=${geo.coverX}:${geo.coverY}[composed]`,
|
|
||||||
`[composed]${titleDraw}${artistDraw}[laid]`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return parts.join(";");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Classic letterbox with blurred cover fill (no on-video title/artist).
|
|
||||||
* Ends at `[laid]` — same convention as art-track for encode relabeling.
|
|
||||||
*/
|
|
||||||
export function buildClassicBlurFillFilterComplex(opts: {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
blurAmount: number;
|
|
||||||
blurOpacity: number;
|
|
||||||
}): string {
|
|
||||||
const { width: W, height: H } = opts;
|
|
||||||
const blurSeg = boxblurFilterSegment(opts.blurAmount);
|
|
||||||
const bgChain = blurSeg
|
|
||||||
? `scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H},${blurSeg}`
|
|
||||||
: `scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H}`;
|
|
||||||
const opacity = clampBlurOpacity(opts.blurOpacity, BLUR_OPACITY_DEFAULT) / 100;
|
|
||||||
|
|
||||||
const parts: string[] = [
|
|
||||||
`[0:v]split=2[bg][fg]`,
|
|
||||||
`[fg]scale=${W}:${H}:force_original_aspect_ratio=decrease[cover]`,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (opacity >= 0.999) {
|
if (opacity >= 0.999) {
|
||||||
parts.push(`[bg]${bgChain}[blurred]`);
|
parts.push(`[bg]${bgChain}[blurred]`);
|
||||||
} else if (opacity <= 0.001) {
|
} else if (opacity <= 0.001) {
|
||||||
@@ -571,7 +401,12 @@ export function buildClassicBlurFillFilterComplex(opts: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
parts.push(`[blurred][cover]overlay=(W-w)/2:(H-h)/2[laid]`);
|
parts.push(
|
||||||
|
`[fg]scale=${geo.coverMaxW}:${geo.coverMaxH}:force_original_aspect_ratio=decrease[cover]`,
|
||||||
|
`[blurred][cover]overlay=${geo.coverX}:${geo.coverY}[composed]`,
|
||||||
|
`[composed]${titleDraw}${artistDraw}[laid]`,
|
||||||
|
);
|
||||||
|
|
||||||
return parts.join(";");
|
return parts.join(";");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,74 +415,4 @@ export const LAYOUT_TEMPLATE_LABELS: Record<LayoutTemplate, string> = {
|
|||||||
COVER_TOP_TEXT_BOTTOM: "Cover top · text bottom",
|
COVER_TOP_TEXT_BOTTOM: "Cover top · text bottom",
|
||||||
COVER_RIGHT_TEXT_LEFT: "Cover right · text left",
|
COVER_RIGHT_TEXT_LEFT: "Cover right · text left",
|
||||||
CENTERED_COMPACT: "Centered compact",
|
CENTERED_COMPACT: "Centered compact",
|
||||||
LOWER_LEFT_COVER_TEXT: "Lower left · cover + text",
|
|
||||||
LOWER_RIGHT_COVER_TEXT: "Lower right · cover + text",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Composition picker families — mirrored pairs share one tile + variant toggles. */
|
|
||||||
export type CompositionFamilyId =
|
|
||||||
| "classic"
|
|
||||||
| "side"
|
|
||||||
| "top"
|
|
||||||
| "centered"
|
|
||||||
| "lower";
|
|
||||||
|
|
||||||
export type CompositionFamily = {
|
|
||||||
id: CompositionFamilyId;
|
|
||||||
/** Tile label in the composition grid. */
|
|
||||||
label: string;
|
|
||||||
/** Which thumb art to draw (null = classic letterbox). */
|
|
||||||
thumb: LayoutTemplate | null;
|
|
||||||
/** Templates in this family; length > 1 shows variant toggles when selected. */
|
|
||||||
variants: LayoutTemplate[];
|
|
||||||
/** Default when first selecting the family. */
|
|
||||||
defaultTemplate: LayoutTemplate | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const COMPOSITION_FAMILIES: readonly CompositionFamily[] = [
|
|
||||||
{
|
|
||||||
id: "classic",
|
|
||||||
label: "Classic letterbox",
|
|
||||||
thumb: null,
|
|
||||||
variants: [],
|
|
||||||
defaultTemplate: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "side",
|
|
||||||
label: "Cover beside text",
|
|
||||||
thumb: "COVER_LEFT_TEXT_RIGHT",
|
|
||||||
variants: ["COVER_LEFT_TEXT_RIGHT", "COVER_RIGHT_TEXT_LEFT"],
|
|
||||||
defaultTemplate: "COVER_LEFT_TEXT_RIGHT",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "top",
|
|
||||||
label: "Cover top · text bottom",
|
|
||||||
thumb: "COVER_TOP_TEXT_BOTTOM",
|
|
||||||
variants: ["COVER_TOP_TEXT_BOTTOM"],
|
|
||||||
defaultTemplate: "COVER_TOP_TEXT_BOTTOM",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "centered",
|
|
||||||
label: "Centered compact",
|
|
||||||
thumb: "CENTERED_COMPACT",
|
|
||||||
variants: ["CENTERED_COMPACT"],
|
|
||||||
defaultTemplate: "CENTERED_COMPACT",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "lower",
|
|
||||||
label: "Lower corner · cover + text",
|
|
||||||
thumb: "LOWER_LEFT_COVER_TEXT",
|
|
||||||
variants: ["LOWER_LEFT_COVER_TEXT", "LOWER_RIGHT_COVER_TEXT"],
|
|
||||||
defaultTemplate: "LOWER_LEFT_COVER_TEXT",
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function compositionFamilyForTemplate(
|
|
||||||
template: LayoutTemplate | null,
|
|
||||||
): CompositionFamily {
|
|
||||||
if (template === null) return COMPOSITION_FAMILIES[0]!;
|
|
||||||
const found = COMPOSITION_FAMILIES.find((f) =>
|
|
||||||
f.variants.includes(template),
|
|
||||||
);
|
|
||||||
return found ?? COMPOSITION_FAMILIES[0]!;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { SUPPORT_EMAIL } from "@/lib/plans";
|
import { SUPPORT_EMAIL } from "@/lib/plans";
|
||||||
import { BRAND_DOMAIN, BRAND_NAME } from "@/lib/branding";
|
import { BRAND_DOMAIN, BRAND_NAME } from "@/lib/branding";
|
||||||
|
|
||||||
export const LEGAL_LAST_UPDATED = "August 3, 2026";
|
export const LEGAL_LAST_UPDATED = "July 30, 2026";
|
||||||
|
|
||||||
export const LEGAL_OPERATOR = {
|
export const LEGAL_OPERATOR = {
|
||||||
name: BRAND_NAME,
|
name: BRAND_NAME,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { WatermarkFontKey } from "./fonts";
|
import type { WatermarkFontKey } from "./fonts";
|
||||||
import { CURATED_FONTS, SYSTEM_FONT } from "./fonts";
|
import { CURATED_FONTS } from "./fonts";
|
||||||
|
|
||||||
/** Matches LayoutStudio max preview width (tailwind max-w-xl ≈ 576px; use 576 for scaling). */
|
/** Matches LayoutStudio max preview width (tailwind max-w-xl ≈ 576px; use 576 for scaling). */
|
||||||
export const PREVIEW_REFERENCE_WIDTH = 576;
|
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 {
|
export function previewFontFamilyCss(fontKey: WatermarkFontKey | undefined): string {
|
||||||
if (!fontKey || fontKey === "system") {
|
if (!fontKey || fontKey === "system") {
|
||||||
return `'${SYSTEM_FONT.previewFamily}', sans-serif`;
|
return "Arial, Helvetica, sans-serif";
|
||||||
}
|
}
|
||||||
if (fontKey === "custom") {
|
if (fontKey === "custom") {
|
||||||
return "'S2VIDCustomWm', sans-serif";
|
return "'S2VIDCustomWm', Arial, sans-serif";
|
||||||
}
|
}
|
||||||
const meta = CURATED_FONTS.find((f) => f.key === fontKey);
|
const meta = CURATED_FONTS.find((f) => f.key === fontKey);
|
||||||
return meta ? `'S2VIDPreview-${meta.key}', sans-serif` : "sans-serif";
|
return meta ? `'S2VIDPreview-${meta.key}', Arial, sans-serif` : "Arial, sans-serif";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function curatedFontApiUrl(key: string): string {
|
export function curatedFontApiUrl(key: string): string {
|
||||||
|
|||||||
@@ -22,12 +22,6 @@ export type ItemMetadata = {
|
|||||||
* When omitted, the job-level shared imagePath is used.
|
* When omitted, the job-level shared imagePath is used.
|
||||||
*/
|
*/
|
||||||
imagePath?: string | null;
|
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. */
|
/** Custom branding watermark settings. */
|
||||||
watermark?: Partial<WatermarkSettings> | null;
|
watermark?: Partial<WatermarkSettings> | null;
|
||||||
/** Artist line for art-track layouts (on-video only). */
|
/** Artist line for art-track layouts (on-video only). */
|
||||||
@@ -37,27 +31,20 @@ export type ItemMetadata = {
|
|||||||
/**
|
/**
|
||||||
* Art-track layout. Prefer camelCase; snake_case aliases accepted:
|
* Art-track layout. Prefer camelCase; snake_case aliases accepted:
|
||||||
* layout_template, blur_amount, text_padding.
|
* layout_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> & {
|
layout?: Partial<LayoutSettings> & {
|
||||||
layoutTemplate?: string | null;
|
layoutTemplate?: string | null;
|
||||||
layout_template?: string | null;
|
layout_template?: string | null;
|
||||||
blur_fill?: boolean;
|
|
||||||
blur_amount?: number;
|
blur_amount?: number;
|
||||||
blur_opacity?: number;
|
blur_opacity?: number;
|
||||||
text_padding?: number;
|
text_padding?: number;
|
||||||
title_artist_gap?: number;
|
title_artist_gap?: number;
|
||||||
text_offset_x?: number;
|
text_offset_x?: number;
|
||||||
text_offset_y?: number;
|
text_offset_y?: number;
|
||||||
title_bold?: boolean;
|
|
||||||
} | null;
|
} | null;
|
||||||
/** Flat aliases (also accepted). */
|
/** Flat aliases (also accepted). */
|
||||||
layoutTemplate?: string | null;
|
layoutTemplate?: string | null;
|
||||||
layout_template?: string | null;
|
layout_template?: string | null;
|
||||||
blurFill?: boolean;
|
|
||||||
blur_fill?: boolean;
|
|
||||||
blurAmount?: number;
|
blurAmount?: number;
|
||||||
blur_amount?: number;
|
blur_amount?: number;
|
||||||
blurOpacity?: number;
|
blurOpacity?: number;
|
||||||
@@ -70,8 +57,6 @@ export type ItemMetadata = {
|
|||||||
text_offset_x?: number;
|
text_offset_x?: number;
|
||||||
textOffsetY?: number;
|
textOffsetY?: number;
|
||||||
text_offset_y?: number;
|
text_offset_y?: number;
|
||||||
titleBold?: boolean;
|
|
||||||
title_bold?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UploadedAudio = {
|
export type UploadedAudio = {
|
||||||
@@ -94,8 +79,6 @@ export type CreateJobPayload = {
|
|||||||
audioFilename: string;
|
audioFilename: string;
|
||||||
metadata: ItemMetadata;
|
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). */
|
/** Create a new playlist and attach its ID to every item (Pro). */
|
||||||
createPlaylist?: CreatePlaylistRequest | null;
|
createPlaylist?: CreatePlaylistRequest | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -39,11 +39,10 @@ export function isYouTubeUploadLimitError(message: string) {
|
|||||||
|
|
||||||
export const YOUTUBE_UPLOAD_LIMIT_USER_MESSAGE =
|
export const YOUTUBE_UPLOAD_LIMIT_USER_MESSAGE =
|
||||||
"YouTube upload limit reached: this Google/YouTube account has exceeded the number of videos " +
|
"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 a Songs2VID usage cap " +
|
"it may upload right now. This is YouTube's own daily limit, not your Songs2VID plan quota. " +
|
||||||
"(self-hosted OSS does not enforce a video quota paywall). " +
|
|
||||||
"Try again later (often after 24 hours) or use a different YouTube channel.";
|
"Try again later (often after 24 hours) or use a different YouTube channel.";
|
||||||
|
|
||||||
/** User-facing message for the dashboard (distinct from YouTube's own caps). */
|
/** User-facing message for the dashboard (distinct from Songs2VID plan quota). */
|
||||||
export function formatYouTubeErrorForUser(err: unknown): string {
|
export function formatYouTubeErrorForUser(err: unknown): string {
|
||||||
const raw = extractYouTubeErrorMessage(err);
|
const raw = extractYouTubeErrorMessage(err);
|
||||||
if (isYouTubeUploadLimitError(raw)) return YOUTUBE_UPLOAD_LIMIT_USER_MESSAGE;
|
if (isYouTubeUploadLimitError(raw)) return YOUTUBE_UPLOAD_LIMIT_USER_MESSAGE;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
"description": "Payment-free self-hosted audio-to-video app with YouTube upload",
|
"description": "Payment-free self-hosted audio-to-video app with YouTube upload",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"dev:all": "concurrently -n web,worker -c blue,green \"npm run dev\" \"npm run worker\"",
|
"dev:all": "concurrently -n web,worker -c blue,green \"npm run dev\" \"npm run worker\"",
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "JobItem" ADD COLUMN "backgroundImagePath" TEXT;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "JobItem" ADD COLUMN "blurFill" BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Job" ADD COLUMN IF NOT EXISTS "webhookUrl" TEXT;
|
|
||||||
@@ -59,8 +59,6 @@ model Job {
|
|||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
status JobStatus @default(PENDING)
|
status JobStatus @default(PENDING)
|
||||||
imagePath String
|
imagePath String
|
||||||
/// Optional absolute http(s) URL for n8n / automation callbacks
|
|
||||||
webhookUrl String?
|
|
||||||
items JobItem[]
|
items JobItem[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
@@ -84,8 +82,6 @@ model JobItem {
|
|||||||
creativeCommons Boolean @default(false)
|
creativeCommons Boolean @default(false)
|
||||||
includeWatermark Boolean @default(false)
|
includeWatermark Boolean @default(false)
|
||||||
itemImagePath String?
|
itemImagePath String?
|
||||||
/// Optional full-frame background for lower-corner layouts (blur fill). Cover stays foreground.
|
|
||||||
backgroundImagePath String?
|
|
||||||
watermarkMode String @default("none")
|
watermarkMode String @default("none")
|
||||||
watermarkText String?
|
watermarkText String?
|
||||||
watermarkLogoPath String?
|
watermarkLogoPath String?
|
||||||
@@ -97,13 +93,10 @@ model JobItem {
|
|||||||
artist String?
|
artist String?
|
||||||
songTitle String?
|
songTitle String?
|
||||||
layoutTemplate String?
|
layoutTemplate String?
|
||||||
/// Classic letterbox: fill black bars with blurred cover
|
|
||||||
blurFill Boolean @default(false)
|
|
||||||
blurAmount Int @default(55)
|
blurAmount Int @default(55)
|
||||||
blurOpacity Int @default(100)
|
blurOpacity Int @default(100)
|
||||||
textPadding Int @default(48)
|
textPadding Int @default(48)
|
||||||
titleArtistGap Int @default(10)
|
titleArtistGap Int @default(10)
|
||||||
titleBold Boolean @default(true)
|
|
||||||
textOffsetX Int @default(0)
|
textOffsetX Int @default(0)
|
||||||
textOffsetY Int @default(0)
|
textOffsetY Int @default(0)
|
||||||
playlistId String?
|
playlistId String?
|
||||||
|
|||||||
@@ -9,17 +9,8 @@ import { fileURLToPath } from "url";
|
|||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const outDir = path.join(__dirname, "..", "assets", "fonts");
|
const outDir = path.join(__dirname, "..", "assets", "fonts");
|
||||||
|
|
||||||
/** Direct TTF URLs from Google Fonts GitHub (OFL / Apache 2.0). */
|
/** Direct TTF URLs from Google Fonts GitHub (OFL). */
|
||||||
const FONTS = [
|
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",
|
file: "Inter-Regular.ttf",
|
||||||
url: "https://github.com/google/fonts/raw/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf",
|
url: "https://github.com/google/fonts/raw/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf",
|
||||||
@@ -44,22 +35,12 @@ const FONTS = [
|
|||||||
|
|
||||||
await fs.mkdir(outDir, { recursive: true });
|
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) {
|
for (const font of FONTS) {
|
||||||
const dest = path.join(outDir, font.file);
|
const dest = path.join(outDir, font.file);
|
||||||
try {
|
try {
|
||||||
const existing = await fs.readFile(dest);
|
await fs.access(dest);
|
||||||
if (looksLikeFont(existing)) {
|
console.log("skip (exists)", font.file);
|
||||||
console.log("skip (exists)", font.file);
|
continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
console.warn("corrupt font, re-fetching", font.file);
|
|
||||||
} catch {
|
} catch {
|
||||||
/* download */
|
/* download */
|
||||||
}
|
}
|
||||||
@@ -73,10 +54,6 @@ for (const font of FONTS) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const buf = Buffer.from(await res.arrayBuffer());
|
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);
|
await fs.writeFile(dest, buf);
|
||||||
console.log("wrote", font.file, buf.length, "bytes");
|
console.log("wrote", font.file, buf.length, "bytes");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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");
|
|
||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
blurToBoxblur,
|
blurToBoxblur,
|
||||||
boxblurFilterSegment,
|
boxblurFilterSegment,
|
||||||
buildArtTrackFilterComplex,
|
buildArtTrackFilterComplex,
|
||||||
buildClassicBlurFillFilterComplex,
|
|
||||||
clampBlurAmount,
|
clampBlurAmount,
|
||||||
clampTextPadding,
|
clampTextPadding,
|
||||||
clampTitleArtistGap,
|
clampTitleArtistGap,
|
||||||
@@ -33,22 +32,8 @@ function testEnumsAndClamp() {
|
|||||||
function testNormalize() {
|
function testNormalize() {
|
||||||
const classic = normalizeLayoutSettings(null);
|
const classic = normalizeLayoutSettings(null);
|
||||||
assert.equal(classic.template, null);
|
assert.equal(classic.template, null);
|
||||||
assert.equal(classic.blurFill, false);
|
|
||||||
assert.equal(classic.titleArtistGap, 10);
|
assert.equal(classic.titleArtistGap, 10);
|
||||||
assert.equal(classic.textOffsetX, 0);
|
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(
|
assert.equal(
|
||||||
normalizeLayoutSettings({
|
normalizeLayoutSettings({
|
||||||
@@ -90,117 +75,26 @@ function testGeometryAndFilter() {
|
|||||||
const tight = computeLayoutGeometry("COVER_LEFT_TEXT_RIGHT", 1280, 720, 40, 0, 0, 0);
|
const tight = computeLayoutGeometry("COVER_LEFT_TEXT_RIGHT", 1280, 720, 40, 0, 0, 0);
|
||||||
assert.ok(Number(withGap.artistY) - Number(withGap.titleY) > Number(tight.artistY) - Number(tight.titleY));
|
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({
|
const fc = buildArtTrackFilterComplex({
|
||||||
width: 1280,
|
width: 1280,
|
||||||
height: 720,
|
height: 720,
|
||||||
layout: {
|
layout: {
|
||||||
template: "CENTERED_COMPACT",
|
template: "CENTERED_COMPACT",
|
||||||
blurFill: false,
|
|
||||||
blurAmount: 40,
|
blurAmount: 40,
|
||||||
blurOpacity: 70,
|
blurOpacity: 70,
|
||||||
textPadding: 40,
|
textPadding: 40,
|
||||||
titleArtistGap: 18,
|
titleArtistGap: 18,
|
||||||
textOffsetX: 5,
|
textOffsetX: 5,
|
||||||
textOffsetY: -8,
|
textOffsetY: -8,
|
||||||
titleBold: true,
|
|
||||||
},
|
},
|
||||||
titleEscaped: sanitizeDrawtext("Hello:World"),
|
titleEscaped: sanitizeDrawtext("Hello:World"),
|
||||||
artistEscaped: sanitizeDrawtext("Artist"),
|
artistEscaped: sanitizeDrawtext("Artist"),
|
||||||
fontfileEscaped: null,
|
|
||||||
titleFontfileEscaped: "C\\:/fonts/Arimo-Bold.ttf",
|
|
||||||
});
|
});
|
||||||
assert.ok(fc.includes("split=2"));
|
assert.ok(fc.includes("split=2"));
|
||||||
assert.ok(fc.includes("blend=") || fc.includes("[blurred]"));
|
assert.ok(fc.includes("blend=") || fc.includes("[blurred]"));
|
||||||
assert.ok(fc.includes("overlay="));
|
assert.ok(fc.includes("overlay="));
|
||||||
assert.ok(fc.includes("drawtext="));
|
assert.ok(fc.includes("drawtext="));
|
||||||
assert.ok(fc.includes("fontfile='C\\:/fonts/Arimo-Bold.ttf'"));
|
|
||||||
assert.ok(fc.endsWith("[laid]"));
|
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();
|
testEnumsAndClamp();
|
||||||
|
|||||||
+1
-1
@@ -19,5 +19,5 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules", "website", "integrations"]
|
"exclude": ["node_modules", "website"]
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-47
@@ -9,13 +9,8 @@ import { getJobDir } from "../lib/storage";
|
|||||||
import type { VideoJobData } from "../lib/types";
|
import type { VideoJobData } from "../lib/types";
|
||||||
import { formatYouTubeErrorForUser } from "../lib/youtube/errors";
|
import { formatYouTubeErrorForUser } from "../lib/youtube/errors";
|
||||||
import { uploadToYouTube } from "../lib/youtube/upload";
|
import { uploadToYouTube } from "../lib/youtube/upload";
|
||||||
import { deliverJobWebhook } from "../lib/webhooks";
|
|
||||||
|
|
||||||
async function updateJobStatus(jobId: string) {
|
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 items = await prisma.jobItem.findMany({ where: { jobId } });
|
||||||
const completed = items.filter((i) => i.status === JobItemStatus.COMPLETED).length;
|
const completed = items.filter((i) => i.status === JobItemStatus.COMPLETED).length;
|
||||||
const failed = items.filter((i) => i.status === JobItemStatus.FAILED).length;
|
const failed = items.filter((i) => i.status === JobItemStatus.FAILED).length;
|
||||||
@@ -28,29 +23,13 @@ async function updateJobStatus(jobId: string) {
|
|||||||
else status = JobStatus.PARTIAL;
|
else status = JobStatus.PARTIAL;
|
||||||
}
|
}
|
||||||
|
|
||||||
const completedAt = completed + failed === total ? new Date() : null;
|
|
||||||
await prisma.job.update({
|
await prisma.job.update({
|
||||||
where: { id: jobId },
|
where: { id: jobId },
|
||||||
data: {
|
data: {
|
||||||
status,
|
status,
|
||||||
completedAt,
|
completedAt: completed + failed === total ? new Date() : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
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) {
|
async function processJobItem(data: VideoJobData) {
|
||||||
@@ -91,25 +70,21 @@ async function processJobItem(data: VideoJobData) {
|
|||||||
includeWatermark: item.includeWatermark,
|
includeWatermark: item.includeWatermark,
|
||||||
songTitle: item.songTitle || item.title,
|
songTitle: item.songTitle || item.title,
|
||||||
artist: item.artist,
|
artist: item.artist,
|
||||||
layout: {
|
layout: item.layoutTemplate
|
||||||
template: (item.layoutTemplate as
|
? {
|
||||||
|
template: item.layoutTemplate as
|
||||||
| "COVER_LEFT_TEXT_RIGHT"
|
| "COVER_LEFT_TEXT_RIGHT"
|
||||||
| "COVER_TOP_TEXT_BOTTOM"
|
| "COVER_TOP_TEXT_BOTTOM"
|
||||||
| "COVER_RIGHT_TEXT_LEFT"
|
| "COVER_RIGHT_TEXT_LEFT"
|
||||||
| "CENTERED_COMPACT"
|
| "CENTERED_COMPACT",
|
||||||
| "LOWER_LEFT_COVER_TEXT"
|
|
||||||
| "LOWER_RIGHT_COVER_TEXT"
|
|
||||||
| null) ?? null,
|
|
||||||
blurFill: item.blurFill ?? false,
|
|
||||||
blurAmount: item.blurAmount ?? 55,
|
blurAmount: item.blurAmount ?? 55,
|
||||||
blurOpacity: item.blurOpacity ?? 100,
|
blurOpacity: item.blurOpacity ?? 100,
|
||||||
textPadding: item.textPadding ?? 48,
|
textPadding: item.textPadding ?? 48,
|
||||||
titleArtistGap: item.titleArtistGap ?? 10,
|
titleArtistGap: item.titleArtistGap ?? 10,
|
||||||
titleBold: item.titleBold ?? true,
|
|
||||||
textOffsetX: item.textOffsetX ?? 0,
|
textOffsetX: item.textOffsetX ?? 0,
|
||||||
textOffsetY: item.textOffsetY ?? 0,
|
textOffsetY: item.textOffsetY ?? 0,
|
||||||
},
|
}
|
||||||
backgroundImagePath: item.backgroundImagePath,
|
: null,
|
||||||
watermark: {
|
watermark: {
|
||||||
mode: (item.watermarkMode as "none" | "default" | "text" | "logo") || "default",
|
mode: (item.watermarkMode as "none" | "default" | "text" | "logo") || "default",
|
||||||
text: item.watermarkText,
|
text: item.watermarkText,
|
||||||
@@ -157,14 +132,6 @@ async function processJobItem(data: VideoJobData) {
|
|||||||
data: { createdVideoCount: { increment: 1 } },
|
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]);
|
await cleanupFiles([outputPath]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = formatYouTubeErrorForUser(err);
|
const message = formatYouTubeErrorForUser(err);
|
||||||
@@ -173,13 +140,6 @@ async function processJobItem(data: VideoJobData) {
|
|||||||
where: { id: item.id },
|
where: { id: item.id },
|
||||||
data: { status: JobItemStatus.FAILED, error: message },
|
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);
|
throw new Error(message);
|
||||||
} finally {
|
} finally {
|
||||||
await updateJobStatus(data.jobId);
|
await updateJobStatus(data.jobId);
|
||||||
|
|||||||
Reference in New Issue
Block a user