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, isLowerCornerTemplate, 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, blurFill: metadata.layout?.blurFill ?? (metadata.layout as { blur_fill?: boolean } | null | undefined)?.blur_fill ?? metadata.blurFill ?? metadata.blur_fill, blurOpacity: metadata.layout?.blurOpacity ?? (metadata.layout as { blur_opacity?: number } | null | undefined)?.blur_opacity ?? 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, titleBold: metadata.layout?.titleBold ?? (metadata.layout as { title_bold?: boolean } | null | undefined)?.title_bold ?? metadata.titleBold ?? metadata.title_bold, }); } 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 = []; 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 layoutForBg = resolveLayoutFromMetadata(item.metadata); const bgRaw = item.metadata.backgroundImagePath ?? item.metadata.background_image_path ?? null; if (bgRaw && isLowerCornerTemplate(layoutForBg.template)) { const bgPath = assertPathInUserUploads(user.id, bgRaw); await fs.access(bgPath); const st = await fs.stat(bgPath); if (st.size > limits.maxFileSizeBytes) { return `Background image for ${item.audioFilename} exceeds size limit`; } } const wm = normalizeWatermarkSettings( item.metadata.watermark, item.metadata.includeWatermark, ); 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) => { const layout = resolveLayoutFromMetadata(item.metadata); const bgRaw = item.metadata.backgroundImagePath ?? item.metadata.background_image_path ?? null; const backgroundImagePath = bgRaw && isLowerCornerTemplate(layout.template) ? assertPathInUserUploads(user.id, bgRaw) : null; return { ...item, audioPath: assertPathInUserUploads(user.id, item.audioPath), itemImagePath: item.metadata.imagePath ? assertPathInUserUploads(user.id, item.metadata.imagePath) : null, backgroundImagePath, watermarkLogoPath: item.metadata.watermark?.logoPath ? assertPathInUserUploads(user.id, item.metadata.watermark.logoPath) : null, watermarkFontPath: item.metadata.watermark?.fontKey === "custom" && item.metadata.watermark?.fontPath ? assertPathInUserUploads(user.id, item.metadata.watermark.fontPath) : null, }; }); await reserveQuota(user.id, items.length); const webhookRaw = body.webhookUrl?.trim() || null; if (webhookRaw) { try { const u = new URL(webhookRaw); if (u.protocol !== "https:" && u.protocol !== "http:") { throw new Error("webhookUrl must be http(s)"); } } catch { throw new Error("Invalid webhookUrl. Provide an absolute http(s) URL for n8n callbacks."); } } try { const job = await prisma.job.create({ data: { userId: user.id, imagePath, webhookUrl: webhookRaw, items: { create: items.map((item, index) => { const wm = applyBrandWatermarkPolicy( 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, backgroundImagePath: item.backgroundImagePath, 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, blurFill: layout.blurFill, blurAmount: layout.blurAmount, blurOpacity: layout.blurOpacity, textPadding: layout.textPadding, titleArtistGap: layout.titleArtistGap, titleBold: layout.titleBold, textOffsetX: layout.textOffsetX, textOffsetY: layout.textOffsetY, playlistId: item.metadata.playlistId?.trim() || null, }; }), }, }, 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(); 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 newBackgroundImage: string | null = null; if (item.backgroundImagePath) { const src = item.backgroundImagePath; if (moved.has(src)) { newBackgroundImage = moved.get(src)!; } else { const ext = path.extname(src); newBackgroundImage = path.join(jobDir, `${item.id}-bg${ext}`); await moveFile(src, newBackgroundImage); moved.set(src, newBackgroundImage); } } let newLogo: string | null = null; if (item.watermarkLogoPath) { const src = item.watermarkLogoPath; 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, backgroundImagePath: newBackgroundImage, 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, }; }