Align preview typography with FFmpeg output, add video/song title split, and ship OSS updates.
Separate YouTube video titles from on-video song/artist fields with Pro gating, serve curated fonts and watermark assets for 1:1 preview parity, and include billing/API/docs/deploy stack for self-hosted release. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
682020ff5a
commit
c8015937f9
+105
@@ -0,0 +1,105 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { findUserByApiKey } from "./api-keys";
|
||||
import { checkApiRateLimit } from "./api-rate-limit";
|
||||
import { hasProFeatures } from "./edition";
|
||||
import { getSessionUser } from "./session";
|
||||
|
||||
function extractBearerToken(req: NextRequest) {
|
||||
const auth = req.headers.get("authorization");
|
||||
if (!auth?.startsWith("Bearer ")) return null;
|
||||
return auth.slice(7).trim();
|
||||
}
|
||||
|
||||
export async function requirePaidApiUser(req: NextRequest) {
|
||||
const token = extractBearerToken(req);
|
||||
if (!token) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: "Missing API key. Use Authorization: Bearer <your_api_key>" },
|
||||
{ status: 401 },
|
||||
),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
const user = await findUserByApiKey(token);
|
||||
if (!user) {
|
||||
return {
|
||||
error: NextResponse.json({ error: "Invalid API key" }, { status: 401 }),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasProFeatures(user.plan)) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: "API access requires an active Pro subscription" },
|
||||
{ status: 403 },
|
||||
),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!user.youtubeConnection) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: "YouTube account not connected. Sign in via the dashboard first." },
|
||||
{ status: 403 },
|
||||
),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
const rateLimit = await checkApiRateLimit(user.id);
|
||||
if (!rateLimit.ok) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{
|
||||
error: "API rate limit exceeded. Try again shortly.",
|
||||
retryAfterSeconds: rateLimit.retryAfterSeconds,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: { "Retry-After": String(rateLimit.retryAfterSeconds) },
|
||||
},
|
||||
),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
return { error: null, user };
|
||||
}
|
||||
|
||||
export async function requirePaidUserFromSessionOrApi(req: NextRequest) {
|
||||
const apiResult = await requirePaidApiUser(req);
|
||||
if (apiResult.user) return apiResult;
|
||||
|
||||
const sessionUser = await getSessionUser();
|
||||
if (!sessionUser) {
|
||||
return apiResult.error
|
||||
? apiResult
|
||||
: {
|
||||
error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasProFeatures(sessionUser.plan)) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: "API access requires an active Pro subscription" },
|
||||
{ status: 403 },
|
||||
),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!sessionUser.youtubeConnection) {
|
||||
return {
|
||||
error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
return { error: null, user: sessionUser };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createHash, randomBytes } from "crypto";
|
||||
import { prisma } from "./db";
|
||||
|
||||
const API_KEY_PREFIX = "s2yt_live_";
|
||||
|
||||
export function hashApiKey(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function generateApiKeyMaterial() {
|
||||
const secret = randomBytes(24).toString("base64url");
|
||||
const token = `${API_KEY_PREFIX}${secret}`;
|
||||
return {
|
||||
token,
|
||||
hash: hashApiKey(token),
|
||||
prefix: token.slice(0, 20),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createUserApiKey(userId: string) {
|
||||
const { token, hash, prefix } = generateApiKeyMaterial();
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
apiKeyHash: hash,
|
||||
apiKeyPrefix: prefix,
|
||||
},
|
||||
});
|
||||
|
||||
return { token, prefix };
|
||||
}
|
||||
|
||||
export async function revokeUserApiKey(userId: string) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
apiKeyHash: null,
|
||||
apiKeyPrefix: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUserApiKeyStatus(userId: string) {
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { apiKeyPrefix: true, apiKeyHash: true },
|
||||
});
|
||||
|
||||
return {
|
||||
configured: Boolean(user.apiKeyHash),
|
||||
prefix: user.apiKeyPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findUserByApiKey(token: string) {
|
||||
if (!token.startsWith(API_KEY_PREFIX)) return null;
|
||||
|
||||
const hash = hashApiKey(token);
|
||||
return prisma.user.findFirst({
|
||||
where: { apiKeyHash: hash },
|
||||
include: { youtubeConnection: true },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import IORedis from "ioredis";
|
||||
import { prisma } from "./db";
|
||||
import { isSelfHostedEdition } from "./edition";
|
||||
|
||||
export const DEFAULT_API_RATE_LIMIT = 60;
|
||||
export const API_RATE_WINDOW_SECONDS = 60;
|
||||
export const MAX_ADMIN_API_RATE_BONUS = 120;
|
||||
const SELFHOSTED_API_RATE_LIMIT = 100_000;
|
||||
|
||||
type MemoryBucket = {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
};
|
||||
|
||||
const memoryBuckets = new Map<string, MemoryBucket>();
|
||||
let redis: IORedis | null | undefined;
|
||||
|
||||
function getRedis() {
|
||||
if (redis !== undefined) return redis;
|
||||
try {
|
||||
const url = process.env.REDIS_URL || "redis://localhost:6379";
|
||||
redis = new IORedis(url, {
|
||||
maxRetriesPerRequest: 1,
|
||||
enableOfflineQueue: false,
|
||||
lazyConnect: true,
|
||||
});
|
||||
return redis;
|
||||
} catch {
|
||||
redis = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function rateKey(userId: string) {
|
||||
return `s2yt:api-rate:${userId}`;
|
||||
}
|
||||
|
||||
export async function getEffectiveApiRateLimit(userId: string) {
|
||||
if (isSelfHostedEdition()) return SELFHOSTED_API_RATE_LIMIT;
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { apiRateLimitBonus: true, plan: true },
|
||||
});
|
||||
if (!user || user.plan !== "PREMIUM") return DEFAULT_API_RATE_LIMIT;
|
||||
return DEFAULT_API_RATE_LIMIT + Math.max(0, user.apiRateLimitBonus);
|
||||
}
|
||||
|
||||
function memoryStatus(userId: string, limit: number) {
|
||||
const now = Date.now();
|
||||
const bucket = memoryBuckets.get(userId);
|
||||
if (!bucket || now >= bucket.resetAt) {
|
||||
return {
|
||||
limit,
|
||||
used: 0,
|
||||
remaining: limit,
|
||||
windowSeconds: API_RATE_WINDOW_SECONDS,
|
||||
resetsInSeconds: API_RATE_WINDOW_SECONDS,
|
||||
bonus: Math.max(0, limit - DEFAULT_API_RATE_LIMIT),
|
||||
};
|
||||
}
|
||||
const used = Math.min(bucket.count, limit);
|
||||
return {
|
||||
limit,
|
||||
used,
|
||||
remaining: Math.max(0, limit - used),
|
||||
windowSeconds: API_RATE_WINDOW_SECONDS,
|
||||
resetsInSeconds: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
|
||||
bonus: Math.max(0, limit - DEFAULT_API_RATE_LIMIT),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getApiRateLimitStatus(userId: string) {
|
||||
const limit = await getEffectiveApiRateLimit(userId);
|
||||
const client = getRedis();
|
||||
if (!client) return memoryStatus(userId, limit);
|
||||
|
||||
try {
|
||||
if (client.status !== "ready") {
|
||||
await client.connect().catch(() => {});
|
||||
}
|
||||
const key = rateKey(userId);
|
||||
const [countRaw, ttl] = await Promise.all([client.get(key), client.ttl(key)]);
|
||||
const used = Math.min(Number(countRaw || 0), limit);
|
||||
return {
|
||||
limit,
|
||||
used,
|
||||
remaining: Math.max(0, limit - used),
|
||||
windowSeconds: API_RATE_WINDOW_SECONDS,
|
||||
resetsInSeconds: ttl > 0 ? ttl : API_RATE_WINDOW_SECONDS,
|
||||
bonus: Math.max(0, limit - DEFAULT_API_RATE_LIMIT),
|
||||
};
|
||||
} catch {
|
||||
return { ...memoryStatus(userId, limit), bonus: Math.max(0, limit - DEFAULT_API_RATE_LIMIT) };
|
||||
}
|
||||
}
|
||||
|
||||
function checkMemoryRateLimit(userId: string, limit: number) {
|
||||
const now = Date.now();
|
||||
const bucket = memoryBuckets.get(userId);
|
||||
|
||||
if (!bucket || now >= bucket.resetAt) {
|
||||
memoryBuckets.set(userId, {
|
||||
count: 1,
|
||||
resetAt: now + API_RATE_WINDOW_SECONDS * 1000,
|
||||
});
|
||||
return { ok: true as const, limit, remaining: limit - 1 };
|
||||
}
|
||||
|
||||
if (bucket.count >= limit) {
|
||||
return {
|
||||
ok: false as const,
|
||||
retryAfterSeconds: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
|
||||
limit,
|
||||
remaining: 0,
|
||||
};
|
||||
}
|
||||
|
||||
bucket.count += 1;
|
||||
return { ok: true as const, limit, remaining: Math.max(0, limit - bucket.count) };
|
||||
}
|
||||
|
||||
export async function checkApiRateLimit(userId: string) {
|
||||
if (isSelfHostedEdition()) {
|
||||
return { ok: true as const, limit: SELFHOSTED_API_RATE_LIMIT, remaining: SELFHOSTED_API_RATE_LIMIT };
|
||||
}
|
||||
const limit = await getEffectiveApiRateLimit(userId);
|
||||
const client = getRedis();
|
||||
if (!client) return checkMemoryRateLimit(userId, limit);
|
||||
|
||||
try {
|
||||
if (client.status !== "ready") {
|
||||
await client.connect().catch(() => {});
|
||||
}
|
||||
|
||||
const key = rateKey(userId);
|
||||
const count = await client.incr(key);
|
||||
if (count === 1) {
|
||||
await client.expire(key, API_RATE_WINDOW_SECONDS);
|
||||
}
|
||||
|
||||
if (count > limit) {
|
||||
const ttl = await client.ttl(key);
|
||||
return {
|
||||
ok: false as const,
|
||||
retryAfterSeconds: Math.max(1, ttl > 0 ? ttl : API_RATE_WINDOW_SECONDS),
|
||||
limit,
|
||||
remaining: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true as const, limit, remaining: Math.max(0, limit - count) };
|
||||
} catch {
|
||||
return checkMemoryRateLimit(userId, limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { parseFile } from "music-metadata";
|
||||
import type { ItemMetadata } from "./types";
|
||||
|
||||
export type AudioTagMetadata = {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
genre?: string;
|
||||
year?: string;
|
||||
};
|
||||
|
||||
export async function readAudioTags(filePath: string): Promise<AudioTagMetadata | null> {
|
||||
try {
|
||||
const { common } = await parseFile(filePath);
|
||||
if (!common.title && !common.artist && !common.album && !common.genre?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
title: common.title,
|
||||
artist: common.artist,
|
||||
album: common.album,
|
||||
genre: common.genre?.join(", "),
|
||||
year: common.year?.toString(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function audioTagsToMetadata(
|
||||
tags: AudioTagMetadata,
|
||||
fallbackTitle: string,
|
||||
): Pick<ItemMetadata, "title" | "description" | "tags" | "artist" | "songTitle"> {
|
||||
const songTitle = tags.title || fallbackTitle;
|
||||
const videoTitle =
|
||||
tags.artist && tags.title
|
||||
? `${tags.artist} - ${tags.title}`
|
||||
: tags.title || fallbackTitle;
|
||||
|
||||
const description = [tags.artist, tags.album, tags.year].filter(Boolean).join(" · ");
|
||||
const tagParts = [tags.genre, tags.artist].filter(Boolean);
|
||||
|
||||
return {
|
||||
title: videoTitle,
|
||||
songTitle,
|
||||
description,
|
||||
tags: tagParts.join(", "),
|
||||
artist: tags.artist || null,
|
||||
};
|
||||
}
|
||||
@@ -36,6 +36,7 @@ export const authOptions: NextAuthOptions = {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
monthlyCredits: 10,
|
||||
quotaResetAt: getInitialQuotaResetAt(),
|
||||
},
|
||||
update: {
|
||||
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
import { Plan } from "@prisma/client";
|
||||
import {
|
||||
EXTRA_CREDITS_MAX,
|
||||
FREE_TOP_UP_CREDITS,
|
||||
MAX_CREDIT_CAP,
|
||||
monthlyCreditsForPlan,
|
||||
} from "./credits";
|
||||
import { prisma } from "./db";
|
||||
import { getNextMonthlyQuotaReset } from "./plans";
|
||||
|
||||
export class CreditInsufficientError extends Error {
|
||||
readonly status = 402;
|
||||
constructor(message = "Payment Required: no credits remaining.") {
|
||||
super(message);
|
||||
this.name = "CreditInsufficientError";
|
||||
}
|
||||
}
|
||||
|
||||
export type DeductResult = {
|
||||
fromMonthly: number;
|
||||
fromExtra: number;
|
||||
};
|
||||
|
||||
export type RenewalBalances = {
|
||||
monthlyCredits: number;
|
||||
videosUsed: number;
|
||||
extraCredits: number;
|
||||
bonusQuota: number;
|
||||
trimmed: number;
|
||||
totalAfter: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Rollover + hard cap on renewal.
|
||||
* prospective = unused (monthly remaining + extras) + newMonthly
|
||||
* If prospective > MAX_CREDIT_CAP (30), trim excess (no refund).
|
||||
*
|
||||
* Storage: prefer filling the new monthly allocation first, leftover as extras.
|
||||
*/
|
||||
export function computeRenewalBalances(input: {
|
||||
monthlyCredits: number;
|
||||
videosUsed: number;
|
||||
bonusQuota?: number;
|
||||
extraCredits: number;
|
||||
newMonthlyCredits: number;
|
||||
maxCap?: number;
|
||||
}): RenewalBalances {
|
||||
const cap = input.maxCap ?? MAX_CREDIT_CAP;
|
||||
const bonus = input.bonusQuota ?? 0;
|
||||
const unusedMonthly = Math.max(0, input.monthlyCredits + bonus - input.videosUsed);
|
||||
const currentUnused = unusedMonthly + Math.max(0, input.extraCredits);
|
||||
const prospective = currentUnused + input.newMonthlyCredits;
|
||||
const totalAfter = Math.min(prospective, cap);
|
||||
const trimmed = Math.max(0, prospective - totalAfter);
|
||||
|
||||
const monthlyCredits = Math.min(input.newMonthlyCredits, totalAfter);
|
||||
const extraCredits = Math.max(0, totalAfter - monthlyCredits);
|
||||
|
||||
return {
|
||||
monthlyCredits,
|
||||
videosUsed: 0,
|
||||
extraCredits,
|
||||
bonusQuota: 0,
|
||||
trimmed,
|
||||
totalAfter,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply monthly credit renewal with MAX_CREDIT_CAP rollover trim.
|
||||
*/
|
||||
export async function applyMonthlyCreditRenewal(
|
||||
userId: string,
|
||||
opts: {
|
||||
plan?: Plan;
|
||||
newMonthlyCredits?: number;
|
||||
quotaResetAt?: Date;
|
||||
} = {},
|
||||
) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
const plan = opts.plan ?? user.plan;
|
||||
const newMonthly = opts.newMonthlyCredits ?? monthlyCreditsForPlan(plan);
|
||||
const balances = computeRenewalBalances({
|
||||
monthlyCredits: user.monthlyCredits,
|
||||
videosUsed: user.videosUsed,
|
||||
bonusQuota: user.bonusQuota,
|
||||
extraCredits: user.extraCredits,
|
||||
newMonthlyCredits: newMonthly,
|
||||
});
|
||||
|
||||
return tx.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
plan,
|
||||
monthlyCredits: balances.monthlyCredits,
|
||||
videosUsed: balances.videosUsed,
|
||||
extraCredits: balances.extraCredits,
|
||||
bonusQuota: balances.bonusQuota,
|
||||
quotaResetAt: opts.quotaResetAt ?? getNextMonthlyQuotaReset(),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduct video creation credits for `count` items.
|
||||
* Order: monthly allocation first, then extraCredits.
|
||||
*/
|
||||
export async function deductUserCredit(
|
||||
userId: string,
|
||||
count = 1,
|
||||
): Promise<DeductResult> {
|
||||
if (count <= 0) return { fromMonthly: 0, fromExtra: 0 };
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const rows = await tx.$queryRaw<
|
||||
Array<{
|
||||
id: string;
|
||||
plan: Plan;
|
||||
videosUsed: number;
|
||||
monthlyCredits: number;
|
||||
bonusQuota: number;
|
||||
videoCredits: number;
|
||||
}>
|
||||
>`SELECT id, plan, "videosUsed", "monthlyCredits", "bonusQuota", "videoCredits"
|
||||
FROM "User" WHERE id = ${userId} FOR UPDATE`;
|
||||
|
||||
const user = rows[0];
|
||||
if (!user) throw new Error("User not found");
|
||||
|
||||
const monthlyLimit = user.monthlyCredits + user.bonusQuota;
|
||||
const monthlyRemaining = Math.max(0, monthlyLimit - user.videosUsed);
|
||||
const extra = user.videoCredits;
|
||||
const total = monthlyRemaining + extra;
|
||||
|
||||
if (total < count) {
|
||||
throw new CreditInsufficientError(
|
||||
`Not enough credits. Available: ${total} (monthly remaining ${monthlyRemaining} + extras ${extra}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const fromMonthly = Math.min(count, monthlyRemaining);
|
||||
const fromExtra = count - fromMonthly;
|
||||
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
...(fromMonthly > 0 ? { videosUsed: { increment: fromMonthly } } : {}),
|
||||
...(fromExtra > 0 ? { extraCredits: { decrement: fromExtra } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return { fromMonthly, fromExtra };
|
||||
});
|
||||
}
|
||||
|
||||
/** Refund reserved credits (job create failure / worker failure). */
|
||||
export async function refundUserCredit(
|
||||
userId: string,
|
||||
fromMonthly: number,
|
||||
fromExtra: number,
|
||||
) {
|
||||
if (fromMonthly > 0) {
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "User"
|
||||
SET "videosUsed" = GREATEST(0, "videosUsed" - ${fromMonthly})
|
||||
WHERE id = ${userId}
|
||||
`;
|
||||
}
|
||||
if (fromExtra > 0) {
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "User"
|
||||
SET "videoCredits" = LEAST(${EXTRA_CREDITS_MAX}, "videoCredits" + ${fromExtra})
|
||||
WHERE id = ${userId}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin / support: reset or adjust a user's billing cycle and extras.
|
||||
*/
|
||||
export async function adminResetUserCredits(
|
||||
userId: string,
|
||||
options: {
|
||||
plan?: Plan;
|
||||
monthlyCredits?: number;
|
||||
creditsUsed?: number;
|
||||
extraCredits?: number;
|
||||
clearFreeTopUp?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
const plan = options.plan ?? user.plan;
|
||||
const monthlyCredits =
|
||||
options.monthlyCredits ?? monthlyCreditsForPlan(plan);
|
||||
|
||||
const extras =
|
||||
options.extraCredits !== undefined
|
||||
? Math.max(0, Math.min(EXTRA_CREDITS_MAX, options.extraCredits))
|
||||
: user.extraCredits;
|
||||
const used = options.creditsUsed ?? 0;
|
||||
const remainingMonthly = Math.max(0, monthlyCredits - used);
|
||||
const total = remainingMonthly + extras;
|
||||
const cappedExtras =
|
||||
total > MAX_CREDIT_CAP
|
||||
? Math.max(0, MAX_CREDIT_CAP - remainingMonthly)
|
||||
: extras;
|
||||
|
||||
return prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
plan,
|
||||
monthlyCredits,
|
||||
videosUsed: used,
|
||||
extraCredits: cappedExtras,
|
||||
...(options.clearFreeTopUp ? { freeTopUpPurchased: false } : {}),
|
||||
quotaResetAt: getNextMonthlyQuotaReset(),
|
||||
...(plan === "FREE"
|
||||
? {
|
||||
stripeSubscriptionId: null,
|
||||
subscribedAt: null,
|
||||
cardLast4: null,
|
||||
bonusQuota: 0,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function activateProPlan(
|
||||
userId: string,
|
||||
opts: {
|
||||
stripeCustomerId?: string | null;
|
||||
stripeSubscriptionId?: string | null;
|
||||
cardLast4?: string | null;
|
||||
} = {},
|
||||
) {
|
||||
await applyMonthlyCreditRenewal(userId, {
|
||||
plan: "PREMIUM",
|
||||
newMonthlyCredits: monthlyCreditsForPlan("PREMIUM"),
|
||||
});
|
||||
|
||||
return prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
plan: "PREMIUM",
|
||||
subscribedAt: new Date(),
|
||||
...(opts.stripeCustomerId !== undefined
|
||||
? { stripeCustomerId: opts.stripeCustomerId }
|
||||
: {}),
|
||||
...(opts.stripeSubscriptionId !== undefined
|
||||
? { stripeSubscriptionId: opts.stripeSubscriptionId }
|
||||
: {}),
|
||||
...(opts.cardLast4 !== undefined ? { cardLast4: opts.cardLast4 } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function downgradeToFreePlan(userId: string) {
|
||||
await applyMonthlyCreditRenewal(userId, {
|
||||
plan: "FREE",
|
||||
newMonthlyCredits: monthlyCreditsForPlan("FREE"),
|
||||
});
|
||||
|
||||
return prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
plan: "FREE",
|
||||
stripeSubscriptionId: null,
|
||||
subscribedAt: null,
|
||||
cardLast4: null,
|
||||
apiKeyHash: null,
|
||||
apiKeyPrefix: null,
|
||||
apiRateLimitBonus: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function grantExtraCredits(userId: string, credits: number) {
|
||||
if (credits <= 0) return;
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
const monthlyRemaining = Math.max(
|
||||
0,
|
||||
user.monthlyCredits + user.bonusQuota - user.videosUsed,
|
||||
);
|
||||
const room = Math.max(0, MAX_CREDIT_CAP - monthlyRemaining - user.extraCredits);
|
||||
const grant = Math.min(credits, room);
|
||||
if (grant <= 0) return;
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { extraCredits: { increment: grant } },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function markFreeTopUpPurchased(userId: string) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { freeTopUpPurchased: true },
|
||||
});
|
||||
await grantExtraCredits(userId, FREE_TOP_UP_CREDITS);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const BRAND_NAME = "Songs2VID";
|
||||
export const BRAND_DOMAIN = "songs2vid.com";
|
||||
export const VIDEO_ATTRIBUTION_PREFIX = "Uploaded through";
|
||||
|
||||
export function getVideoAttributionText() {
|
||||
return `${VIDEO_ATTRIBUTION_PREFIX} ${BRAND_NAME}.com`;
|
||||
}
|
||||
+4
-2
@@ -1,10 +1,12 @@
|
||||
export const FREE_PLAN = {
|
||||
monthlyQuota: 14,
|
||||
monthlyQuota: 10,
|
||||
maxFileSizeBytes: 30 * 1024 * 1024,
|
||||
watermarkRequired: true,
|
||||
maxBatchSize: 3,
|
||||
watermarkOptional: true,
|
||||
} as const;
|
||||
|
||||
export const RESOLUTIONS = [
|
||||
{ value: "1920x1080", label: "1920x1080 (16:9)", width: 1920, height: 1080 },
|
||||
{ value: "1280x720", label: "1280x720 (16:9)", width: 1280, height: 720 },
|
||||
{ value: "854x480", label: "854x480 (16:9)", width: 854, height: 480 },
|
||||
{ value: "720x720", label: "720x720 (1:1)", width: 720, height: 720 },
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Billing / credit constants for Songs2VID hybrid pricing.
|
||||
* Plan enum in DB: FREE | PREMIUM (Pro = €5/mo "starter_5eur").
|
||||
*/
|
||||
|
||||
export const CREDIT_CURRENCY = "eur";
|
||||
|
||||
/** Free monthly allocation */
|
||||
export const FREE_MONTHLY_CREDITS = 10;
|
||||
|
||||
/** Pro monthly allocation */
|
||||
export const PRO_MONTHLY_CREDITS = 50;
|
||||
|
||||
/** Free-tier top-up: buyer picks 1–15 credits */
|
||||
export const FREE_TOP_UP_MIN = 1;
|
||||
export const FREE_TOP_UP_MAX = 15;
|
||||
|
||||
/** €0.25 per extra credit */
|
||||
export const CREDIT_PRICE_CENTS = 25;
|
||||
|
||||
/** @deprecated use FREE_TOP_UP_MAX */
|
||||
export const FREE_TOP_UP_CREDITS = FREE_TOP_UP_MAX;
|
||||
|
||||
/** @deprecated use creditPurchaseTotalCents(FREE_TOP_UP_MAX) */
|
||||
export const FREE_TOP_UP_PRICE_CENTS = FREE_TOP_UP_MAX * CREDIT_PRICE_CENTS;
|
||||
|
||||
/** Pro subscription €5 / month */
|
||||
export const PRO_PRICE_CENTS = 500;
|
||||
|
||||
/** Soft cap on never-expiring extra credits (Pro / legacy storage bound) */
|
||||
export const EXTRA_CREDITS_MAX = 1000;
|
||||
|
||||
/**
|
||||
* Hard cap on total accumulated credits (monthly remaining + extras).
|
||||
* On renewal, unused + new monthly is trimmed to this threshold.
|
||||
*/
|
||||
export const MAX_CREDIT_CAP = 30;
|
||||
|
||||
/**
|
||||
* Free plan: extras balance cannot exceed 15.
|
||||
* After monthly (10) + extras (≤15) are used, upgrade to Pro is required.
|
||||
*/
|
||||
export const FREE_EXTRA_CREDITS_MAX = FREE_TOP_UP_MAX;
|
||||
|
||||
/** Stripe Tax / Managed Payments SaaS personal use */
|
||||
export const STRIPE_PRODUCT_TAX_CODE = "txcd_10103000";
|
||||
|
||||
export const CREDIT_PURCHASE_MIN = FREE_TOP_UP_MIN;
|
||||
export const CREDIT_PURCHASE_MAX = FREE_TOP_UP_MAX;
|
||||
export const CREDIT_BALANCE_MAX = EXTRA_CREDITS_MAX;
|
||||
|
||||
/** Shown in quota UI errors so UpgradeProLink can attach a CTA */
|
||||
export const PRO_UPGRADE_REQUIRED_SUFFIX = " Upload up to 50 videos with Pro!";
|
||||
|
||||
export function monthlyCreditsForPlan(plan: "FREE" | "PREMIUM"): number {
|
||||
return plan === "PREMIUM" ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS;
|
||||
}
|
||||
|
||||
export function freeExtraCreditsCap(): number {
|
||||
return FREE_EXTRA_CREDITS_MAX;
|
||||
}
|
||||
|
||||
export function formatEuroFromCents(cents: number): string {
|
||||
const amount = cents / 100;
|
||||
const formatted = amount % 1 === 0 ? String(amount) : amount.toFixed(2);
|
||||
return `€${formatted}`;
|
||||
}
|
||||
|
||||
export function creditPurchaseTotalCents(credits: number): number {
|
||||
return credits * CREDIT_PRICE_CENTS;
|
||||
}
|
||||
|
||||
export function formatCreditPrice(credits = 1): string {
|
||||
return formatEuroFromCents(creditPurchaseTotalCents(credits));
|
||||
}
|
||||
|
||||
export function validateFreeTopUpAmount(
|
||||
credits: number,
|
||||
currentExtraBalance: number,
|
||||
monthlyRemaining = 0,
|
||||
): { ok: true; credits: number; amountCents: number } | { ok: false; error: string } {
|
||||
if (!Number.isInteger(credits)) {
|
||||
return { ok: false, error: "Credit amount must be a whole number." };
|
||||
}
|
||||
if (credits < FREE_TOP_UP_MIN || credits > FREE_TOP_UP_MAX) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `You can buy between ${FREE_TOP_UP_MIN} and ${FREE_TOP_UP_MAX} credits per top-up.`,
|
||||
};
|
||||
}
|
||||
if (currentExtraBalance >= FREE_EXTRA_CREDITS_MAX) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Free plan extras are capped at ${FREE_EXTRA_CREDITS_MAX}. Upgrade to Pro for 50 videos every month.${PRO_UPGRADE_REQUIRED_SUFFIX}`,
|
||||
};
|
||||
}
|
||||
const freeExtraRoom = FREE_EXTRA_CREDITS_MAX - currentExtraBalance;
|
||||
const totalRoom = Math.max(0, MAX_CREDIT_CAP - monthlyRemaining - currentExtraBalance);
|
||||
const room = Math.min(freeExtraRoom, totalRoom);
|
||||
if (room <= 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Your total credit balance cannot exceed ${MAX_CREDIT_CAP}. Upgrade to Pro or use existing credits first.${PRO_UPGRADE_REQUIRED_SUFFIX}`,
|
||||
};
|
||||
}
|
||||
if (credits > room) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `You can buy up to ${room} more credits under the ${MAX_CREDIT_CAP}-credit account cap.`,
|
||||
};
|
||||
}
|
||||
return { ok: true, credits, amountCents: creditPurchaseTotalCents(credits) };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
|
||||
|
||||
const ENC_PREFIX = "enc:v1:";
|
||||
|
||||
function getEncryptionKey() {
|
||||
const secret = process.env.TOKEN_ENCRYPTION_KEY || process.env.NEXTAUTH_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("TOKEN_ENCRYPTION_KEY or NEXTAUTH_SECRET must be set to encrypt secrets");
|
||||
}
|
||||
return createHash("sha256").update(secret).digest();
|
||||
}
|
||||
|
||||
/** Encrypt a secret for DB storage (AES-256-GCM). Idempotent if already encrypted. */
|
||||
export function encryptSecret(plaintext: string): string {
|
||||
if (!plaintext) return plaintext;
|
||||
if (plaintext.startsWith(ENC_PREFIX)) return plaintext;
|
||||
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", getEncryptionKey(), iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return [
|
||||
ENC_PREFIX,
|
||||
iv.toString("base64url"),
|
||||
".",
|
||||
tag.toString("base64url"),
|
||||
".",
|
||||
encrypted.toString("base64url"),
|
||||
].join("");
|
||||
}
|
||||
|
||||
/** Decrypt a stored secret. Legacy plaintext values are returned as-is. */
|
||||
export function decryptSecret(value: string): string {
|
||||
if (!value) return value;
|
||||
if (!value.startsWith(ENC_PREFIX)) return value;
|
||||
|
||||
const payload = value.slice(ENC_PREFIX.length);
|
||||
const [ivB64, tagB64, dataB64] = payload.split(".");
|
||||
if (!ivB64 || !tagB64 || !dataB64) {
|
||||
throw new Error("Invalid encrypted secret format");
|
||||
}
|
||||
|
||||
const decipher = createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
getEncryptionKey(),
|
||||
Buffer.from(ivB64, "base64url"),
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(tagB64, "base64url"));
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(Buffer.from(dataB64, "base64url")),
|
||||
decipher.final(),
|
||||
]);
|
||||
return decrypted.toString("utf8");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Plan } from "@prisma/client";
|
||||
|
||||
export function isSelfHostedEdition(): boolean {
|
||||
return process.env.S2VID_EDITION === "selfhosted";
|
||||
}
|
||||
|
||||
export function hasProFeatures(plan: Plan): boolean {
|
||||
return isSelfHostedEdition() || plan === "PREMIUM";
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Plan } from "@prisma/client";
|
||||
import { getPlanLimits } from "./plans";
|
||||
import { hasProFeatures } from "./edition";
|
||||
import {
|
||||
requiresArtTrackLayoutEntitlement,
|
||||
type LayoutSettings,
|
||||
} from "./layout";
|
||||
import {
|
||||
PREMIUM_REQUIRED_CODE,
|
||||
requiresCustomWatermarkEntitlement,
|
||||
type WatermarkSettings,
|
||||
} from "./watermark";
|
||||
|
||||
export class PremiumRequiredError extends Error {
|
||||
readonly code = PREMIUM_REQUIRED_CODE;
|
||||
readonly status = 403;
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "PremiumRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
export function assertCustomWatermarkAllowed(plan: Plan, settings: WatermarkSettings) {
|
||||
if (!requiresCustomWatermarkEntitlement(settings)) return;
|
||||
if (getPlanLimits(plan).customWatermark || hasProFeatures(plan)) return;
|
||||
throw new PremiumRequiredError(
|
||||
"Custom watermark, typography, logo overlay, and position controls require Pro.",
|
||||
);
|
||||
}
|
||||
|
||||
export function assertArtTrackLayoutAllowed(plan: Plan, settings: LayoutSettings) {
|
||||
if (!requiresArtTrackLayoutEntitlement(settings)) return;
|
||||
if (getPlanLimits(plan).artTrackLayouts || hasProFeatures(plan)) return;
|
||||
throw new PremiumRequiredError(
|
||||
"Blurred backgrounds and art-track layout templates require Pro.",
|
||||
);
|
||||
}
|
||||
|
||||
export function assertPerItemImagesAllowed(
|
||||
plan: Plan,
|
||||
sharedImagePath: string,
|
||||
itemImagePaths: Array<string | null | undefined>,
|
||||
) {
|
||||
const unique = new Set(
|
||||
itemImagePaths
|
||||
.map((p) => (p && p.trim() ? p.trim() : sharedImagePath))
|
||||
.filter(Boolean),
|
||||
);
|
||||
// More than one distinct cover in the batch → Pro
|
||||
if (unique.size <= 1) return;
|
||||
if (getPlanLimits(plan).perItemImages || hasProFeatures(plan)) return;
|
||||
throw new PremiumRequiredError(
|
||||
"Matching a unique image to each audio file requires Pro. Free plan uses one shared cover image.",
|
||||
);
|
||||
}
|
||||
|
||||
export function premiumRequiredResponse(message: string) {
|
||||
return {
|
||||
error: message,
|
||||
code: PREMIUM_REQUIRED_CODE,
|
||||
};
|
||||
}
|
||||
+210
-33
@@ -1,8 +1,98 @@
|
||||
import { spawn } from "child_process";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import ffmpegStatic from "ffmpeg-static";
|
||||
import { getVideoAttributionText } from "../branding";
|
||||
import { getResolution } from "../constants";
|
||||
import {
|
||||
isCuratedFontKey,
|
||||
sanitizeFontfileForFilter,
|
||||
} from "../fonts";
|
||||
import { resolveCuratedFontPath } from "../fonts-server";
|
||||
import {
|
||||
buildArtTrackFilterComplex,
|
||||
type LayoutSettings,
|
||||
} from "../layout";
|
||||
import { getWatermarkPath } from "../storage";
|
||||
import {
|
||||
buildDrawtextFilter,
|
||||
normalizeWatermarkSettings,
|
||||
overlayXy,
|
||||
sanitizeDrawtext,
|
||||
type WatermarkSettings,
|
||||
} from "../watermark";
|
||||
|
||||
function getFfmpegPath(): string {
|
||||
if (process.env.FFMPEG_PATH) return process.env.FFMPEG_PATH;
|
||||
if (ffmpegStatic) return ffmpegStatic;
|
||||
return "ffmpeg";
|
||||
}
|
||||
|
||||
export { getFfmpegPath };
|
||||
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify PNG magic bytes (prevents MIME spoof → FFmpeg surprises). */
|
||||
export async function assertPngFile(filePath: string): Promise<void> {
|
||||
const fh = await fs.open(filePath, "r");
|
||||
try {
|
||||
const buf = Buffer.alloc(8);
|
||||
await fh.read(buf, 0, 8, 0);
|
||||
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
if (!buf.equals(sig)) {
|
||||
throw new Error("Logo must be a valid PNG file");
|
||||
}
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveFontfileEscaped(
|
||||
settings: WatermarkSettings,
|
||||
): Promise<string | null> {
|
||||
const key = settings.fontKey ?? "system";
|
||||
if (key === "system") return null;
|
||||
|
||||
if (key === "custom") {
|
||||
if (!settings.fontPath) return null;
|
||||
if (!(await fileExists(settings.fontPath))) {
|
||||
throw new Error("Custom watermark font file not found");
|
||||
}
|
||||
return sanitizeFontfileForFilter(path.resolve(settings.fontPath));
|
||||
}
|
||||
|
||||
if (isCuratedFontKey(key)) {
|
||||
const fontPath = resolveCuratedFontPath(key);
|
||||
if (!(await fileExists(fontPath))) {
|
||||
console.warn(`[ffmpeg] curated font missing: ${key} at ${fontPath}; using system font`);
|
||||
return null;
|
||||
}
|
||||
return sanitizeFontfileForFilter(fontPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Art-track filter that ends at `outLabel` instead of hardcoded [laid].
|
||||
*/
|
||||
function artTrackFilterEndingAt(
|
||||
opts: Parameters<typeof buildArtTrackFilterComplex>[0],
|
||||
outLabel: string,
|
||||
): string {
|
||||
return buildArtTrackFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`);
|
||||
}
|
||||
|
||||
export async function encodeVideo(options: {
|
||||
imagePath: string;
|
||||
@@ -10,62 +100,148 @@ export async function encodeVideo(options: {
|
||||
outputPath: string;
|
||||
resolution: string;
|
||||
includeWatermark: boolean;
|
||||
watermark?: Partial<WatermarkSettings> | null;
|
||||
layout?: LayoutSettings | null;
|
||||
/** On-video song title (art-track layouts). */
|
||||
songTitle?: string;
|
||||
artist?: string | null;
|
||||
}): Promise<void> {
|
||||
const res = getResolution(options.resolution);
|
||||
if (!res) throw new Error(`Invalid resolution: ${options.resolution}`);
|
||||
|
||||
await fs.mkdir(path.dirname(options.outputPath), { recursive: true });
|
||||
|
||||
const scaleFilter = `scale=${res.width}:${res.height}:force_original_aspect_ratio=decrease,pad=${res.width}:${res.height}:(ow-iw)/2:(oh-ih)/2:black`;
|
||||
const settings = normalizeWatermarkSettings(options.watermark, options.includeWatermark);
|
||||
const layout = options.layout?.template ? options.layout : null;
|
||||
const watermarkWidth = Math.max(1, Math.round(res.width * 0.32));
|
||||
const fontSize = Math.max(16, Math.round(res.width * 0.018));
|
||||
const fontfile = await resolveFontfileEscaped(settings);
|
||||
|
||||
const watermarkPath = getWatermarkPath();
|
||||
let watermarkExists = false;
|
||||
try {
|
||||
await fs.access(watermarkPath);
|
||||
watermarkExists = true;
|
||||
} catch {
|
||||
watermarkExists = false;
|
||||
const args = ["-y", "-loop", "1", "-r", "1", "-i", options.imagePath, "-i", options.audioPath];
|
||||
|
||||
let nextInput = 2;
|
||||
let logoInputIndex: number | null = null;
|
||||
let defaultWmInputIndex: number | null = null;
|
||||
|
||||
const needsLogo = settings.mode === "logo" && Boolean(settings.logoPath);
|
||||
const defaultPath = getWatermarkPath();
|
||||
const useDefaultPng =
|
||||
settings.mode === "default" && (await fileExists(defaultPath));
|
||||
|
||||
if (needsLogo && settings.logoPath) {
|
||||
if (!(await fileExists(settings.logoPath))) {
|
||||
throw new Error("Watermark logo file not found");
|
||||
}
|
||||
await assertPngFile(settings.logoPath);
|
||||
args.push("-i", settings.logoPath);
|
||||
logoInputIndex = nextInput++;
|
||||
} else if (useDefaultPng) {
|
||||
args.push("-i", defaultPath);
|
||||
defaultWmInputIndex = nextInput++;
|
||||
}
|
||||
|
||||
const useWatermark = options.includeWatermark && watermarkExists;
|
||||
const applyWm =
|
||||
settings.mode === "logo" && logoInputIndex !== null
|
||||
? ("logo" as const)
|
||||
: settings.mode === "text" && settings.text?.trim()
|
||||
? ("text" as const)
|
||||
: settings.mode === "default" && defaultWmInputIndex !== null
|
||||
? ("default-png" as const)
|
||||
: settings.mode === "default"
|
||||
? ("default-text" as const)
|
||||
: ("none" as const);
|
||||
|
||||
const args = ["-y", "-loop", "1", "-i", options.imagePath, "-i", options.audioPath];
|
||||
|
||||
if (useWatermark) {
|
||||
args.push("-i", watermarkPath);
|
||||
// Fast path: classic letterbox, no watermark
|
||||
if (!layout && applyWm === "none") {
|
||||
args.push("-vf", classicScaleFilter(res.width, res.height));
|
||||
args.push(
|
||||
"-filter_complex",
|
||||
`[0:v]${scaleFilter}[scaled];[2:v]scale=iw*0.15:-1[wm];[scaled][wm]overlay=W-w-20:H-h-20[vout]`,
|
||||
"-map",
|
||||
"[vout]",
|
||||
"-map",
|
||||
"1:a",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-tune",
|
||||
"stillimage",
|
||||
"-c:a",
|
||||
"copy",
|
||||
"-shortest",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
options.outputPath,
|
||||
);
|
||||
} else if (options.includeWatermark && !watermarkExists) {
|
||||
args.push(
|
||||
"-filter_complex",
|
||||
`[0:v]${scaleFilter},drawtext=text='s2yt':fontsize=24:fontcolor=white@0.7:x=w-tw-20:y=h-th-20[vout]`,
|
||||
"-map",
|
||||
"[vout]",
|
||||
"-map",
|
||||
"1:a",
|
||||
await runFfmpeg(args);
|
||||
return;
|
||||
}
|
||||
|
||||
const outDirect = applyWm === "none";
|
||||
const baseLabel = outDirect ? "vout" : "base";
|
||||
const filterParts: string[] = [];
|
||||
|
||||
if (layout) {
|
||||
const titleEscaped = sanitizeDrawtext(options.songTitle?.trim() || "Untitled");
|
||||
const artistRaw = options.artist?.trim();
|
||||
const artistEscaped = artistRaw ? sanitizeDrawtext(artistRaw) : null;
|
||||
filterParts.push(
|
||||
artTrackFilterEndingAt(
|
||||
{
|
||||
width: res.width,
|
||||
height: res.height,
|
||||
layout,
|
||||
titleEscaped,
|
||||
artistEscaped,
|
||||
fontfileEscaped: fontfile,
|
||||
},
|
||||
baseLabel,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
args.push("-vf", scaleFilter);
|
||||
filterParts.push(`[0:v]${classicScaleFilter(res.width, res.height)}[${baseLabel}]`);
|
||||
}
|
||||
|
||||
if (applyWm === "logo" && logoInputIndex !== null) {
|
||||
const { x, y } = overlayXy(settings.position, settings.offsetX, settings.offsetY);
|
||||
filterParts.push(
|
||||
`[${logoInputIndex}:v]scale=${watermarkWidth}:-1[wm]`,
|
||||
`[${baseLabel}][wm]overlay=${x}:${y}[vout]`,
|
||||
);
|
||||
} else if (applyWm === "text") {
|
||||
const draw = buildDrawtextFilter({
|
||||
text: settings.text!.trim(),
|
||||
fontSize,
|
||||
fontColor: "white@0.9",
|
||||
position: settings.position,
|
||||
offsetX: settings.offsetX,
|
||||
offsetY: settings.offsetY,
|
||||
fontfileEscaped: fontfile,
|
||||
});
|
||||
filterParts.push(`[${baseLabel}]${draw}[vout]`);
|
||||
} else if (applyWm === "default-png" && defaultWmInputIndex !== null) {
|
||||
const { x, y } = overlayXy(settings.position, settings.offsetX, settings.offsetY);
|
||||
filterParts.push(
|
||||
`[${defaultWmInputIndex}:v]scale=${watermarkWidth}:-1[wm]`,
|
||||
`[${baseLabel}][wm]overlay=${x}:${y}[vout]`,
|
||||
);
|
||||
} else if (applyWm === "default-text") {
|
||||
const draw = buildDrawtextFilter({
|
||||
text: getVideoAttributionText(),
|
||||
fontSize,
|
||||
fontColor: "white@0.85",
|
||||
position: settings.position,
|
||||
offsetX: settings.offsetX,
|
||||
offsetY: settings.offsetY,
|
||||
fontfileEscaped: null,
|
||||
});
|
||||
filterParts.push(`[${baseLabel}]${draw}[vout]`);
|
||||
}
|
||||
|
||||
args.push("-filter_complex", filterParts.join(";"), "-map", "[vout]", "-map", "1:a");
|
||||
args.push(
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-tune",
|
||||
"stillimage",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
"copy",
|
||||
"-shortest",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-shortest",
|
||||
options.outputPath,
|
||||
);
|
||||
|
||||
@@ -74,10 +250,11 @@ export async function encodeVideo(options: {
|
||||
|
||||
function runFfmpeg(args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
const proc = spawn(getFfmpegPath(), args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stderr = "";
|
||||
proc.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
if (stderr.length > 64_000) stderr = stderr.slice(-32_000);
|
||||
});
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) resolve();
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/** Server-only font filesystem helpers. Do not import from client components. */
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { CURATED_FONTS, type CuratedFontKey } from "./fonts";
|
||||
|
||||
export function getFontsDir(): string {
|
||||
return path.join(process.cwd(), "assets", "fonts");
|
||||
}
|
||||
|
||||
/** Resolve a curated font file on disk (must exist under assets/fonts). */
|
||||
export function resolveCuratedFontPath(key: CuratedFontKey): string {
|
||||
const meta = CURATED_FONTS.find((f) => f.key === key);
|
||||
if (!meta) throw new Error("Unknown font");
|
||||
// Whitelist filename only never accept user-controlled path segments
|
||||
const safe = meta.file.replace(/[^a-zA-Z0-9._-]/g, "");
|
||||
if (safe !== meta.file) throw new Error("Invalid font asset name");
|
||||
return path.join(getFontsDir(), safe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate TTF / OTF magic bytes.
|
||||
* TTF: 00 01 00 00 | true | typ1
|
||||
* OTF: OTTO
|
||||
*/
|
||||
export async function assertFontFile(filePath: string): Promise<"ttf" | "otf"> {
|
||||
const fh = await fs.open(filePath, "r");
|
||||
try {
|
||||
const buf = Buffer.alloc(4);
|
||||
await fh.read(buf, 0, 4, 0);
|
||||
const asStr = buf.toString("ascii");
|
||||
if (asStr === "OTTO") return "otf";
|
||||
if (asStr === "true" || asStr === "typ1") return "ttf";
|
||||
if (buf[0] === 0x00 && buf[1] === 0x01 && buf[2] === 0x00 && buf[3] === 0x00) {
|
||||
return "ttf";
|
||||
}
|
||||
if (asStr === "wOFF" || asStr === "wOF2") {
|
||||
throw new Error("WOFF fonts are not supported. Upload a .ttf or .otf file.");
|
||||
}
|
||||
throw new Error("File is not a valid TTF or OTF font");
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/** Shared font catalog safe for client and server bundles (no Node APIs). */
|
||||
|
||||
/** Max custom font upload size (10 MB). */
|
||||
export const FONT_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export const CURATED_FONTS = [
|
||||
{
|
||||
key: "inter",
|
||||
label: "Inter",
|
||||
cssFamily: "Inter",
|
||||
googleCss: "Inter:wght@400;600",
|
||||
file: "Inter-Regular.ttf",
|
||||
},
|
||||
{
|
||||
key: "montserrat",
|
||||
label: "Montserrat",
|
||||
cssFamily: "Montserrat",
|
||||
googleCss: "Montserrat:wght@400;600",
|
||||
file: "Montserrat-Regular.ttf",
|
||||
},
|
||||
{
|
||||
key: "roboto",
|
||||
label: "Roboto",
|
||||
cssFamily: "Roboto",
|
||||
googleCss: "Roboto:wght@400;500",
|
||||
file: "Roboto-Regular.ttf",
|
||||
},
|
||||
{
|
||||
key: "oswald",
|
||||
label: "Oswald",
|
||||
cssFamily: "Oswald",
|
||||
googleCss: "Oswald:wght@400;500",
|
||||
file: "Oswald-Regular.ttf",
|
||||
},
|
||||
{
|
||||
key: "playfair",
|
||||
label: "Playfair Display",
|
||||
cssFamily: "Playfair Display",
|
||||
googleCss: "Playfair+Display:wght@400;600",
|
||||
file: "PlayfairDisplay-Regular.ttf",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type CuratedFontKey = (typeof CURATED_FONTS)[number]["key"];
|
||||
export type WatermarkFontKey = CuratedFontKey | "custom" | "system";
|
||||
|
||||
const CURATED_KEYS = new Set<string>(CURATED_FONTS.map((f) => f.key));
|
||||
|
||||
export function isCuratedFontKey(v: unknown): v is CuratedFontKey {
|
||||
return typeof v === "string" && CURATED_KEYS.has(v);
|
||||
}
|
||||
|
||||
export function isWatermarkFontKey(v: unknown): v is WatermarkFontKey {
|
||||
return v === "custom" || v === "system" || isCuratedFontKey(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape an absolute font path for use inside an FFmpeg filtergraph `fontfile=` value.
|
||||
* Paths are never passed through a shell; this only escapes filter special chars.
|
||||
*/
|
||||
export function sanitizeFontfileForFilter(absPath: string): string {
|
||||
return absPath
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/:/g, "\\:")
|
||||
.replace(/'/g, "\\'")
|
||||
.replace(/\[/g, "\\[")
|
||||
.replace(/\]/g, "\\]");
|
||||
}
|
||||
|
||||
export function googleFontsStylesheetUrl(keys: CuratedFontKey[]): string {
|
||||
const families = CURATED_FONTS.filter((f) => keys.includes(f.key))
|
||||
.map((f) => `family=${f.googleCss}`)
|
||||
.join("&");
|
||||
return `https://fonts.googleapis.com/css2?${families}&display=swap`;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createWriteStream } from "fs";
|
||||
import fs from "fs/promises";
|
||||
import { Readable } from "stream";
|
||||
import { pipeline } from "stream/promises";
|
||||
|
||||
export async function writeUploadedFile(file: File, destPath: string) {
|
||||
const webStream = file.stream();
|
||||
const nodeStream = Readable.fromWeb(webStream as Parameters<typeof Readable.fromWeb>[0]);
|
||||
await pipeline(nodeStream, createWriteStream(destPath));
|
||||
}
|
||||
|
||||
export async function moveFile(src: string, dest: string) {
|
||||
try {
|
||||
await fs.rename(src, dest);
|
||||
} catch (err) {
|
||||
const code = err && typeof err === "object" && "code" in err ? err.code : null;
|
||||
if (code === "EXDEV") {
|
||||
await fs.copyFile(src, dest);
|
||||
await fs.unlink(src).catch(() => {});
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
fn: (item: T, index: number) => Promise<R>,
|
||||
) {
|
||||
const results: R[] = new Array(items.length);
|
||||
let nextIndex = 0;
|
||||
|
||||
async function worker() {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[index] = await fn(items[index], index);
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker());
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { Plan, Privacy } from "@prisma/client";
|
||||
import { readAudioTags } from "../audio-tags";
|
||||
import { filenameWithoutExtension, isAllowedResolution } from "../constants";
|
||||
import { prisma } from "../db";
|
||||
import { hasProFeatures } from "../edition";
|
||||
import {
|
||||
assertArtTrackLayoutAllowed,
|
||||
assertCustomWatermarkAllowed,
|
||||
assertPerItemImagesAllowed,
|
||||
PremiumRequiredError,
|
||||
} from "../entitlements";
|
||||
import { FONT_UPLOAD_MAX_BYTES } from "../fonts";
|
||||
import { assertFontFile } from "../fonts-server";
|
||||
import { assertPngFile } from "../ffmpeg/encode";
|
||||
import { moveFile, writeUploadedFile } from "../fs-utils";
|
||||
import {
|
||||
ARTIST_MAX,
|
||||
INVALID_LAYOUT_TEMPLATE_MESSAGE,
|
||||
normalizeLayoutSettings,
|
||||
SONG_TITLE_MAX,
|
||||
type LayoutSettings,
|
||||
} from "../layout";
|
||||
import { enqueueVideoJob } from "../queue/client";
|
||||
import {
|
||||
getPlanLimits,
|
||||
isAudioExtensionAllowed,
|
||||
isResolutionAllowedForPlan,
|
||||
} from "../plans";
|
||||
import { releaseReservationSplit, reserveQuota } from "../quota";
|
||||
import { getJobDir } from "../storage";
|
||||
import {
|
||||
assertPathInUserUploads,
|
||||
getUserStagingDir,
|
||||
sanitizeUploadSessionKey,
|
||||
} from "../upload-paths";
|
||||
import type { CreateJobPayload, ItemMetadata } from "../types";
|
||||
import { resolveBurnedSongTitle, resolveYouTubeTitle, validateYouTubeTitle } from "../titles";
|
||||
import {
|
||||
normalizeWatermarkSettings,
|
||||
WATERMARK_TEXT_MAX,
|
||||
} from "../watermark";
|
||||
|
||||
/** Merge nested + flat layout fields, then normalize (throws on bad template / coords). */
|
||||
export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSettings {
|
||||
return normalizeLayoutSettings({
|
||||
...(metadata.layout ?? {}),
|
||||
template:
|
||||
metadata.layout?.template ??
|
||||
metadata.layout?.layoutTemplate ??
|
||||
metadata.layout?.layout_template ??
|
||||
metadata.layoutTemplate ??
|
||||
metadata.layout_template,
|
||||
blurAmount:
|
||||
metadata.layout?.blurAmount ??
|
||||
metadata.layout?.blur_amount ??
|
||||
metadata.blurAmount ??
|
||||
metadata.blur_amount,
|
||||
blurOpacity:
|
||||
metadata.layout?.blurOpacity ??
|
||||
(metadata.layout as { blur_opacity?: number } | null | undefined)?.blur_opacity ??
|
||||
metadata.blurOpacity ??
|
||||
metadata.blur_opacity,
|
||||
textPadding:
|
||||
metadata.layout?.textPadding ??
|
||||
metadata.layout?.text_padding ??
|
||||
metadata.textPadding ??
|
||||
metadata.text_padding,
|
||||
titleArtistGap:
|
||||
metadata.layout?.titleArtistGap ??
|
||||
(metadata.layout as { title_artist_gap?: number } | null | undefined)?.title_artist_gap ??
|
||||
metadata.titleArtistGap ??
|
||||
metadata.title_artist_gap,
|
||||
textOffsetX:
|
||||
metadata.layout?.textOffsetX ??
|
||||
(metadata.layout as { text_offset_x?: number } | null | undefined)?.text_offset_x ??
|
||||
metadata.textOffsetX ??
|
||||
metadata.text_offset_x,
|
||||
textOffsetY:
|
||||
metadata.layout?.textOffsetY ??
|
||||
(metadata.layout as { text_offset_y?: number } | null | undefined)?.text_offset_y ??
|
||||
metadata.textOffsetY ??
|
||||
metadata.text_offset_y,
|
||||
});
|
||||
}
|
||||
|
||||
export function validateItemMetadata(
|
||||
metadata: CreateJobPayload["items"][0]["metadata"],
|
||||
plan: Plan,
|
||||
) {
|
||||
const titleErr = validateYouTubeTitle(metadata, plan);
|
||||
if (titleErr) return titleErr;
|
||||
if (!isAllowedResolution(metadata.resolution)) return "Invalid resolution";
|
||||
if (!["PUBLIC", "PRIVATE", "UNLISTED"].includes(metadata.privacy)) {
|
||||
return "Invalid privacy setting";
|
||||
}
|
||||
if (metadata.watermark?.text && metadata.watermark.text.length > WATERMARK_TEXT_MAX) {
|
||||
return `Watermark text must be at most ${WATERMARK_TEXT_MAX} characters`;
|
||||
}
|
||||
if (metadata.artist && metadata.artist.length > ARTIST_MAX) {
|
||||
return `Artist must be at most ${ARTIST_MAX} characters`;
|
||||
}
|
||||
if (metadata.songTitle && metadata.songTitle.length > SONG_TITLE_MAX) {
|
||||
return `Song title must be at most ${SONG_TITLE_MAX} characters`;
|
||||
}
|
||||
try {
|
||||
resolveLayoutFromMetadata(metadata);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === INVALID_LAYOUT_TEMPLATE_MESSAGE) {
|
||||
return INVALID_LAYOUT_TEMPLATE_MESSAGE;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function validateJobPayload(user: { id: string; plan: Plan }, body: CreateJobPayload) {
|
||||
if (!body.imagePath || !body.items?.length) {
|
||||
return "Image and at least one audio file required";
|
||||
}
|
||||
|
||||
const itemImagePaths: Array<string | null | undefined> = [];
|
||||
|
||||
for (const item of body.items) {
|
||||
const metaError = validateItemMetadata(item.metadata, user.plan);
|
||||
if (metaError) return metaError;
|
||||
if (!isResolutionAllowedForPlan(item.metadata.resolution, user.plan)) {
|
||||
return `Resolution ${item.metadata.resolution} is not available on your plan`;
|
||||
}
|
||||
if (item.metadata.playlistId && !hasProFeatures(user.plan)) {
|
||||
return "Adding videos to a YouTube playlist requires the Pro plan";
|
||||
}
|
||||
|
||||
const wm = normalizeWatermarkSettings(
|
||||
item.metadata.watermark,
|
||||
item.metadata.includeWatermark,
|
||||
);
|
||||
try {
|
||||
assertCustomWatermarkAllowed(user.plan, wm);
|
||||
assertArtTrackLayoutAllowed(user.plan, resolveLayoutFromMetadata(item.metadata));
|
||||
} catch (err) {
|
||||
if (err instanceof PremiumRequiredError) return err.message;
|
||||
if (err instanceof Error && err.message === INVALID_LAYOUT_TEMPLATE_MESSAGE) {
|
||||
return INVALID_LAYOUT_TEMPLATE_MESSAGE;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
itemImagePaths.push(item.metadata.imagePath);
|
||||
}
|
||||
|
||||
try {
|
||||
assertPerItemImagesAllowed(user.plan, body.imagePath, itemImagePaths);
|
||||
} catch (err) {
|
||||
if (err instanceof PremiumRequiredError) return err.message;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const limits = getPlanLimits(user.plan);
|
||||
|
||||
try {
|
||||
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
|
||||
await fs.access(imagePath);
|
||||
const imageStat = await fs.stat(imagePath);
|
||||
if (imageStat.size > limits.maxFileSizeBytes) {
|
||||
return "Image exceeds size limit";
|
||||
}
|
||||
|
||||
for (const item of body.items) {
|
||||
if (!isAudioExtensionAllowed(item.audioFilename, user.plan)) {
|
||||
return `Audio file ${item.audioFilename} is not supported on your plan`;
|
||||
}
|
||||
const audioPath = assertPathInUserUploads(user.id, item.audioPath);
|
||||
await fs.access(audioPath);
|
||||
const stat = await fs.stat(audioPath);
|
||||
if (stat.size > limits.maxFileSizeBytes) {
|
||||
return `Audio file ${item.audioFilename} exceeds size limit`;
|
||||
}
|
||||
|
||||
if (item.metadata.imagePath) {
|
||||
const itemImg = assertPathInUserUploads(user.id, item.metadata.imagePath);
|
||||
await fs.access(itemImg);
|
||||
const st = await fs.stat(itemImg);
|
||||
if (st.size > limits.maxFileSizeBytes) {
|
||||
return `Per-item image for ${item.audioFilename} exceeds size limit`;
|
||||
}
|
||||
}
|
||||
|
||||
const wm = normalizeWatermarkSettings(
|
||||
item.metadata.watermark,
|
||||
item.metadata.includeWatermark,
|
||||
);
|
||||
if (wm.mode === "logo" && wm.logoPath) {
|
||||
const logoPath = assertPathInUserUploads(user.id, wm.logoPath);
|
||||
await fs.access(logoPath);
|
||||
await assertPngFile(logoPath);
|
||||
const st = await fs.stat(logoPath);
|
||||
if (st.size > limits.maxFileSizeBytes) {
|
||||
return "Watermark logo exceeds size limit";
|
||||
}
|
||||
}
|
||||
if (wm.mode === "text" && !wm.text?.trim()) {
|
||||
return "Watermark text mode requires non-empty text";
|
||||
}
|
||||
if (wm.fontKey === "custom") {
|
||||
if (!wm.fontPath) {
|
||||
return "Custom font selected but no font file uploaded";
|
||||
}
|
||||
const fontPath = assertPathInUserUploads(user.id, wm.fontPath);
|
||||
await fs.access(fontPath);
|
||||
await assertFontFile(fontPath);
|
||||
const st = await fs.stat(fontPath);
|
||||
if (st.size > FONT_UPLOAD_MAX_BYTES) {
|
||||
return "Custom font exceeds 10 MB limit";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof PremiumRequiredError) return err.message;
|
||||
if (err instanceof Error && err.message === "Invalid upload path") {
|
||||
return "Invalid upload path";
|
||||
}
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(err.message.includes("PNG") ||
|
||||
err.message.includes("font") ||
|
||||
err.message.includes("TTF") ||
|
||||
err.message.includes("OTF") ||
|
||||
err.message.includes("WOFF"))
|
||||
) {
|
||||
return err.message;
|
||||
}
|
||||
return "One or more uploaded files not found";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function createVideoJob(
|
||||
user: { id: string; plan: Plan },
|
||||
body: CreateJobPayload,
|
||||
) {
|
||||
const validationError = await validateJobPayload(user, body);
|
||||
if (validationError) {
|
||||
if (validationError === INVALID_LAYOUT_TEMPLATE_MESSAGE) {
|
||||
throw new Error(validationError);
|
||||
}
|
||||
const isPremium =
|
||||
validationError.includes("requires Pro") ||
|
||||
validationError.includes("Pro plan");
|
||||
const err = isPremium
|
||||
? new PremiumRequiredError(validationError)
|
||||
: new Error(validationError);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
|
||||
const items = body.items.map((item) => ({
|
||||
...item,
|
||||
audioPath: assertPathInUserUploads(user.id, item.audioPath),
|
||||
itemImagePath: item.metadata.imagePath
|
||||
? assertPathInUserUploads(user.id, item.metadata.imagePath)
|
||||
: null,
|
||||
watermarkLogoPath: item.metadata.watermark?.logoPath
|
||||
? assertPathInUserUploads(user.id, item.metadata.watermark.logoPath)
|
||||
: null,
|
||||
watermarkFontPath:
|
||||
item.metadata.watermark?.fontKey === "custom" && item.metadata.watermark?.fontPath
|
||||
? assertPathInUserUploads(user.id, item.metadata.watermark.fontPath)
|
||||
: null,
|
||||
}));
|
||||
|
||||
const reservation = await reserveQuota(user.id, items.length);
|
||||
|
||||
try {
|
||||
const job = await prisma.job.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
imagePath,
|
||||
items: {
|
||||
create: items.map((item, index) => {
|
||||
const wm = normalizeWatermarkSettings(
|
||||
item.metadata.watermark
|
||||
? {
|
||||
...item.metadata.watermark,
|
||||
logoPath: item.watermarkLogoPath,
|
||||
fontPath: item.watermarkFontPath,
|
||||
}
|
||||
: null,
|
||||
item.metadata.includeWatermark,
|
||||
);
|
||||
const layout = resolveLayoutFromMetadata(item.metadata);
|
||||
const pro = hasProFeatures(user.plan);
|
||||
const youtubeTitle = resolveYouTubeTitle(item.metadata, user.plan);
|
||||
const burnedTitle = pro
|
||||
? resolveBurnedSongTitle(
|
||||
item.metadata,
|
||||
filenameWithoutExtension(item.audioFilename),
|
||||
)
|
||||
: null;
|
||||
return {
|
||||
audioPath: item.audioPath,
|
||||
audioFilename: item.audioFilename,
|
||||
title: youtubeTitle,
|
||||
songTitle: burnedTitle,
|
||||
description: item.metadata.description || "",
|
||||
tags: item.metadata.tags || "",
|
||||
privacy: item.metadata.privacy as Privacy,
|
||||
categoryId: item.metadata.categoryId || "10",
|
||||
resolution: item.metadata.resolution,
|
||||
notifySubscribers: item.metadata.notifySubscribers,
|
||||
madeForKids: item.metadata.madeForKids,
|
||||
embeddable: item.metadata.embeddable,
|
||||
creativeCommons: item.metadata.creativeCommons,
|
||||
includeWatermark: wm.mode !== "none",
|
||||
itemImagePath: item.itemImagePath,
|
||||
watermarkMode: wm.mode,
|
||||
watermarkText: wm.mode === "text" ? wm.text?.trim() || null : null,
|
||||
watermarkLogoPath: wm.mode === "logo" ? item.watermarkLogoPath : null,
|
||||
watermarkFontKey: wm.fontKey ?? "system",
|
||||
watermarkFontPath:
|
||||
wm.fontKey === "custom" ? item.watermarkFontPath : null,
|
||||
watermarkPosition: wm.position,
|
||||
watermarkOffsetX: wm.offsetX,
|
||||
watermarkOffsetY: wm.offsetY,
|
||||
artist: pro
|
||||
? item.metadata.artist?.trim().slice(0, ARTIST_MAX) || null
|
||||
: null,
|
||||
layoutTemplate: layout.template,
|
||||
blurAmount: layout.blurAmount,
|
||||
blurOpacity: layout.blurOpacity,
|
||||
textPadding: layout.textPadding,
|
||||
titleArtistGap: layout.titleArtistGap,
|
||||
textOffsetX: layout.textOffsetX,
|
||||
textOffsetY: layout.textOffsetY,
|
||||
playlistId: item.metadata.playlistId?.trim() || null,
|
||||
billingSource: index < reservation.fromQuota ? "QUOTA" : "CREDIT",
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
const jobDir = getJobDir(user.id, job.id);
|
||||
await fs.mkdir(jobDir, { recursive: true });
|
||||
|
||||
const imageExt = path.extname(imagePath);
|
||||
const newImagePath = path.join(jobDir, `image${imageExt}`);
|
||||
await moveFile(imagePath, newImagePath);
|
||||
await prisma.job.update({ where: { id: job.id }, data: { imagePath: newImagePath } });
|
||||
|
||||
const moved = new Map<string, string>();
|
||||
moved.set(imagePath, newImagePath);
|
||||
|
||||
await Promise.all(
|
||||
job.items.map(async (item) => {
|
||||
const audioExt = path.extname(item.audioPath);
|
||||
const newAudioPath = path.join(jobDir, `${item.id}${audioExt}`);
|
||||
await moveFile(item.audioPath, newAudioPath);
|
||||
|
||||
let newItemImage: string | null = null;
|
||||
if (item.itemImagePath) {
|
||||
const src = item.itemImagePath;
|
||||
if (moved.has(src)) {
|
||||
newItemImage = moved.get(src)!;
|
||||
} else {
|
||||
const ext = path.extname(src);
|
||||
newItemImage = path.join(jobDir, `${item.id}-cover${ext}`);
|
||||
await moveFile(src, newItemImage);
|
||||
moved.set(src, newItemImage);
|
||||
}
|
||||
}
|
||||
|
||||
let newLogo: string | null = null;
|
||||
if (item.watermarkLogoPath) {
|
||||
const src = item.watermarkLogoPath;
|
||||
if (moved.has(src)) {
|
||||
newLogo = moved.get(src)!;
|
||||
} else {
|
||||
newLogo = path.join(jobDir, `${item.id}-logo.png`);
|
||||
await moveFile(src, newLogo);
|
||||
moved.set(src, newLogo);
|
||||
}
|
||||
}
|
||||
|
||||
let newFont: string | null = null;
|
||||
if (item.watermarkFontPath) {
|
||||
const src = item.watermarkFontPath;
|
||||
if (moved.has(src)) {
|
||||
newFont = moved.get(src)!;
|
||||
} else {
|
||||
const ext = path.extname(src).toLowerCase() === ".otf" ? ".otf" : ".ttf";
|
||||
newFont = path.join(jobDir, `${item.id}-font${ext}`);
|
||||
await moveFile(src, newFont);
|
||||
moved.set(src, newFont);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.jobItem.update({
|
||||
where: { id: item.id },
|
||||
data: {
|
||||
audioPath: newAudioPath,
|
||||
itemImagePath: newItemImage,
|
||||
watermarkLogoPath: newLogo,
|
||||
watermarkFontPath: newFont,
|
||||
},
|
||||
});
|
||||
|
||||
await enqueueVideoJob({
|
||||
jobItemId: item.id,
|
||||
userId: user.id,
|
||||
jobId: job.id,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return job;
|
||||
} catch (err) {
|
||||
await releaseReservationSplit(user.id, reservation).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export type UploadFileType = "image" | "audio" | "logo" | "font";
|
||||
|
||||
export async function saveUploadedFile(
|
||||
userId: string,
|
||||
file: File,
|
||||
type: UploadFileType,
|
||||
plan: Plan,
|
||||
options?: { sessionKey?: string },
|
||||
) {
|
||||
const limits = getPlanLimits(plan);
|
||||
|
||||
if (type === "font") {
|
||||
if (!limits.customWatermark && !hasProFeatures(plan)) {
|
||||
throw new PremiumRequiredError("Custom watermark fonts require Pro.");
|
||||
}
|
||||
if (file.size > FONT_UPLOAD_MAX_BYTES) {
|
||||
throw new Error("Font file must be 10 MB or smaller");
|
||||
}
|
||||
if (!/\.(ttf|otf)$/i.test(file.name)) {
|
||||
throw new Error("Font must be a .ttf or .otf file");
|
||||
}
|
||||
} else if (file.size > limits.maxFileSizeBytes) {
|
||||
throw new Error(`File exceeds ${limits.maxFileSizeBytes / (1024 * 1024)} MB limit`);
|
||||
}
|
||||
|
||||
if (type === "logo") {
|
||||
if (!limits.customWatermark && !hasProFeatures(plan)) {
|
||||
throw new PremiumRequiredError("Custom logo watermarks require Pro.");
|
||||
}
|
||||
const nameOk = /\.png$/i.test(file.name);
|
||||
const typeOk = file.type === "image/png" || file.type === "";
|
||||
if (!nameOk && !typeOk) {
|
||||
throw new Error("Watermark logo must be a PNG file");
|
||||
}
|
||||
}
|
||||
|
||||
const allowedImageTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||
const allowedAudioTypes = [
|
||||
"audio/mpeg",
|
||||
"audio/mp3",
|
||||
"audio/wav",
|
||||
"audio/x-wav",
|
||||
"audio/ogg",
|
||||
"audio/flac",
|
||||
"audio/aac",
|
||||
"audio/mp4",
|
||||
"audio/x-m4a",
|
||||
];
|
||||
|
||||
if (type === "image") {
|
||||
if (
|
||||
!allowedImageTypes.includes(file.type) &&
|
||||
!file.name.match(/\.(jpg|jpeg|png|webp|gif)$/i)
|
||||
) {
|
||||
throw new Error("Invalid image file type");
|
||||
}
|
||||
} else if (type === "audio") {
|
||||
if (
|
||||
!allowedAudioTypes.includes(file.type) &&
|
||||
!file.name.match(/\.(mp3|wav|ogg|flac|aac|m4a)$/i)
|
||||
) {
|
||||
throw new Error("Invalid audio file type");
|
||||
}
|
||||
if (!isAudioExtensionAllowed(file.name, plan)) {
|
||||
const allowed = limits.allowedAudioExtensions.join(", ");
|
||||
throw new Error(`Your plan supports ${allowed} audio files only`);
|
||||
}
|
||||
}
|
||||
|
||||
const sessionKey = sanitizeUploadSessionKey(options?.sessionKey);
|
||||
const sessionDir = getUserStagingDir(userId, sessionKey);
|
||||
await fs.mkdir(sessionDir, { recursive: true });
|
||||
|
||||
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
const uniqueName = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}-${safeName}`;
|
||||
const filePath = path.join(sessionDir, uniqueName);
|
||||
assertPathInUserUploads(userId, filePath);
|
||||
await writeUploadedFile(file, filePath);
|
||||
|
||||
if (type === "logo") {
|
||||
try {
|
||||
await assertPngFile(filePath);
|
||||
} catch (err) {
|
||||
await fs.unlink(filePath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "font") {
|
||||
try {
|
||||
await assertFontFile(filePath);
|
||||
} catch (err) {
|
||||
await fs.unlink(filePath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
let audioTags = null;
|
||||
if (type === "audio" && limits.id3TagSupport && file.name.toLowerCase().endsWith(".mp3")) {
|
||||
audioTags = await readAudioTags(filePath);
|
||||
}
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
audioTags,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Plan } from "@prisma/client";
|
||||
import { hasProFeatures } from "../edition";
|
||||
import type { CreateJobPayload, CreatePlaylistRequest, ItemMetadata } from "../types";
|
||||
import { createYouTubePlaylist } from "../youtube/upload";
|
||||
|
||||
export function parseCreatePlaylistInput(raw: unknown): CreatePlaylistRequest | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const value = raw as Record<string, unknown>;
|
||||
const title = typeof value.title === "string" ? value.title.trim() : "";
|
||||
if (!title) return null;
|
||||
|
||||
const privacy =
|
||||
value.privacy === "public" || value.privacy === "unlisted" || value.privacy === "private"
|
||||
? value.privacy
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
title,
|
||||
description: typeof value.description === "string" ? value.description : undefined,
|
||||
privacy,
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyCreatePlaylistToItems(
|
||||
user: { id: string; plan: Plan },
|
||||
items: CreateJobPayload["items"],
|
||||
createPlaylist: CreatePlaylistRequest | null | undefined,
|
||||
) {
|
||||
if (!createPlaylist) {
|
||||
return { items, playlist: null as Awaited<ReturnType<typeof createYouTubePlaylist>> | null };
|
||||
}
|
||||
|
||||
if (!hasProFeatures(user.plan)) {
|
||||
throw new Error("Creating a YouTube playlist requires the Pro plan");
|
||||
}
|
||||
|
||||
const playlist = await createYouTubePlaylist(user.id, createPlaylist);
|
||||
const nextItems = items.map((item) => ({
|
||||
...item,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
playlistId: item.metadata.playlistId || playlist.id,
|
||||
} satisfies ItemMetadata,
|
||||
}));
|
||||
|
||||
return { items: nextItems, playlist };
|
||||
}
|
||||
+418
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* Art-track layout templates + blur background (Pro).
|
||||
* Cover/text anchors come from enum templates; only clamped fine-tuning offsets are allowed.
|
||||
*/
|
||||
|
||||
export const LAYOUT_TEMPLATES = [
|
||||
"COVER_LEFT_TEXT_RIGHT",
|
||||
"COVER_TOP_TEXT_BOTTOM",
|
||||
"COVER_RIGHT_TEXT_LEFT",
|
||||
"CENTERED_COMPACT",
|
||||
] as const;
|
||||
|
||||
export type LayoutTemplate = (typeof LAYOUT_TEMPLATES)[number];
|
||||
|
||||
export const INVALID_LAYOUT_TEMPLATE_MESSAGE =
|
||||
"Invalid layout template. Refer to API documentation for valid enum values.";
|
||||
|
||||
export const BLUR_AMOUNT_MIN = 0;
|
||||
export const BLUR_AMOUNT_MAX = 100;
|
||||
export const BLUR_AMOUNT_DEFAULT = 55;
|
||||
|
||||
/** Visibility of the blurred cover fill over black (0 = solid black, 100 = full blur). */
|
||||
export const BLUR_OPACITY_MIN = 0;
|
||||
export const BLUR_OPACITY_MAX = 100;
|
||||
export const BLUR_OPACITY_DEFAULT = 100;
|
||||
|
||||
export const TEXT_PADDING_MIN = 16;
|
||||
export const TEXT_PADDING_MAX = 120;
|
||||
export const TEXT_PADDING_DEFAULT = 48;
|
||||
|
||||
export const TITLE_ARTIST_GAP_MIN = 0;
|
||||
export const TITLE_ARTIST_GAP_MAX = 64;
|
||||
export const TITLE_ARTIST_GAP_DEFAULT = 10;
|
||||
|
||||
/** Fine-tune title/artist block within the template (not free-form canvas coords). */
|
||||
export const TEXT_OFFSET_MIN = -120;
|
||||
export const TEXT_OFFSET_MAX = 120;
|
||||
export const TEXT_OFFSET_DEFAULT = 0;
|
||||
|
||||
export const ARTIST_MAX = 80;
|
||||
export const SONG_TITLE_MAX = 120;
|
||||
|
||||
export type LayoutSettings = {
|
||||
/** When null, classic letterbox (no art-track layout / blur fill). */
|
||||
template: LayoutTemplate | null;
|
||||
/** 0–100 → FFmpeg boxblur intensity. */
|
||||
blurAmount: number;
|
||||
/** 0–100 → how visible the blurred fill is (vs black). */
|
||||
blurOpacity: number;
|
||||
/** Pixel padding / gap around cover + text. */
|
||||
textPadding: number;
|
||||
/** Extra pixels between title and artist lines. */
|
||||
titleArtistGap: number;
|
||||
/** Shift text block horizontally within the template. */
|
||||
textOffsetX: number;
|
||||
/** Shift text block vertically within the template. */
|
||||
textOffsetY: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_LAYOUT: LayoutSettings = {
|
||||
template: null,
|
||||
blurAmount: BLUR_AMOUNT_DEFAULT,
|
||||
blurOpacity: BLUR_OPACITY_DEFAULT,
|
||||
textPadding: TEXT_PADDING_DEFAULT,
|
||||
titleArtistGap: TITLE_ARTIST_GAP_DEFAULT,
|
||||
textOffsetX: TEXT_OFFSET_DEFAULT,
|
||||
textOffsetY: TEXT_OFFSET_DEFAULT,
|
||||
};
|
||||
|
||||
export function isLayoutTemplate(v: unknown): v is LayoutTemplate {
|
||||
return typeof v === "string" && (LAYOUT_TEMPLATES as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
export function clampBlurAmount(n: unknown, fallback = BLUR_AMOUNT_DEFAULT): number {
|
||||
const v = typeof n === "number" ? n : Number(n);
|
||||
if (!Number.isFinite(v)) return fallback;
|
||||
return Math.max(BLUR_AMOUNT_MIN, Math.min(BLUR_AMOUNT_MAX, Math.round(v)));
|
||||
}
|
||||
|
||||
export function clampBlurOpacity(n: unknown, fallback = BLUR_OPACITY_DEFAULT): number {
|
||||
const v = typeof n === "number" ? n : Number(n);
|
||||
if (!Number.isFinite(v)) return fallback;
|
||||
return Math.max(BLUR_OPACITY_MIN, Math.min(BLUR_OPACITY_MAX, Math.round(v)));
|
||||
}
|
||||
|
||||
export function clampTextPadding(n: unknown, fallback = TEXT_PADDING_DEFAULT): number {
|
||||
const v = typeof n === "number" ? n : Number(n);
|
||||
if (!Number.isFinite(v)) return fallback;
|
||||
return Math.max(TEXT_PADDING_MIN, Math.min(TEXT_PADDING_MAX, Math.round(v)));
|
||||
}
|
||||
|
||||
export function clampTitleArtistGap(n: unknown, fallback = TITLE_ARTIST_GAP_DEFAULT): number {
|
||||
const v = typeof n === "number" ? n : Number(n);
|
||||
if (!Number.isFinite(v)) return fallback;
|
||||
return Math.max(TITLE_ARTIST_GAP_MIN, Math.min(TITLE_ARTIST_GAP_MAX, Math.round(v)));
|
||||
}
|
||||
|
||||
export function clampTextOffset(n: unknown, fallback = TEXT_OFFSET_DEFAULT): number {
|
||||
const v = typeof n === "number" ? n : Number(n);
|
||||
if (!Number.isFinite(v)) return fallback;
|
||||
return Math.max(TEXT_OFFSET_MIN, Math.min(TEXT_OFFSET_MAX, Math.round(v)));
|
||||
}
|
||||
|
||||
export function blurToBoxblur(blurAmount: number): { radius: number; power: number } | null {
|
||||
const amount = clampBlurAmount(blurAmount, 0);
|
||||
if (amount <= 0) return null;
|
||||
const radius = Math.max(1, Math.round((amount / 100) * 50));
|
||||
const power = Math.max(1, Math.min(4, Math.ceil(amount / 25)));
|
||||
return { radius, power };
|
||||
}
|
||||
|
||||
export function boxblurFilterSegment(blurAmount: number): string {
|
||||
const bb = blurToBoxblur(blurAmount);
|
||||
if (!bb) return "";
|
||||
return `boxblur=luma_radius=${bb.radius}:luma_power=${bb.power}:chroma_radius=${bb.radius}:chroma_power=${bb.power}`;
|
||||
}
|
||||
|
||||
type RawLayoutInput = {
|
||||
template?: unknown;
|
||||
layoutTemplate?: unknown;
|
||||
layout_template?: unknown;
|
||||
blurAmount?: unknown;
|
||||
blur_amount?: unknown;
|
||||
blurOpacity?: unknown;
|
||||
blur_opacity?: unknown;
|
||||
textPadding?: unknown;
|
||||
text_padding?: unknown;
|
||||
titleArtistGap?: unknown;
|
||||
title_artist_gap?: unknown;
|
||||
textOffsetX?: unknown;
|
||||
text_offset_x?: unknown;
|
||||
textOffsetY?: unknown;
|
||||
text_offset_y?: unknown;
|
||||
/** Rejected clients must not send free-form cover coordinates. */
|
||||
x?: unknown;
|
||||
y?: unknown;
|
||||
coverX?: unknown;
|
||||
coverY?: unknown;
|
||||
offsetX?: unknown;
|
||||
offsetY?: unknown;
|
||||
};
|
||||
|
||||
export function normalizeLayoutSettings(
|
||||
input: RawLayoutInput | Partial<LayoutSettings> | null | undefined,
|
||||
): LayoutSettings {
|
||||
if (!input) return { ...DEFAULT_LAYOUT };
|
||||
|
||||
const forbidden = ["x", "y", "coverX", "coverY", "offsetX", "offsetY"] as const;
|
||||
for (const k of forbidden) {
|
||||
if ((input as RawLayoutInput)[k] !== undefined) {
|
||||
throw new Error(INVALID_LAYOUT_TEMPLATE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
const raw =
|
||||
(input as RawLayoutInput).template ??
|
||||
(input as RawLayoutInput).layoutTemplate ??
|
||||
(input as RawLayoutInput).layout_template;
|
||||
|
||||
let template: LayoutTemplate | null = null;
|
||||
if (raw !== undefined && raw !== null && raw !== "" && raw !== "CLASSIC" && raw !== "classic") {
|
||||
if (!isLayoutTemplate(raw)) {
|
||||
throw new Error(INVALID_LAYOUT_TEMPLATE_MESSAGE);
|
||||
}
|
||||
template = raw;
|
||||
}
|
||||
|
||||
const blurRaw =
|
||||
(input as RawLayoutInput).blurAmount ??
|
||||
(input as RawLayoutInput).blur_amount ??
|
||||
(input as LayoutSettings).blurAmount;
|
||||
const opacityRaw =
|
||||
(input as RawLayoutInput).blurOpacity ??
|
||||
(input as RawLayoutInput).blur_opacity ??
|
||||
(input as LayoutSettings).blurOpacity;
|
||||
const padRaw =
|
||||
(input as RawLayoutInput).textPadding ??
|
||||
(input as RawLayoutInput).text_padding ??
|
||||
(input as LayoutSettings).textPadding;
|
||||
const gapRaw =
|
||||
(input as RawLayoutInput).titleArtistGap ??
|
||||
(input as RawLayoutInput).title_artist_gap ??
|
||||
(input as LayoutSettings).titleArtistGap;
|
||||
const oxRaw =
|
||||
(input as RawLayoutInput).textOffsetX ??
|
||||
(input as RawLayoutInput).text_offset_x ??
|
||||
(input as LayoutSettings).textOffsetX;
|
||||
const oyRaw =
|
||||
(input as RawLayoutInput).textOffsetY ??
|
||||
(input as RawLayoutInput).text_offset_y ??
|
||||
(input as LayoutSettings).textOffsetY;
|
||||
|
||||
return {
|
||||
template,
|
||||
blurAmount: clampBlurAmount(blurRaw, BLUR_AMOUNT_DEFAULT),
|
||||
blurOpacity: clampBlurOpacity(opacityRaw, BLUR_OPACITY_DEFAULT),
|
||||
textPadding: clampTextPadding(padRaw, TEXT_PADDING_DEFAULT),
|
||||
titleArtistGap: clampTitleArtistGap(gapRaw, TITLE_ARTIST_GAP_DEFAULT),
|
||||
textOffsetX: clampTextOffset(oxRaw, TEXT_OFFSET_DEFAULT),
|
||||
textOffsetY: clampTextOffset(oyRaw, TEXT_OFFSET_DEFAULT),
|
||||
};
|
||||
}
|
||||
|
||||
export function requiresArtTrackLayoutEntitlement(settings: LayoutSettings): boolean {
|
||||
return settings.template !== null;
|
||||
}
|
||||
|
||||
export type LayoutGeometry = {
|
||||
coverMaxW: number;
|
||||
coverMaxH: number;
|
||||
coverX: string;
|
||||
coverY: string;
|
||||
titleFontSize: number;
|
||||
artistFontSize: number;
|
||||
titleX: string;
|
||||
titleY: string;
|
||||
artistX: string;
|
||||
artistY: string;
|
||||
};
|
||||
|
||||
function applyTextOffsets(
|
||||
geo: LayoutGeometry,
|
||||
textOffsetX: number,
|
||||
textOffsetY: number,
|
||||
): LayoutGeometry {
|
||||
const ox = clampTextOffset(textOffsetX);
|
||||
const oy = clampTextOffset(textOffsetY);
|
||||
if (ox === 0 && oy === 0) return geo;
|
||||
|
||||
const shiftX = (expr: string) => {
|
||||
if (expr === "(w-text_w)/2") return `(w-text_w)/2+${ox}`;
|
||||
if (/^-?\d+$/.test(expr)) return String(Number(expr) + ox);
|
||||
return `${expr}+${ox}`;
|
||||
};
|
||||
const shiftY = (expr: string) => {
|
||||
if (/^-?\d+$/.test(expr)) return String(Number(expr) + oy);
|
||||
return `${expr}+${oy}`;
|
||||
};
|
||||
|
||||
return {
|
||||
...geo,
|
||||
titleX: shiftX(geo.titleX),
|
||||
titleY: shiftY(geo.titleY),
|
||||
artistX: shiftX(geo.artistX),
|
||||
artistY: shiftY(geo.artistY),
|
||||
};
|
||||
}
|
||||
|
||||
/** Pixel geometry template + padding + title/artist gap + text offsets. */
|
||||
export function computeLayoutGeometry(
|
||||
template: LayoutTemplate,
|
||||
width: number,
|
||||
height: number,
|
||||
textPadding: number,
|
||||
titleArtistGap: number = TITLE_ARTIST_GAP_DEFAULT,
|
||||
textOffsetX: number = 0,
|
||||
textOffsetY: number = 0,
|
||||
): LayoutGeometry {
|
||||
const pad = clampTextPadding(textPadding);
|
||||
const gap = clampTitleArtistGap(titleArtistGap);
|
||||
const titleFontSize = Math.max(22, Math.round(width * 0.032));
|
||||
const artistFontSize = Math.max(16, Math.round(width * 0.02));
|
||||
const lineGap = gap;
|
||||
|
||||
let base: LayoutGeometry;
|
||||
|
||||
switch (template) {
|
||||
case "COVER_LEFT_TEXT_RIGHT": {
|
||||
const coverMaxW = Math.round(width * 0.42);
|
||||
const coverMaxH = height - pad * 2;
|
||||
const textX = pad + coverMaxW + pad;
|
||||
const midY = Math.round(height / 2);
|
||||
base = {
|
||||
coverMaxW,
|
||||
coverMaxH,
|
||||
coverX: String(pad),
|
||||
coverY: "(H-h)/2",
|
||||
titleFontSize,
|
||||
artistFontSize,
|
||||
titleX: String(textX),
|
||||
titleY: String(midY - titleFontSize - Math.round(lineGap / 2)),
|
||||
artistX: String(textX),
|
||||
artistY: String(midY + Math.round(lineGap / 2)),
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "COVER_RIGHT_TEXT_LEFT": {
|
||||
const coverMaxW = Math.round(width * 0.42);
|
||||
const coverMaxH = height - pad * 2;
|
||||
const textX = pad;
|
||||
const midY = Math.round(height / 2);
|
||||
base = {
|
||||
coverMaxW,
|
||||
coverMaxH,
|
||||
coverX: `W-w-${pad}`,
|
||||
coverY: "(H-h)/2",
|
||||
titleFontSize,
|
||||
artistFontSize,
|
||||
titleX: String(textX),
|
||||
titleY: String(midY - titleFontSize - Math.round(lineGap / 2)),
|
||||
artistX: String(textX),
|
||||
artistY: String(midY + Math.round(lineGap / 2)),
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "COVER_TOP_TEXT_BOTTOM": {
|
||||
// Near full width avoid empty side gutters next to the cover
|
||||
const coverMaxW = width - pad * 2;
|
||||
const coverMaxH = Math.round(height * 0.56);
|
||||
const textBlockTop = pad + coverMaxH + Math.round(pad * 0.55);
|
||||
base = {
|
||||
coverMaxW,
|
||||
coverMaxH,
|
||||
coverX: "(W-w)/2",
|
||||
coverY: String(pad),
|
||||
titleFontSize,
|
||||
artistFontSize,
|
||||
titleX: "(w-text_w)/2",
|
||||
titleY: String(textBlockTop),
|
||||
artistX: "(w-text_w)/2",
|
||||
artistY: String(textBlockTop + titleFontSize + lineGap),
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "CENTERED_COMPACT": {
|
||||
const side = Math.round(Math.min(width, height) * 0.4);
|
||||
const stackH = side + pad + titleFontSize + lineGap + artistFontSize;
|
||||
const stackTop = Math.round((height - stackH) / 2);
|
||||
const coverY = Math.max(pad, stackTop);
|
||||
const titleY = coverY + side + Math.round(pad * 0.55);
|
||||
base = {
|
||||
coverMaxW: side,
|
||||
coverMaxH: side,
|
||||
coverX: "(W-w)/2",
|
||||
coverY: String(coverY),
|
||||
titleFontSize,
|
||||
artistFontSize,
|
||||
titleX: "(w-text_w)/2",
|
||||
titleY: String(titleY),
|
||||
artistX: "(w-text_w)/2",
|
||||
artistY: String(titleY + titleFontSize + lineGap),
|
||||
};
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(INVALID_LAYOUT_TEMPLATE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
return applyTextOffsets(base, textOffsetX, textOffsetY);
|
||||
}
|
||||
|
||||
export function buildArtTrackFilterComplex(opts: {
|
||||
width: number;
|
||||
height: number;
|
||||
layout: LayoutSettings;
|
||||
titleEscaped: string;
|
||||
artistEscaped: string | null;
|
||||
fontfileEscaped?: string | null;
|
||||
}): string {
|
||||
const { width: W, height: H, layout } = opts;
|
||||
if (!layout.template) {
|
||||
throw new Error("Art-track filter requires a layout template");
|
||||
}
|
||||
|
||||
const geo = computeLayoutGeometry(
|
||||
layout.template,
|
||||
W,
|
||||
H,
|
||||
layout.textPadding,
|
||||
layout.titleArtistGap,
|
||||
layout.textOffsetX,
|
||||
layout.textOffsetY,
|
||||
);
|
||||
const blurSeg = boxblurFilterSegment(layout.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(layout.blurOpacity, BLUR_OPACITY_DEFAULT) / 100;
|
||||
const fontPart = opts.fontfileEscaped ? `:fontfile='${opts.fontfileEscaped}'` : "";
|
||||
const titleDraw = `drawtext=text='${opts.titleEscaped}'${fontPart}:fontsize=${geo.titleFontSize}:fontcolor=white@0.95:x=${geo.titleX}:y=${geo.titleY}`;
|
||||
const artistDraw = opts.artistEscaped
|
||||
? `,drawtext=text='${opts.artistEscaped}'${fontPart}:fontsize=${geo.artistFontSize}:fontcolor=white@0.75:x=${geo.artistX}:y=${geo.artistY}`
|
||||
: "";
|
||||
|
||||
const parts: string[] = [`[0:v]split=2[bg][fg]`];
|
||||
|
||||
// Fade blurred fill toward black when opacity < 100%
|
||||
if (opacity >= 0.999) {
|
||||
parts.push(`[bg]${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(
|
||||
`[bg]${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(
|
||||
`[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(";");
|
||||
}
|
||||
|
||||
export const LAYOUT_TEMPLATE_LABELS: Record<LayoutTemplate, string> = {
|
||||
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",
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SUPPORT_EMAIL, SALES_EMAIL, QUOTA_REQUEST_EMAIL } from "@/lib/plans";
|
||||
import { BRAND_DOMAIN, BRAND_NAME } from "@/lib/branding";
|
||||
|
||||
export const LEGAL_LAST_UPDATED = "July 25, 2026";
|
||||
|
||||
export const LEGAL_OPERATOR = {
|
||||
name: BRAND_NAME,
|
||||
legalName: "Atakan Doğan Özban",
|
||||
address: "Universitas u. 2/A",
|
||||
city: "7622 Pécs, Hungary",
|
||||
email: SUPPORT_EMAIL,
|
||||
salesEmail: SALES_EMAIL,
|
||||
quotaRequestEmail: QUOTA_REQUEST_EMAIL,
|
||||
website: `https://www.${BRAND_DOMAIN}`,
|
||||
} as const;
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import { Plan } from "@prisma/client";
|
||||
import { getResolution, RESOLUTIONS } from "./constants";
|
||||
import { isSelfHostedEdition } from "./edition";
|
||||
|
||||
export type PlanLimits = {
|
||||
monthlyQuota: number;
|
||||
maxBatchSize: number;
|
||||
maxResolutionHeight: number;
|
||||
maxFileSizeBytes: number;
|
||||
watermarkOptional: boolean;
|
||||
/** Custom text / PNG logo + position controls (Pro). */
|
||||
customWatermark: boolean;
|
||||
/** Pair a unique cover image per audio in a batch (Pro). */
|
||||
perItemImages: boolean;
|
||||
/** Blurred cover background + art-track layout templates (Pro). */
|
||||
artTrackLayouts: boolean;
|
||||
allowedAudioExtensions: readonly string[];
|
||||
id3TagSupport: boolean;
|
||||
};
|
||||
|
||||
export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
||||
FREE: {
|
||||
monthlyQuota: 10,
|
||||
maxBatchSize: 3,
|
||||
maxResolutionHeight: 720,
|
||||
maxFileSizeBytes: 30 * 1024 * 1024,
|
||||
watermarkOptional: true,
|
||||
customWatermark: false,
|
||||
perItemImages: false,
|
||||
artTrackLayouts: false,
|
||||
allowedAudioExtensions: [".mp3"],
|
||||
id3TagSupport: true,
|
||||
},
|
||||
PREMIUM: {
|
||||
monthlyQuota: 50,
|
||||
maxBatchSize: 5,
|
||||
maxResolutionHeight: 1080,
|
||||
maxFileSizeBytes: 30 * 1024 * 1024,
|
||||
watermarkOptional: true,
|
||||
customWatermark: true,
|
||||
perItemImages: true,
|
||||
artTrackLayouts: true,
|
||||
allowedAudioExtensions: [".mp3", ".wav", ".flac"],
|
||||
id3TagSupport: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const SELFHOSTED_LIMITS: PlanLimits = {
|
||||
monthlyQuota: 1_000_000,
|
||||
maxBatchSize: 100,
|
||||
maxResolutionHeight: Math.max(...RESOLUTIONS.map((r) => r.height)),
|
||||
maxFileSizeBytes: 500 * 1024 * 1024,
|
||||
watermarkOptional: true,
|
||||
customWatermark: true,
|
||||
perItemImages: true,
|
||||
artTrackLayouts: true,
|
||||
allowedAudioExtensions: [".mp3", ".wav", ".flac"],
|
||||
id3TagSupport: true,
|
||||
};
|
||||
|
||||
export const SALES_EMAIL = "songs2vid@atakanozban.com";
|
||||
export const SUPPORT_EMAIL = "songs2vid@atakanozban.com";
|
||||
/** Pro quota reset / extension requests */
|
||||
export const QUOTA_REQUEST_EMAIL = "songs2vid@atakanozban.com";
|
||||
export const UPGRADE_URL = "/#pricing";
|
||||
export const GITEA_ISSUES_URL =
|
||||
process.env.NEXT_PUBLIC_GITEA_ISSUES_URL ?? "https://git.atakanozban.com/Songs2VID/songs2vid/issues";
|
||||
export const GITEA_URL =
|
||||
process.env.NEXT_PUBLIC_GITEA_URL ?? "https://git.atakanozban.com/Songs2VID";
|
||||
export const DOCKER_HUB_URL =
|
||||
process.env.NEXT_PUBLIC_DOCKER_HUB_URL ?? "https://hub.docker.com/r/atakanozban/songs2vid";
|
||||
|
||||
/** Docusaurus docs base URL. Local: http://localhost:3001 (npm run docs:dev). */
|
||||
export function resolveDocsUrl(): string {
|
||||
if (process.env.NEXT_PUBLIC_DOCS_URL?.trim()) {
|
||||
return process.env.NEXT_PUBLIC_DOCS_URL.replace(/\/$/, "");
|
||||
}
|
||||
const auth = process.env.NEXTAUTH_URL ?? "";
|
||||
if (/localhost|127\.0\.0\.1/i.test(auth)) {
|
||||
return "http://localhost:3001";
|
||||
}
|
||||
return "https://docs.songs2vid.com";
|
||||
}
|
||||
|
||||
export const DOCS_URL = resolveDocsUrl();
|
||||
export const API_DOCS_URL = `${DOCS_URL}/docs/api/overview`;
|
||||
|
||||
export function getPlanLimits(plan: Plan): PlanLimits {
|
||||
if (isSelfHostedEdition()) return SELFHOSTED_LIMITS;
|
||||
return PLAN_LIMITS[plan];
|
||||
}
|
||||
|
||||
export function getResolutionsForPlan(plan: Plan) {
|
||||
const maxHeight = getPlanLimits(plan).maxResolutionHeight;
|
||||
return RESOLUTIONS.filter((r) => r.height <= maxHeight);
|
||||
}
|
||||
|
||||
export function isResolutionAllowedForPlan(resolution: string, plan: Plan): boolean {
|
||||
const res = getResolution(resolution);
|
||||
if (!res) return false;
|
||||
return res.height <= getPlanLimits(plan).maxResolutionHeight;
|
||||
}
|
||||
|
||||
export function isAudioExtensionAllowed(filename: string, plan: Plan): boolean {
|
||||
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
|
||||
return getPlanLimits(plan).allowedAudioExtensions.includes(ext);
|
||||
}
|
||||
|
||||
export function getNextMonthlyQuotaReset(from: Date = new Date()): Date {
|
||||
return new Date(from.getFullYear(), from.getMonth() + 1, 1, 0, 0, 0, 0);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Shared typography math for dashboard preview ↔ FFmpeg output parity.
|
||||
* Preview container uses a fixed reference width; scale from target encode resolution.
|
||||
*/
|
||||
|
||||
import type { WatermarkFontKey } from "./fonts";
|
||||
import { CURATED_FONTS } from "./fonts";
|
||||
|
||||
/** Matches LayoutStudio max preview width (tailwind max-w-xl ≈ 576px; use 576 for scaling). */
|
||||
export const PREVIEW_REFERENCE_WIDTH = 576;
|
||||
|
||||
export function titleFontSizeForWidth(width: number): number {
|
||||
return Math.max(22, Math.round(width * 0.032));
|
||||
}
|
||||
|
||||
export function artistFontSizeForWidth(width: number): number {
|
||||
return Math.max(16, Math.round(width * 0.02));
|
||||
}
|
||||
|
||||
export function watermarkFontSizeForWidth(width: number): number {
|
||||
return Math.max(16, Math.round(width * 0.018));
|
||||
}
|
||||
|
||||
/** Scale encode-resolution px to preview container px. */
|
||||
export function scaleFontToPreview(fontPx: number, encodeWidth: number): number {
|
||||
const ref = encodeWidth > 0 ? encodeWidth : 1280;
|
||||
return Math.max(8, Math.round((fontPx * PREVIEW_REFERENCE_WIDTH) / ref));
|
||||
}
|
||||
|
||||
export function previewFontFamilyCss(fontKey: WatermarkFontKey | undefined): string {
|
||||
if (!fontKey || fontKey === "system") {
|
||||
return "Arial, Helvetica, sans-serif";
|
||||
}
|
||||
if (fontKey === "custom") {
|
||||
return "'S2VIDCustomWm', Arial, sans-serif";
|
||||
}
|
||||
const meta = CURATED_FONTS.find((f) => f.key === fontKey);
|
||||
return meta ? `'S2VIDPreview-${meta.key}', Arial, sans-serif` : "Arial, sans-serif";
|
||||
}
|
||||
|
||||
export function curatedFontApiUrl(key: string): string {
|
||||
return `/api/fonts/${key}`;
|
||||
}
|
||||
|
||||
/** Watermark overlay width as fraction of frame (matches encode.ts). */
|
||||
export const WATERMARK_WIDTH_FRACTION = 0.32;
|
||||
+21
-16
@@ -1,24 +1,29 @@
|
||||
import { Redis } from "ioredis";
|
||||
import { Queue } from "bullmq";
|
||||
import { QUEUE_NAME } from "./constants";
|
||||
import type { VideoJobData } from "./types";
|
||||
import { QUEUE_NAME } from "../constants";
|
||||
import type { VideoJobData } from "../types";
|
||||
|
||||
let connection: Redis | null = null;
|
||||
let queue: Queue<VideoJobData> | null = null;
|
||||
|
||||
export function getRedisConnection(): Redis {
|
||||
if (!connection) {
|
||||
connection = new Redis(process.env.REDIS_URL || "redis://localhost:6379", {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
}
|
||||
return connection;
|
||||
function getConnectionOptions() {
|
||||
const url = process.env.REDIS_URL || "redis://localhost:6379";
|
||||
const parsed = new URL(url);
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
port: Number(parsed.port) || 6379,
|
||||
username: parsed.username || undefined,
|
||||
password: parsed.password || undefined,
|
||||
maxRetriesPerRequest: null as null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getVideoQueue(): Queue<VideoJobData> {
|
||||
let queue: Queue | null = null;
|
||||
|
||||
export function getRedisConnection() {
|
||||
return getConnectionOptions();
|
||||
}
|
||||
|
||||
export function getVideoQueue(): Queue {
|
||||
if (!queue) {
|
||||
queue = new Queue<VideoJobData>(QUEUE_NAME, {
|
||||
connection: getRedisConnection(),
|
||||
queue = new Queue(QUEUE_NAME, {
|
||||
connection: getConnectionOptions(),
|
||||
});
|
||||
}
|
||||
return queue;
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { QuotaExtensionRequestStatus } from "@prisma/client";
|
||||
import { MAX_ADMIN_API_RATE_BONUS } from "./api-rate-limit";
|
||||
import { prisma } from "./db";
|
||||
import { getPlanLimits } from "./plans";
|
||||
|
||||
export const QUOTA_EXTENSION_ANNUAL_LIMIT = 5;
|
||||
/** Max bonus videos an admin may grant per approval. */
|
||||
export const MAX_ADMIN_BONUS_QUOTA = 50;
|
||||
|
||||
export const EXTENSION_KIND = {
|
||||
VIDEO_QUOTA: "VIDEO_QUOTA",
|
||||
API_RATE_LIMIT: "API_RATE_LIMIT",
|
||||
} as const;
|
||||
|
||||
export type ExtensionKind = (typeof EXTENSION_KIND)[keyof typeof EXTENSION_KIND];
|
||||
|
||||
function getCalendarYearBounds(year = new Date().getFullYear()) {
|
||||
return {
|
||||
start: new Date(year, 0, 1),
|
||||
end: new Date(year + 1, 0, 1),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getQuotaExtensionUsage(
|
||||
userId: string,
|
||||
kind: ExtensionKind = EXTENSION_KIND.VIDEO_QUOTA,
|
||||
) {
|
||||
const { start, end } = getCalendarYearBounds();
|
||||
|
||||
const requests = await prisma.quotaExtensionRequest.findMany({
|
||||
where: {
|
||||
userId,
|
||||
kind,
|
||||
requestedAt: { gte: start, lt: end },
|
||||
},
|
||||
orderBy: { requestedAt: "desc" },
|
||||
});
|
||||
|
||||
const used = requests.filter((r) => r.status !== QuotaExtensionRequestStatus.REJECTED).length;
|
||||
|
||||
return {
|
||||
used,
|
||||
limit: QUOTA_EXTENSION_ANNUAL_LIMIT,
|
||||
remaining: Math.max(0, QUOTA_EXTENSION_ANNUAL_LIMIT - used),
|
||||
kind,
|
||||
requests: requests.map((r) => ({
|
||||
id: r.id,
|
||||
kind: r.kind,
|
||||
status: r.status,
|
||||
message: r.message,
|
||||
requestedAt: r.requestedAt.toISOString(),
|
||||
processedAt: r.processedAt?.toISOString() ?? null,
|
||||
adminNote: r.adminNote,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createQuotaExtensionRequest(
|
||||
userId: string,
|
||||
message = "",
|
||||
kind: ExtensionKind = EXTENSION_KIND.VIDEO_QUOTA,
|
||||
) {
|
||||
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
|
||||
if (user.plan !== "PREMIUM") {
|
||||
throw new Error("Only Pro subscribers can request quota resets or extensions.");
|
||||
}
|
||||
|
||||
const usage = await getQuotaExtensionUsage(userId, kind);
|
||||
if (usage.remaining <= 0) {
|
||||
throw new Error(
|
||||
`You have used all ${QUOTA_EXTENSION_ANNUAL_LIMIT} ${
|
||||
kind === EXTENSION_KIND.API_RATE_LIMIT ? "API rate limit" : "quota"
|
||||
} extension requests for this year.`,
|
||||
);
|
||||
}
|
||||
|
||||
const pending = await prisma.quotaExtensionRequest.findFirst({
|
||||
where: { userId, kind, status: QuotaExtensionRequestStatus.PENDING },
|
||||
});
|
||||
if (pending) {
|
||||
throw new Error("You already have a pending request. Please wait for it to be processed.");
|
||||
}
|
||||
|
||||
const request = await prisma.quotaExtensionRequest.create({
|
||||
data: {
|
||||
userId,
|
||||
kind,
|
||||
message: message.trim().slice(0, 1000),
|
||||
status: QuotaExtensionRequestStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
const updatedUsage = await getQuotaExtensionUsage(userId, kind);
|
||||
|
||||
return {
|
||||
requestId: request.id,
|
||||
...updatedUsage,
|
||||
};
|
||||
}
|
||||
|
||||
/** Call when approving a request in the database (admin / support). */
|
||||
export async function approveQuotaExtensionRequest(
|
||||
requestId: string,
|
||||
options?: { bonusQuota?: number; bonusRateLimit?: number; adminNote?: string },
|
||||
) {
|
||||
const request = await prisma.quotaExtensionRequest.findUniqueOrThrow({
|
||||
where: { id: requestId },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (request.status !== QuotaExtensionRequestStatus.PENDING) {
|
||||
throw new Error("Request is not pending.");
|
||||
}
|
||||
|
||||
if (request.kind === EXTENSION_KIND.API_RATE_LIMIT) {
|
||||
const bonus = Math.min(
|
||||
MAX_ADMIN_API_RATE_BONUS,
|
||||
Math.max(0, Math.floor(options?.bonusRateLimit ?? 30)),
|
||||
);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.quotaExtensionRequest.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: QuotaExtensionRequestStatus.APPROVED,
|
||||
processedAt: new Date(),
|
||||
adminNote: options?.adminNote?.trim().slice(0, 500) ?? null,
|
||||
},
|
||||
}),
|
||||
prisma.user.update({
|
||||
where: { id: request.userId },
|
||||
data: {
|
||||
apiRateLimitBonus: request.user.apiRateLimitBonus + bonus,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const bonus = Math.min(
|
||||
MAX_ADMIN_BONUS_QUOTA,
|
||||
Math.max(0, Math.floor(options?.bonusQuota ?? 0)),
|
||||
);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.quotaExtensionRequest.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: QuotaExtensionRequestStatus.APPROVED,
|
||||
processedAt: new Date(),
|
||||
adminNote: options?.adminNote?.trim().slice(0, 500) ?? null,
|
||||
},
|
||||
}),
|
||||
prisma.user.update({
|
||||
where: { id: request.userId },
|
||||
data: {
|
||||
videosUsed: 0,
|
||||
bonusQuota: request.user.bonusQuota + bonus,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function rejectQuotaExtensionRequest(requestId: string, adminNote?: string) {
|
||||
const request = await prisma.quotaExtensionRequest.findUniqueOrThrow({
|
||||
where: { id: requestId },
|
||||
});
|
||||
|
||||
if (request.status !== QuotaExtensionRequestStatus.PENDING) {
|
||||
throw new Error("Request is not pending.");
|
||||
}
|
||||
|
||||
await prisma.quotaExtensionRequest.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: QuotaExtensionRequestStatus.REJECTED,
|
||||
processedAt: new Date(),
|
||||
adminNote: adminNote?.trim().slice(0, 500) ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getEffectiveQuotaLimit(plan: Parameters<typeof getPlanLimits>[0], bonusQuota: number) {
|
||||
return getPlanLimits(plan).monthlyQuota + bonusQuota;
|
||||
}
|
||||
+249
-28
@@ -1,55 +1,276 @@
|
||||
import { FREE_PLAN } from "./constants";
|
||||
import { Plan } from "@prisma/client";
|
||||
import {
|
||||
CreditInsufficientError,
|
||||
applyMonthlyCreditRenewal,
|
||||
deductUserCredit,
|
||||
refundUserCredit,
|
||||
} from "./billing";
|
||||
import { FREE_EXTRA_CREDITS_MAX, MAX_CREDIT_CAP, PRO_UPGRADE_REQUIRED_SUFFIX, monthlyCreditsForPlan } from "./credits";
|
||||
import { prisma } from "./db";
|
||||
import { isSelfHostedEdition } from "./edition";
|
||||
import { getNextMonthlyQuotaReset, getPlanLimits } from "./plans";
|
||||
|
||||
function getNextQuotaReset(from: Date = new Date()): Date {
|
||||
return new Date(from.getFullYear(), from.getMonth() + 1, 1);
|
||||
export function formatQuotaResetCountdown(resetsAt: Date | string): string {
|
||||
const target = typeof resetsAt === "string" ? new Date(resetsAt) : resetsAt;
|
||||
const ms = Math.max(0, target.getTime() - Date.now());
|
||||
const totalSec = Math.ceil(ms / 1000);
|
||||
const hours = Math.floor(totalSec / 3600);
|
||||
const minutes = Math.floor((totalSec % 3600) / 60);
|
||||
const seconds = totalSec % 60;
|
||||
return `${hours}h ${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
export function formatQuotaResetDisplay(plan: Plan, resetsAt: Date | string): string {
|
||||
const target = typeof resetsAt === "string" ? new Date(resetsAt) : resetsAt;
|
||||
return target.toLocaleDateString(undefined, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function getQuotaExceededMessage(
|
||||
plan: Plan,
|
||||
resetsAt: Date,
|
||||
extraCredits: number,
|
||||
): string {
|
||||
const resetLabel = formatQuotaResetDisplay(plan, resetsAt);
|
||||
if (plan === "PREMIUM") {
|
||||
return extraCredits > 0
|
||||
? `Monthly Pro quota exhausted. You still have ${extraCredits} extra credits.`
|
||||
: `Quota exceeded. Your Pro monthly credits reset on ${resetLabel}.`;
|
||||
}
|
||||
if (extraCredits > 0) {
|
||||
return `Monthly free quota exhausted. You still have ${extraCredits} extra credits.`;
|
||||
}
|
||||
return (
|
||||
`You've used your free monthly credits (10) and any extra top-ups (max ${FREE_EXTRA_CREDITS_MAX}). ` +
|
||||
`Upgrade to the Pro monthly plan to continue uploading.` +
|
||||
PRO_UPGRADE_REQUIRED_SUFFIX
|
||||
);
|
||||
}
|
||||
|
||||
/** Reset cycle when due: rollover unused into extras, add new monthly, trim to MAX_CREDIT_CAP. */
|
||||
export async function ensureQuotaReset(userId: string) {
|
||||
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
if (new Date() >= user.quotaResetAt) {
|
||||
return prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
videosUsed: 0,
|
||||
quotaResetAt: getNextQuotaReset(),
|
||||
},
|
||||
const now = new Date();
|
||||
const expectedMonthly = monthlyCreditsForPlan(user.plan);
|
||||
|
||||
if (now >= user.quotaResetAt) {
|
||||
return applyMonthlyCreditRenewal(userId, {
|
||||
plan: user.plan,
|
||||
newMonthlyCredits: expectedMonthly,
|
||||
quotaResetAt: getNextMonthlyQuotaReset(now),
|
||||
});
|
||||
}
|
||||
|
||||
// Keep monthlyCredits in sync if plan was changed mid-cycle without reset
|
||||
if (user.monthlyCredits !== expectedMonthly && user.videosUsed === 0 && user.extraCredits === 0) {
|
||||
return prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { monthlyCredits: expectedMonthly },
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getQuotaInfo(userId: string) {
|
||||
const user = await ensureQuotaReset(userId);
|
||||
const remaining = Math.max(0, FREE_PLAN.monthlyQuota - user.videosUsed);
|
||||
const limits = getPlanLimits(user.plan);
|
||||
const limit = isSelfHostedEdition()
|
||||
? limits.monthlyQuota
|
||||
: user.monthlyCredits + (user.plan === "PREMIUM" ? user.bonusQuota : 0);
|
||||
const remaining = Math.max(0, limit - user.videosUsed);
|
||||
const extraCredits = isSelfHostedEdition() ? 0 : user.extraCredits;
|
||||
|
||||
return {
|
||||
used: user.videosUsed,
|
||||
limit: FREE_PLAN.monthlyQuota,
|
||||
creditsUsed: user.videosUsed,
|
||||
limit,
|
||||
monthlyCredits: user.monthlyCredits,
|
||||
baseLimit: limits.monthlyQuota,
|
||||
bonusQuota: isSelfHostedEdition() ? 0 : user.bonusQuota,
|
||||
remaining,
|
||||
/** @deprecated use extraCredits */
|
||||
videoCredits: extraCredits,
|
||||
extraCredits,
|
||||
creditBalanceMax: MAX_CREDIT_CAP,
|
||||
requiresProUpgrade:
|
||||
!isSelfHostedEdition() &&
|
||||
user.plan === "FREE" &&
|
||||
remaining === 0 &&
|
||||
extraCredits === 0,
|
||||
freeTopUpPurchased: user.freeTopUpPurchased,
|
||||
totalAvailable: remaining + extraCredits,
|
||||
resetsAt: user.quotaResetAt.toISOString(),
|
||||
resetsIn: formatQuotaResetDisplay(user.plan, user.quotaResetAt),
|
||||
plan: user.plan,
|
||||
maxBatchSize: limits.maxBatchSize,
|
||||
watermarkOptional: limits.watermarkOptional,
|
||||
customWatermark: limits.customWatermark,
|
||||
perItemImages: limits.perItemImages,
|
||||
artTrackLayouts: limits.artTrackLayouts,
|
||||
maxResolutionHeight: limits.maxResolutionHeight,
|
||||
selfHosted: isSelfHostedEdition(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkQuota(userId: string, requestedCount: number) {
|
||||
const info = await getQuotaInfo(userId);
|
||||
if (requestedCount > info.remaining) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `Quota exceeded. You have ${info.remaining} videos remaining this month.`,
|
||||
...info,
|
||||
};
|
||||
}
|
||||
return { ok: true as const, ...info };
|
||||
export type ReservationSplit = {
|
||||
fromQuota: number;
|
||||
fromCredits: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Monthly first, then never-expiring extras for both FREE and PREMIUM.
|
||||
*/
|
||||
export function planReservation(
|
||||
_plan: Plan,
|
||||
remainingQuota: number,
|
||||
extraCredits: number,
|
||||
count: number,
|
||||
): ReservationSplit | null {
|
||||
if (count <= 0) return { fromQuota: 0, fromCredits: 0 };
|
||||
const fromQuota = Math.min(count, Math.max(0, remainingQuota));
|
||||
const fromCredits = count - fromQuota;
|
||||
if (fromCredits > extraCredits) return null;
|
||||
return { fromQuota, fromCredits };
|
||||
}
|
||||
|
||||
export async function incrementQuota(userId: string, count: number) {
|
||||
export async function checkQuota(userId: string, requestedCount: number) {
|
||||
if (isSelfHostedEdition()) {
|
||||
const info = await getQuotaInfo(userId);
|
||||
if (requestedCount > info.maxBatchSize) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `Batch limit exceeded. Max ${info.maxBatchSize} files per batch.`,
|
||||
used: info.used,
|
||||
limit: info.limit,
|
||||
remaining: info.remaining,
|
||||
videoCredits: 0,
|
||||
extraCredits: 0,
|
||||
resetsAt: info.resetsAt,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true as const,
|
||||
...info,
|
||||
reservation: { fromQuota: requestedCount, fromCredits: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const user = await ensureQuotaReset(userId);
|
||||
const limits = getPlanLimits(user.plan);
|
||||
const limit = user.monthlyCredits + (user.plan === "PREMIUM" ? user.bonusQuota : 0);
|
||||
const remaining = Math.max(0, limit - user.videosUsed);
|
||||
|
||||
if (requestedCount > limits.maxBatchSize) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.`,
|
||||
used: user.videosUsed,
|
||||
limit,
|
||||
remaining,
|
||||
videoCredits: user.extraCredits,
|
||||
extraCredits: user.extraCredits,
|
||||
resetsAt: user.quotaResetAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const split = planReservation(user.plan, remaining, user.extraCredits, requestedCount);
|
||||
if (!split) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: getQuotaExceededMessage(user.plan, user.quotaResetAt, user.extraCredits),
|
||||
used: user.videosUsed,
|
||||
limit,
|
||||
remaining,
|
||||
videoCredits: user.extraCredits,
|
||||
extraCredits: user.extraCredits,
|
||||
resetsAt: user.quotaResetAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const info = await getQuotaInfo(userId);
|
||||
return { ok: true as const, ...info, reservation: split };
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically reserve monthly credits first, then extras.
|
||||
*/
|
||||
export async function reserveQuota(userId: string, count: number): Promise<ReservationSplit> {
|
||||
if (count <= 0) return { fromQuota: 0, fromCredits: 0 };
|
||||
|
||||
if (isSelfHostedEdition()) {
|
||||
const limits = getPlanLimits("FREE");
|
||||
if (count > limits.maxBatchSize) {
|
||||
throw new Error(`Batch limit exceeded. Max ${limits.maxBatchSize} files per batch.`);
|
||||
}
|
||||
await ensureQuotaReset(userId);
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { videosUsed: { increment: count } },
|
||||
});
|
||||
return { fromQuota: count, fromCredits: 0 };
|
||||
}
|
||||
|
||||
await ensureQuotaReset(userId);
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { videosUsed: { increment: count } },
|
||||
});
|
||||
|
||||
const limits = getPlanLimits(
|
||||
(await prisma.user.findUniqueOrThrow({ where: { id: userId } })).plan,
|
||||
);
|
||||
if (count > limits.maxBatchSize) {
|
||||
throw new Error(
|
||||
`Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await deductUserCredit(userId, count);
|
||||
return { fromQuota: result.fromMonthly, fromCredits: result.fromExtra };
|
||||
} catch (err) {
|
||||
if (err instanceof CreditInsufficientError) {
|
||||
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
throw new Error(
|
||||
getQuotaExceededMessage(user.plan, user.quotaResetAt, user.extraCredits),
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function releaseQuota(userId: string, count: number) {
|
||||
if (count <= 0) return;
|
||||
await refundUserCredit(userId, count, 0);
|
||||
}
|
||||
|
||||
export async function releaseCredits(userId: string, count: number) {
|
||||
if (count <= 0) return;
|
||||
await refundUserCredit(userId, 0, count);
|
||||
}
|
||||
|
||||
export async function releaseReservation(
|
||||
userId: string,
|
||||
billingSource: "QUOTA" | "CREDIT",
|
||||
count = 1,
|
||||
) {
|
||||
if (billingSource === "CREDIT") {
|
||||
await releaseCredits(userId, count);
|
||||
} else {
|
||||
await releaseQuota(userId, count);
|
||||
}
|
||||
}
|
||||
|
||||
export async function releaseReservationSplit(userId: string, split: ReservationSplit) {
|
||||
if (split.fromQuota > 0) await releaseQuota(userId, split.fromQuota);
|
||||
if (split.fromCredits > 0) await releaseCredits(userId, split.fromCredits);
|
||||
}
|
||||
|
||||
/** @deprecated Prefer reserveQuota at create */
|
||||
export async function incrementQuota(userId: string, count: number) {
|
||||
await reserveQuota(userId, count);
|
||||
}
|
||||
|
||||
export function getInitialQuotaResetAt(): Date {
|
||||
return getNextQuotaReset();
|
||||
return getNextMonthlyQuotaReset();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type Stripe from "stripe";
|
||||
import { STRIPE_PRODUCT_TAX_CODE } from "@/lib/credits";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getStripe } from "@/lib/stripe";
|
||||
|
||||
/** Enable Stripe Tax only when Dashboard registrations are active (STRIPE_AUTOMATIC_TAX=true). */
|
||||
export function isStripeAutomaticTaxEnabled(): boolean {
|
||||
return process.env.STRIPE_AUTOMATIC_TAX === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared Checkout Session options required/recommended by Stripe for SaaS:
|
||||
* - client_reference_id for linking
|
||||
* - automatic_tax (opt-in) + customer_update.address when tax is on
|
||||
* - tax_code already set on product_data by callers
|
||||
*/
|
||||
export function checkoutTaxAndReferenceOptions(userId: string): Partial<Stripe.Checkout.SessionCreateParams> {
|
||||
const opts: Partial<Stripe.Checkout.SessionCreateParams> = {
|
||||
client_reference_id: userId,
|
||||
};
|
||||
|
||||
if (isStripeAutomaticTaxEnabled()) {
|
||||
opts.automatic_tax = { enabled: true };
|
||||
opts.customer_update = { address: "auto", name: "auto" };
|
||||
// Collect billing address so tax can be calculated for new/returning customers
|
||||
opts.billing_address_collection = "required";
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
/** tax_behavior required on Prices when automatic tax is enabled. */
|
||||
export function priceDataTaxFields(): { tax_behavior?: Stripe.Checkout.SessionCreateParams.LineItem.PriceData["tax_behavior"] } {
|
||||
if (!isStripeAutomaticTaxEnabled()) return {};
|
||||
// Exclusive: listed prices are pre-tax; VAT/GST added at checkout
|
||||
return { tax_behavior: "exclusive" };
|
||||
}
|
||||
|
||||
export function productDataWithTaxCode(
|
||||
name: string,
|
||||
description: string,
|
||||
): Stripe.Checkout.SessionCreateParams.LineItem.PriceData.ProductData {
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
tax_code: STRIPE_PRODUCT_TAX_CODE,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ensure the app user has a Stripe Customer and return its id. */
|
||||
export async function ensureStripeCustomer(userId: string, email: string): Promise<string> {
|
||||
const dbUser = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { stripeCustomerId: true },
|
||||
});
|
||||
|
||||
if (dbUser.stripeCustomerId) return dbUser.stripeCustomerId;
|
||||
|
||||
const stripe = getStripe();
|
||||
const customer = await stripe.customers.create({
|
||||
email,
|
||||
metadata: { userId },
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { stripeCustomerId: customer.id },
|
||||
});
|
||||
|
||||
return customer.id;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Stripe from "stripe";
|
||||
|
||||
let stripe: Stripe | null = null;
|
||||
|
||||
export function getStripe(): Stripe {
|
||||
const key = process.env.STRIPE_SECRET_KEY;
|
||||
if (!key) {
|
||||
throw new Error("STRIPE_SECRET_KEY is not configured");
|
||||
}
|
||||
if (!stripe) {
|
||||
stripe = new Stripe(key);
|
||||
}
|
||||
return stripe;
|
||||
}
|
||||
|
||||
export function isStripeConfigured(): boolean {
|
||||
return Boolean(process.env.STRIPE_SECRET_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bypass Stripe Checkout when:
|
||||
* - BILLING_DEV_MOCK=true, or
|
||||
* - development and no STRIPE_SECRET_KEY (so UI still works without stripe listen)
|
||||
* Set BILLING_DEV_MOCK=false to force real Stripe even without keys (will 503).
|
||||
*/
|
||||
export function isBillingDevMock(): boolean {
|
||||
if (process.env.BILLING_DEV_MOCK === "true") return true;
|
||||
if (process.env.BILLING_DEV_MOCK === "false") return false;
|
||||
return process.env.NODE_ENV === "development" && !process.env.STRIPE_SECRET_KEY;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Plan } from "@prisma/client";
|
||||
import { hasProFeatures } from "./edition";
|
||||
|
||||
export type TitleFields = {
|
||||
/** YouTube video title */
|
||||
title?: string | null;
|
||||
/** On-video song / track title (art-track layouts) */
|
||||
songTitle?: string | null;
|
||||
artist?: string | null;
|
||||
};
|
||||
|
||||
/** Resolve the title sent to YouTube. Pro may omit video title → `${artist} - ${songTitle}`. */
|
||||
export function resolveYouTubeTitle(meta: TitleFields, plan: Plan): string {
|
||||
const videoTitle = meta.title?.trim();
|
||||
if (videoTitle) return videoTitle;
|
||||
|
||||
if (hasProFeatures(plan)) {
|
||||
const artist = meta.artist?.trim();
|
||||
const song = meta.songTitle?.trim();
|
||||
if (artist && song) return `${artist} - ${song}`;
|
||||
if (song) return song;
|
||||
if (artist) return artist;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Title burned into art-track layout (never the YouTube-only field alone when songTitle set). */
|
||||
export function resolveBurnedSongTitle(
|
||||
meta: TitleFields,
|
||||
fallback = "Untitled",
|
||||
): string {
|
||||
return meta.songTitle?.trim() || fallback;
|
||||
}
|
||||
|
||||
export function validateYouTubeTitle(meta: TitleFields, plan: Plan): string | null {
|
||||
const resolved = resolveYouTubeTitle(meta, plan);
|
||||
if (!resolved) {
|
||||
if (hasProFeatures(plan)) {
|
||||
return "Enter a video title, or both artist and song title for the YouTube fallback";
|
||||
}
|
||||
return "Each video must have a video title";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Privacy } from "@prisma/client";
|
||||
import type { LayoutSettings } from "./layout";
|
||||
import type { WatermarkSettings } from "./watermark";
|
||||
|
||||
export type ItemMetadata = {
|
||||
title: string;
|
||||
@@ -11,7 +13,50 @@ export type ItemMetadata = {
|
||||
madeForKids: boolean;
|
||||
embeddable: boolean;
|
||||
creativeCommons: boolean;
|
||||
/** Legacy Free toggle default Songs2VID branding when watermark.mode is omitted. */
|
||||
includeWatermark: boolean;
|
||||
/** YouTube playlist ID (Pro only). Video is added after upload. */
|
||||
playlistId?: string | null;
|
||||
/**
|
||||
* Optional per-item cover image path (Pro / perItemImages).
|
||||
* When omitted, the job-level shared imagePath is used.
|
||||
*/
|
||||
imagePath?: string | null;
|
||||
/** Pro custom branding. Free may only use mode none|default at bottom-right. */
|
||||
watermark?: Partial<WatermarkSettings> | null;
|
||||
/** Artist line for art-track layouts (Pro, on-video only). */
|
||||
artist?: string | null;
|
||||
/** Song / track title burned into art-track layouts (Pro, on-video only). */
|
||||
songTitle?: string | null;
|
||||
/**
|
||||
* Pro art-track layout. Prefer camelCase; snake_case aliases accepted:
|
||||
* layout_template, blur_amount, text_padding.
|
||||
*/
|
||||
layout?: Partial<LayoutSettings> & {
|
||||
layoutTemplate?: string | null;
|
||||
layout_template?: string | null;
|
||||
blur_amount?: number;
|
||||
blur_opacity?: number;
|
||||
text_padding?: number;
|
||||
title_artist_gap?: number;
|
||||
text_offset_x?: number;
|
||||
text_offset_y?: number;
|
||||
} | null;
|
||||
/** Flat aliases (also accepted). */
|
||||
layoutTemplate?: string | null;
|
||||
layout_template?: string | null;
|
||||
blurAmount?: number;
|
||||
blur_amount?: number;
|
||||
blurOpacity?: number;
|
||||
blur_opacity?: number;
|
||||
textPadding?: number;
|
||||
text_padding?: number;
|
||||
titleArtistGap?: number;
|
||||
title_artist_gap?: number;
|
||||
textOffsetX?: number;
|
||||
text_offset_x?: number;
|
||||
textOffsetY?: number;
|
||||
text_offset_y?: number;
|
||||
};
|
||||
|
||||
export type UploadedAudio = {
|
||||
@@ -20,13 +65,22 @@ export type UploadedAudio = {
|
||||
metadata: ItemMetadata;
|
||||
};
|
||||
|
||||
export type CreatePlaylistRequest = {
|
||||
title: string;
|
||||
description?: string;
|
||||
privacy?: "public" | "unlisted" | "private";
|
||||
};
|
||||
|
||||
export type CreateJobPayload = {
|
||||
/** Shared cover for Free / default when items omit imagePath. */
|
||||
imagePath: string;
|
||||
items: Array<{
|
||||
audioPath: string;
|
||||
audioFilename: string;
|
||||
metadata: ItemMetadata;
|
||||
}>;
|
||||
/** Create a new playlist and attach its ID to every item (Pro). */
|
||||
createPlaylist?: CreatePlaylistRequest | null;
|
||||
};
|
||||
|
||||
export type JobItemResponse = {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import path from "path";
|
||||
import { randomBytes } from "crypto";
|
||||
import { getUploadDir } from "./storage";
|
||||
|
||||
/** Allow only safe staging folder names from clients. */
|
||||
export function sanitizeUploadSessionKey(raw?: string | null): string {
|
||||
const cleaned = (raw ?? "").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64);
|
||||
if (cleaned.length >= 8) return cleaned;
|
||||
return `${Date.now().toString(36)}-${randomBytes(6).toString("hex")}`;
|
||||
}
|
||||
|
||||
function userUploadRoot(userId: string) {
|
||||
return path.resolve(getUploadDir(), userId);
|
||||
}
|
||||
|
||||
function isInsideDir(filePath: string, dir: string) {
|
||||
const resolvedFile = path.resolve(filePath);
|
||||
const resolvedDir = path.resolve(dir);
|
||||
return resolvedFile === resolvedDir || resolvedFile.startsWith(resolvedDir + path.sep);
|
||||
}
|
||||
|
||||
/** Ensure a path stays under uploads/{userId}/ (blocks traversal & cross-user access). */
|
||||
export function assertPathInUserUploads(userId: string, filePath: string): string {
|
||||
if (!filePath?.trim()) {
|
||||
throw new Error("Invalid upload path");
|
||||
}
|
||||
|
||||
const resolved = path.resolve(filePath);
|
||||
const root = userUploadRoot(userId);
|
||||
|
||||
if (!isInsideDir(resolved, root)) {
|
||||
throw new Error("Invalid upload path");
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function getUserStagingDir(userId: string, sessionKey: string) {
|
||||
const safeSession = sanitizeUploadSessionKey(sessionKey);
|
||||
const dir = path.join(userUploadRoot(userId), "staging", safeSession);
|
||||
// Defense in depth: resolve and re-check
|
||||
const resolved = path.resolve(dir);
|
||||
if (!isInsideDir(resolved, userUploadRoot(userId))) {
|
||||
throw new Error("Invalid upload session");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Watermark / branding helpers position math + FFmpeg-safe sanitization.
|
||||
* Never interpolate unsanitized user text into filter graphs.
|
||||
*/
|
||||
|
||||
import {
|
||||
isWatermarkFontKey,
|
||||
type WatermarkFontKey,
|
||||
} from "./fonts";
|
||||
|
||||
export const WATERMARK_POSITIONS = [
|
||||
"top-left",
|
||||
"top-right",
|
||||
"bottom-left",
|
||||
"bottom-right",
|
||||
"center",
|
||||
] as const;
|
||||
|
||||
export type WatermarkPosition = (typeof WATERMARK_POSITIONS)[number];
|
||||
|
||||
export const WATERMARK_MODES = ["none", "default", "text", "logo"] as const;
|
||||
export type WatermarkMode = (typeof WATERMARK_MODES)[number];
|
||||
|
||||
export type WatermarkSettings = {
|
||||
mode: WatermarkMode;
|
||||
text?: string | null;
|
||||
logoPath?: string | null;
|
||||
position: WatermarkPosition;
|
||||
/** Pixel offset from the chosen anchor (0–200). */
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
/**
|
||||
* Typography (Pro / text mode).
|
||||
* `system` = FFmpeg default; curated keys map to assets/fonts; `custom` uses fontPath.
|
||||
*/
|
||||
fontKey?: WatermarkFontKey;
|
||||
/** Absolute/staging path to uploaded .ttf/.otf when fontKey === "custom". */
|
||||
fontPath?: string | null;
|
||||
};
|
||||
|
||||
export const DEFAULT_WATERMARK: WatermarkSettings = {
|
||||
mode: "default",
|
||||
text: null,
|
||||
logoPath: null,
|
||||
position: "bottom-right",
|
||||
offsetX: 20,
|
||||
offsetY: 20,
|
||||
fontKey: "system",
|
||||
fontPath: null,
|
||||
};
|
||||
|
||||
export const WATERMARK_TEXT_MAX = 80;
|
||||
export const WATERMARK_OFFSET_MIN = 0;
|
||||
export const WATERMARK_OFFSET_MAX = 200;
|
||||
|
||||
export function isWatermarkPosition(v: unknown): v is WatermarkPosition {
|
||||
return typeof v === "string" && (WATERMARK_POSITIONS as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
export function isWatermarkMode(v: unknown): v is WatermarkMode {
|
||||
return typeof v === "string" && (WATERMARK_MODES as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
export function clampOffset(n: unknown, fallback = 20): number {
|
||||
const v = typeof n === "number" ? n : Number(n);
|
||||
if (!Number.isFinite(v)) return fallback;
|
||||
return Math.max(WATERMARK_OFFSET_MIN, Math.min(WATERMARK_OFFSET_MAX, Math.round(v)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape user text for FFmpeg drawtext.
|
||||
* Strips control chars; escapes \, :, ', %, and [.
|
||||
*/
|
||||
export function sanitizeDrawtext(raw: string): string {
|
||||
return raw
|
||||
.slice(0, WATERMARK_TEXT_MAX)
|
||||
.replace(/[\u0000-\u001f\u007f]/g, "")
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/:/g, "\\:")
|
||||
.replace(/'/g, "\\'")
|
||||
.replace(/%/g, "%%")
|
||||
.replace(/\[/g, "\\[");
|
||||
}
|
||||
|
||||
/** FFmpeg overlay=x:y expressions for a scaled watermark layer `[wm]`. */
|
||||
export function overlayXy(
|
||||
position: WatermarkPosition,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
): { x: string; y: string } {
|
||||
const ox = clampOffset(offsetX);
|
||||
const oy = clampOffset(offsetY);
|
||||
switch (position) {
|
||||
case "top-left":
|
||||
return { x: String(ox), y: String(oy) };
|
||||
case "top-right":
|
||||
return { x: `W-w-${ox}`, y: String(oy) };
|
||||
case "bottom-left":
|
||||
return { x: String(ox), y: `H-h-${oy}` };
|
||||
case "center":
|
||||
return { x: `(W-w)/2+${ox}`, y: `(H-h)/2+${oy}` };
|
||||
case "bottom-right":
|
||||
default:
|
||||
return { x: `W-w-${ox}`, y: `H-h-${oy}` };
|
||||
}
|
||||
}
|
||||
|
||||
/** drawtext x/y for text watermarks. */
|
||||
export function drawtextXy(
|
||||
position: WatermarkPosition,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
): { x: string; y: string } {
|
||||
const ox = clampOffset(offsetX);
|
||||
const oy = clampOffset(offsetY);
|
||||
switch (position) {
|
||||
case "top-left":
|
||||
return { x: String(ox), y: String(oy) };
|
||||
case "top-right":
|
||||
return { x: `w-text_w-${ox}`, y: String(oy) };
|
||||
case "bottom-left":
|
||||
return { x: String(ox), y: `h-th-${oy}` };
|
||||
case "center":
|
||||
return { x: `(w-text_w)/2+${ox}`, y: `(h-th)/2+${oy}` };
|
||||
case "bottom-right":
|
||||
default:
|
||||
return { x: `w-text_w-${ox}`, y: `h-th-${oy}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a drawtext filter segment (without leading comma).
|
||||
* `fontfileEscaped` must already be sanitized via sanitizeFontfileForFilter.
|
||||
*/
|
||||
export function buildDrawtextFilter(opts: {
|
||||
text: string;
|
||||
fontSize: number;
|
||||
fontColor?: string;
|
||||
position: WatermarkPosition;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
fontfileEscaped?: string | null;
|
||||
}): string {
|
||||
const text = sanitizeDrawtext(opts.text);
|
||||
const { x, y } = drawtextXy(opts.position, opts.offsetX, opts.offsetY);
|
||||
const color = opts.fontColor ?? "white@0.9";
|
||||
const fontPart = opts.fontfileEscaped
|
||||
? `:fontfile='${opts.fontfileEscaped}'`
|
||||
: "";
|
||||
return `drawtext=text='${text}'${fontPart}:fontsize=${opts.fontSize}:fontcolor=${color}:x=${x}:y=${y}`;
|
||||
}
|
||||
|
||||
export function normalizeWatermarkSettings(
|
||||
input: Partial<WatermarkSettings> | null | undefined,
|
||||
includeWatermarkFallback: boolean,
|
||||
): WatermarkSettings {
|
||||
if (!input) {
|
||||
return {
|
||||
...DEFAULT_WATERMARK,
|
||||
mode: includeWatermarkFallback ? "default" : "none",
|
||||
};
|
||||
}
|
||||
const mode = isWatermarkMode(input.mode)
|
||||
? input.mode
|
||||
: includeWatermarkFallback
|
||||
? "default"
|
||||
: "none";
|
||||
const fontKey = isWatermarkFontKey(input.fontKey) ? input.fontKey : "system";
|
||||
return {
|
||||
mode,
|
||||
text: typeof input.text === "string" ? input.text.slice(0, WATERMARK_TEXT_MAX) : null,
|
||||
logoPath: typeof input.logoPath === "string" ? input.logoPath : null,
|
||||
position: isWatermarkPosition(input.position) ? input.position : "bottom-right",
|
||||
offsetX: clampOffset(input.offsetX, 20),
|
||||
offsetY: clampOffset(input.offsetY, 20),
|
||||
fontKey,
|
||||
fontPath: typeof input.fontPath === "string" ? input.fontPath : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** True when settings go beyond Free-tier default branding toggle. */
|
||||
export function requiresCustomWatermarkEntitlement(settings: WatermarkSettings): boolean {
|
||||
if (settings.mode === "text" || settings.mode === "logo") return true;
|
||||
if (settings.mode === "none") return false;
|
||||
if (settings.position !== "bottom-right") return true;
|
||||
if (settings.offsetX !== 20 || settings.offsetY !== 20) return true;
|
||||
if (settings.fontKey && settings.fontKey !== "system") return true;
|
||||
if (settings.fontPath) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export const PREMIUM_REQUIRED_CODE = "PREMIUM_REQUIRED" as const;
|
||||
@@ -0,0 +1,57 @@
|
||||
/** Extract a readable message from googleapis / Gaxios errors. */
|
||||
export function extractYouTubeErrorMessage(err: unknown): string {
|
||||
if (!err || typeof err !== "object") {
|
||||
return typeof err === "string" ? err : "Unknown YouTube error";
|
||||
}
|
||||
|
||||
const anyErr = err as {
|
||||
message?: string;
|
||||
errors?: Array<{ message?: string; reason?: string }>;
|
||||
response?: {
|
||||
data?: {
|
||||
error?: {
|
||||
message?: string;
|
||||
errors?: Array<{ message?: string; reason?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const nested =
|
||||
anyErr.response?.data?.error?.errors?.[0]?.message ||
|
||||
anyErr.response?.data?.error?.message ||
|
||||
anyErr.errors?.[0]?.message;
|
||||
|
||||
if (nested?.trim()) return nested.trim();
|
||||
if (anyErr.message?.trim()) return anyErr.message.trim();
|
||||
return "Unknown YouTube error";
|
||||
}
|
||||
|
||||
export function isYouTubeUploadLimitError(message: string) {
|
||||
const lower = message.toLowerCase();
|
||||
return (
|
||||
lower.includes("exceeded the number of videos") ||
|
||||
lower.includes("uploadLimitExceeded") ||
|
||||
lower.includes("upload limit") ||
|
||||
(lower.includes("quota") && lower.includes("exceeded") && lower.includes("youtube"))
|
||||
);
|
||||
}
|
||||
|
||||
export const YOUTUBE_UPLOAD_LIMIT_USER_MESSAGE =
|
||||
"YouTube upload limit reached: this Google/YouTube account has exceeded the number of videos " +
|
||||
"it may upload right now. This is YouTube's own daily limit, not your Songs2VID plan quota. " +
|
||||
"Try again later (often after 24 hours) or use a different YouTube channel.";
|
||||
|
||||
/** User-facing message for the dashboard (distinct from Songs2VID plan quota). */
|
||||
export function formatYouTubeErrorForUser(err: unknown): string {
|
||||
const raw = extractYouTubeErrorMessage(err);
|
||||
if (isYouTubeUploadLimitError(raw)) return YOUTUBE_UPLOAD_LIMIT_USER_MESSAGE;
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** Normalize a stored job-item error string for display in the UI. */
|
||||
export function displayJobItemError(message: string | null | undefined): string | null {
|
||||
if (!message?.trim()) return null;
|
||||
if (isYouTubeUploadLimitError(message)) return YOUTUBE_UPLOAD_LIMIT_USER_MESSAGE;
|
||||
return message.trim();
|
||||
}
|
||||
+151
-31
@@ -2,7 +2,9 @@ import { google } from "googleapis";
|
||||
import fs from "fs";
|
||||
import { prisma } from "../db";
|
||||
import { parseTags } from "../constants";
|
||||
import { decryptSecret, encryptSecret } from "../crypto/secrets";
|
||||
import type { JobItem } from "@prisma/client";
|
||||
import { formatYouTubeErrorForUser } from "./errors";
|
||||
|
||||
async function refreshAccessToken(refreshToken: string) {
|
||||
const oauth2Client = new google.auth.OAuth2(
|
||||
@@ -25,11 +27,12 @@ export async function getYouTubeClient(userId: string) {
|
||||
process.env.GOOGLE_CLIENT_SECRET,
|
||||
);
|
||||
|
||||
let accessToken = connection.accessToken;
|
||||
let accessToken = decryptSecret(connection.accessToken);
|
||||
const refreshToken = decryptSecret(connection.refreshToken);
|
||||
let expiresAt = connection.expiresAt;
|
||||
|
||||
if (new Date() >= expiresAt) {
|
||||
const credentials = await refreshAccessToken(connection.refreshToken);
|
||||
const credentials = await refreshAccessToken(refreshToken);
|
||||
if (!credentials.access_token) {
|
||||
throw new Error("Failed to refresh YouTube access token");
|
||||
}
|
||||
@@ -40,13 +43,18 @@ export async function getYouTubeClient(userId: string) {
|
||||
|
||||
await prisma.youTubeConnection.update({
|
||||
where: { userId },
|
||||
data: { accessToken, expiresAt },
|
||||
data: {
|
||||
accessToken: encryptSecret(accessToken),
|
||||
// Re-encrypt refresh token if it was still plaintext legacy
|
||||
refreshToken: encryptSecret(refreshToken),
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
oauth2Client.setCredentials({
|
||||
access_token: accessToken,
|
||||
refresh_token: connection.refreshToken,
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
return google.youtube({ version: "v3", auth: oauth2Client });
|
||||
@@ -57,41 +65,153 @@ export async function uploadToYouTube(
|
||||
videoPath: string,
|
||||
item: JobItem,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const youtube = await getYouTubeClient(userId);
|
||||
const tags = parseTags(item.tags);
|
||||
|
||||
const privacyStatus =
|
||||
item.privacy === "PUBLIC"
|
||||
? "public"
|
||||
: item.privacy === "PRIVATE"
|
||||
? "private"
|
||||
: "unlisted";
|
||||
|
||||
const response = await youtube.videos.insert({
|
||||
part: ["snippet", "status"],
|
||||
notifySubscribers: item.notifySubscribers,
|
||||
requestBody: {
|
||||
snippet: {
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
categoryId: item.categoryId,
|
||||
},
|
||||
status: {
|
||||
privacyStatus,
|
||||
embeddable: item.embeddable,
|
||||
selfDeclaredMadeForKids: item.madeForKids,
|
||||
license: item.creativeCommons ? "creativeCommon" : "youtube",
|
||||
},
|
||||
},
|
||||
media: {
|
||||
body: fs.createReadStream(videoPath),
|
||||
},
|
||||
});
|
||||
|
||||
const videoId = response.data.id;
|
||||
if (!videoId) throw new Error("YouTube upload succeeded but no video ID returned");
|
||||
|
||||
if (item.playlistId) {
|
||||
try {
|
||||
await addVideoToPlaylist(youtube, item.playlistId, videoId);
|
||||
} catch (playlistErr) {
|
||||
throw new Error(
|
||||
`Video uploaded (${videoId}) but failed to add to playlist: ${formatYouTubeErrorForUser(playlistErr)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return videoId;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.startsWith("Video uploaded (")) {
|
||||
throw err;
|
||||
}
|
||||
throw new Error(formatYouTubeErrorForUser(err));
|
||||
}
|
||||
}
|
||||
|
||||
export type PlaylistPrivacy = "public" | "unlisted" | "private";
|
||||
|
||||
export type CreatePlaylistInput = {
|
||||
title: string;
|
||||
description?: string;
|
||||
privacy?: PlaylistPrivacy;
|
||||
};
|
||||
|
||||
function toPlaylistPrivacyStatus(privacy?: PlaylistPrivacy) {
|
||||
if (privacy === "public") return "public";
|
||||
if (privacy === "unlisted") return "unlisted";
|
||||
return "private";
|
||||
}
|
||||
|
||||
export async function listYouTubePlaylists(userId: string) {
|
||||
const youtube = await getYouTubeClient(userId);
|
||||
const tags = parseTags(item.tags);
|
||||
const playlists: Array<{ id: string; title: string; itemCount: number }> = [];
|
||||
let pageToken: string | undefined;
|
||||
|
||||
const privacyStatus =
|
||||
item.privacy === "PUBLIC"
|
||||
? "public"
|
||||
: item.privacy === "PRIVATE"
|
||||
? "private"
|
||||
: "unlisted";
|
||||
do {
|
||||
const response = await youtube.playlists.list({
|
||||
part: ["snippet", "contentDetails"],
|
||||
mine: true,
|
||||
maxResults: 50,
|
||||
pageToken,
|
||||
});
|
||||
|
||||
const response = await youtube.videos.insert({
|
||||
part: ["snippet", "status"],
|
||||
notifySubscribers: item.notifySubscribers,
|
||||
for (const playlist of response.data.items ?? []) {
|
||||
if (!playlist.id || !playlist.snippet?.title) continue;
|
||||
playlists.push({
|
||||
id: playlist.id,
|
||||
title: playlist.snippet.title,
|
||||
itemCount: playlist.contentDetails?.itemCount ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
pageToken = response.data.nextPageToken ?? undefined;
|
||||
} while (pageToken);
|
||||
|
||||
return playlists;
|
||||
}
|
||||
|
||||
export async function createYouTubePlaylist(userId: string, input: CreatePlaylistInput) {
|
||||
const title = input.title?.trim();
|
||||
if (!title) throw new Error("Playlist title is required");
|
||||
|
||||
try {
|
||||
const youtube = await getYouTubeClient(userId);
|
||||
const response = await youtube.playlists.insert({
|
||||
part: ["snippet", "status"],
|
||||
requestBody: {
|
||||
snippet: {
|
||||
title,
|
||||
description: input.description?.trim() || "",
|
||||
},
|
||||
status: {
|
||||
privacyStatus: toPlaylistPrivacyStatus(input.privacy),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const id = response.data.id;
|
||||
if (!id) throw new Error("YouTube created the playlist but returned no ID");
|
||||
|
||||
return {
|
||||
id,
|
||||
title: response.data.snippet?.title || title,
|
||||
itemCount: 0,
|
||||
privacy: input.privacy ?? "private",
|
||||
};
|
||||
} catch (err) {
|
||||
throw new Error(formatYouTubeErrorForUser(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function addVideoToPlaylist(
|
||||
youtube: Awaited<ReturnType<typeof getYouTubeClient>>,
|
||||
playlistId: string,
|
||||
videoId: string,
|
||||
) {
|
||||
await youtube.playlistItems.insert({
|
||||
part: ["snippet"],
|
||||
requestBody: {
|
||||
snippet: {
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
categoryId: item.categoryId,
|
||||
playlistId,
|
||||
resourceId: {
|
||||
kind: "youtube#video",
|
||||
videoId,
|
||||
},
|
||||
},
|
||||
status: {
|
||||
privacyStatus,
|
||||
embeddable: item.embeddable,
|
||||
selfDeclaredMadeForKids: item.madeForKids,
|
||||
licenseId: item.creativeCommons ? "creativeCommon" : "youtube",
|
||||
},
|
||||
},
|
||||
media: {
|
||||
body: fs.createReadStream(videoPath),
|
||||
},
|
||||
});
|
||||
|
||||
const videoId = response.data.id;
|
||||
if (!videoId) throw new Error("YouTube upload succeeded but no video ID returned");
|
||||
return videoId;
|
||||
}
|
||||
|
||||
export async function fetchYouTubeChannel(accessToken: string, refreshToken: string) {
|
||||
|
||||
Reference in New Issue
Block a user