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
+24
-101
@@ -1,23 +1,11 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { Privacy } from "@prisma/client";
|
||||
import { FREE_PLAN, isAllowedResolution } from "@/lib/constants";
|
||||
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
|
||||
import { createVideoJob } from "@/lib/jobs/create-job";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { enqueueVideoJob } from "@/lib/queue/client";
|
||||
import { checkQuota } from "@/lib/quota";
|
||||
import { requireAuth } from "@/lib/session";
|
||||
import { getJobDir } from "@/lib/storage";
|
||||
import type { CreateJobPayload } from "@/lib/types";
|
||||
|
||||
function validateItemMetadata(metadata: CreateJobPayload["items"][0]["metadata"]) {
|
||||
if (!metadata.title?.trim()) return "Each video must have a title";
|
||||
if (!isAllowedResolution(metadata.resolution)) return "Invalid resolution";
|
||||
if (!["PUBLIC", "PRIVATE", "UNLISTED"].includes(metadata.privacy)) {
|
||||
return "Invalid privacy setting";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export const maxDuration = 120;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requireAuth();
|
||||
@@ -25,102 +13,29 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const body = (await req.json()) as CreateJobPayload;
|
||||
|
||||
if (!body.imagePath || !body.items?.length) {
|
||||
return NextResponse.json({ error: "Image and at least one audio file required" }, { status: 400 });
|
||||
}
|
||||
|
||||
for (const item of body.items) {
|
||||
const metaError = validateItemMetadata(item.metadata);
|
||||
if (metaError) {
|
||||
return NextResponse.json({ error: metaError }, { status: 400 });
|
||||
}
|
||||
if (user.plan === "FREE" && !item.metadata.includeWatermark) {
|
||||
return NextResponse.json({ error: "Watermark is required on the free plan" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const quotaCheck = await checkQuota(user.id, body.items.length);
|
||||
if (!quotaCheck.ok) {
|
||||
return NextResponse.json({ error: quotaCheck.error }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(body.imagePath);
|
||||
for (const item of body.items) {
|
||||
await fs.access(item.audioPath);
|
||||
const stat = await fs.stat(item.audioPath);
|
||||
if (stat.size > FREE_PLAN.maxFileSizeBytes) {
|
||||
return NextResponse.json({ error: `Audio file ${item.audioFilename} exceeds size limit` }, { status: 400 });
|
||||
}
|
||||
const job = await createVideoJob(user, body);
|
||||
return NextResponse.json({ jobId: job.id });
|
||||
} catch (err) {
|
||||
if (err instanceof PremiumRequiredError) {
|
||||
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
|
||||
}
|
||||
const imageStat = await fs.stat(body.imagePath);
|
||||
if (imageStat.size > FREE_PLAN.maxFileSizeBytes) {
|
||||
return NextResponse.json({ error: "Image exceeds size limit" }, { status: 400 });
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ error: "One or more uploaded files not found" }, { status: 400 });
|
||||
const message = err instanceof Error ? err.message : "Failed to create job";
|
||||
const status = message.includes("Quota exceeded") ? 403 : 400;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
|
||||
const job = await prisma.job.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
imagePath: body.imagePath,
|
||||
items: {
|
||||
create: body.items.map((item) => ({
|
||||
audioPath: item.audioPath,
|
||||
audioFilename: item.audioFilename,
|
||||
title: item.metadata.title.trim(),
|
||||
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: user.plan === "FREE" ? true : item.metadata.includeWatermark,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
const jobDir = getJobDir(user.id, job.id);
|
||||
await fs.mkdir(jobDir, { recursive: true });
|
||||
|
||||
const imageExt = path.extname(body.imagePath);
|
||||
const newImagePath = path.join(jobDir, `image${imageExt}`);
|
||||
await fs.copyFile(body.imagePath, newImagePath);
|
||||
await prisma.job.update({ where: { id: job.id }, data: { imagePath: newImagePath } });
|
||||
|
||||
for (const item of job.items) {
|
||||
const audioExt = path.extname(item.audioPath);
|
||||
const newAudioPath = path.join(jobDir, `${item.id}${audioExt}`);
|
||||
await fs.copyFile(item.audioPath, newAudioPath);
|
||||
await prisma.jobItem.update({
|
||||
where: { id: item.id },
|
||||
data: { audioPath: newAudioPath },
|
||||
});
|
||||
|
||||
await enqueueVideoJob({
|
||||
jobItemId: item.id,
|
||||
userId: user.id,
|
||||
jobId: job.id,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ jobId: job.id });
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(req: NextRequest) {
|
||||
const { error, user } = await requireAuth();
|
||||
if (error || !user) return error!;
|
||||
|
||||
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100);
|
||||
|
||||
const jobs = await prisma.job.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 20,
|
||||
take: limit,
|
||||
include: {
|
||||
items: {
|
||||
select: {
|
||||
@@ -135,5 +50,13 @@ export async function GET() {
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ jobs });
|
||||
return NextResponse.json({
|
||||
jobs: jobs.map((job) => ({
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
createdAt: job.createdAt.toISOString(),
|
||||
completedAt: job.completedAt?.toISOString() ?? null,
|
||||
items: job.items,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user