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.
535 lines
18 KiB
TypeScript
535 lines
18 KiB
TypeScript
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,
|
|
};
|
|
}
|