Files
songs2vid/lib/jobs/create-job.ts
T
Atakan Doğan Özban 6476bb42a4 Strip Songs2VID to a payment-free self-hosted OSS core.
Remove Stripe/billing/pricing/marketing, simplify schema and entitlements for unlimited self-host use, and keep auth, encode, and YouTube upload.
2026-08-03 06:52:00 +02:00

507 lines
16 KiB
TypeScript

import fs from "fs/promises";
import path from "path";
import { Privacy } from "@prisma/client";
import { readAudioTags } from "../audio-tags";
import { filenameWithoutExtension, isAllowedResolution } from "../constants";
import { prisma } from "../db";
import {
assertArtTrackLayoutAllowed,
assertCustomWatermarkAllowed,
assertPerItemImagesAllowed,
} 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 { 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";
import { applyBrandWatermarkPolicy } from "../watermark-policy";
/** 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"],
) {
const titleErr = validateYouTubeTitle(metadata);
if (titleErr) return titleErr;
if (!isAllowedResolution(metadata.resolution)) return "Invalid resolution";
if (!["PUBLIC", "PRIVATE", "UNLISTED"].includes(metadata.privacy)) {
return "Invalid privacy setting";
}
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 }, 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);
if (metaError) return metaError;
if (!isResolutionAllowedForPlan(item.metadata.resolution)) {
return `Resolution ${item.metadata.resolution} is not available`;
}
const wm = normalizeWatermarkSettings(
item.metadata.watermark,
item.metadata.includeWatermark,
);
try {
assertCustomWatermarkAllowed(wm);
assertArtTrackLayoutAllowed(resolveLayoutFromMetadata(item.metadata));
} catch (err) {
if (err instanceof Error && err.message === INVALID_LAYOUT_TEMPLATE_MESSAGE) {
return INVALID_LAYOUT_TEMPLATE_MESSAGE;
}
throw err;
}
itemImagePaths.push(item.metadata.imagePath);
}
try {
assertPerItemImagesAllowed(body.imagePath, itemImagePaths);
} catch (err) {
throw err;
}
const limits = getPlanLimits();
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)) {
return `Audio file ${item.audioFilename} is not supported`;
}
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 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 },
body: CreateJobPayload,
) {
const validationError = await validateJobPayload(user, body);
if (validationError) {
if (validationError === INVALID_LAYOUT_TEMPLATE_MESSAGE) {
throw new Error(validationError);
}
throw new Error(validationError);
}
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,
}));
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 = applyBrandWatermarkPolicy(
item.metadata.watermark
? {
...item.metadata.watermark,
logoPath: item.watermarkLogoPath,
fontPath: item.watermarkFontPath,
}
: null,
item.metadata.includeWatermark,
);
const layout = resolveLayoutFromMetadata(item.metadata);
const youtubeTitle = resolveYouTubeTitle(item.metadata);
const burnedTitle = resolveBurnedSongTitle(
item.metadata,
filenameWithoutExtension(item.audioFilename),
);
return {
audioPath: item.audioPath,
audioFilename: item.audioFilename,
title: 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: item.metadata.artist?.trim().slice(0, ARTIST_MAX) || 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,
};
}),
},
},
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) {
throw err;
}
}
export type UploadFileType = "image" | "audio" | "logo" | "font";
export async function saveUploadedFile(
userId: string,
file: File,
type: UploadFileType,
options?: { sessionKey?: string },
) {
const limits = getPlanLimits();
if (type === "font") {
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") {
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)) {
const allowed = limits.allowedAudioExtensions.join(", ");
throw new Error(`Supported audio formats: ${allowed}`);
}
}
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,
};
}