"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Plan, Privacy } from "@prisma/client"; import { filenameWithoutExtension } from "@/lib/constants"; import { getVideoAttributionText } from "@/lib/branding"; import { audioTagsToMetadata } from "@/lib/audio-tags"; import type { ItemMetadata } from "@/lib/types"; import { DEFAULT_LAYOUT, type LayoutSettings } from "@/lib/layout"; import { DEFAULT_WATERMARK, type WatermarkSettings } from "@/lib/watermark"; import { CategorySelect } from "./CategorySelect"; import { LayoutStudio } from "./LayoutStudio"; import { PlaylistSelect } from "./PlaylistSelect"; import { PrivacyToggle } from "./PrivacyToggle"; import { ResolutionSelect } from "./ResolutionSelect"; const UPLOAD_CONCURRENCY = 4; type AudioItem = { id: string; file: File; path: string | null; uploading: boolean; itemImagePath: string | null; itemImageName: string | null; itemImagePreview: string | null; metadata: ItemMetadata; }; function defaultMetadata(title = ""): ItemMetadata { return { title, description: "", tags: "", privacy: "PUBLIC" as Privacy, categoryId: "10", resolution: "1280x720", notifySubscribers: true, madeForKids: false, embeddable: true, creativeCommons: false, includeWatermark: true, playlistId: null, imagePath: null, artist: null, watermark: { ...DEFAULT_WATERMARK }, layout: { ...DEFAULT_LAYOUT }, }; } export function UploadForm() { const router = useRouter(); const uploadSessionRef = useRef(crypto.randomUUID()); const [imageFile, setImageFile] = useState(null); const [imagePath, setImagePath] = useState(null); const [imagePreviewUrl, setImagePreviewUrl] = useState(null); const [imageUploading, setImageUploading] = useState(false); const [audioItems, setAudioItems] = useState([]); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [playlistId, setPlaylistId] = useState(""); const [jobWatermark, setJobWatermark] = useState({ ...DEFAULT_WATERMARK }); const [jobLayout, setJobLayout] = useState({ ...DEFAULT_LAYOUT }); const [logoPreviewUrl, setLogoPreviewUrl] = useState(null); const [quota, setQuota] = useState<{ plan: Plan; maxBatchSize: number; used: number; } | null>(null); const loadQuota = useCallback(async () => { const res = await fetch("/api/quota"); if (res.ok) { const data = await res.json(); setQuota({ plan: data.plan, maxBatchSize: data.maxBatchSize, used: data.used ?? 0, }); } }, []); useEffect(() => { loadQuota(); }, [loadQuota]); useEffect(() => { return () => { if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl); if (logoPreviewUrl) URL.revokeObjectURL(logoPreviewUrl); audioItems.forEach((a) => { if (a.itemImagePreview) URL.revokeObjectURL(a.itemImagePreview); }); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup on unmount only }, []); async function uploadFile( file: File, type: "image" | "audio" | "logo" | "font", ): Promise<{ path: string; audioTags?: Parameters[0] | null }> { const formData = new FormData(); formData.append("file", file); formData.append("type", type); formData.append("session", uploadSessionRef.current); const res = await fetch("/api/upload", { method: "POST", body: formData }); const data = await res.json(); if (!res.ok) { throw new Error(data.error || "Upload failed"); } return { path: data.path, audioTags: data.audioTags }; } async function uploadFilesWithConcurrency(files: File[]) { const pending = files.map((file) => { const id = crypto.randomUUID(); const autoTitle = filenameWithoutExtension(file.name); setAudioItems((prev) => [ ...prev, { id, file, path: null, uploading: true, itemImagePath: null, itemImageName: null, itemImagePreview: null, metadata: { ...defaultMetadata(autoTitle), playlistId: playlistId || null, watermark: { ...jobWatermark }, layout: { ...jobLayout }, }, }, ]); return { id, file, autoTitle }; }); for (let i = 0; i < pending.length; i += UPLOAD_CONCURRENCY) { const chunk = pending.slice(i, i + UPLOAD_CONCURRENCY); const results = await Promise.allSettled( chunk.map(async ({ id, file, autoTitle }) => { const upload = await uploadFile(file, "audio"); const tagMetadata = upload.audioTags ? audioTagsToMetadata(upload.audioTags, autoTitle) : null; setAudioItems((prev) => prev.map((item) => item.id === id ? { ...item, path: upload.path, uploading: false, metadata: tagMetadata ? { ...item.metadata, ...tagMetadata } : item.metadata, } : item, ), ); }), ); for (let j = 0; j < results.length; j++) { if (results[j].status === "rejected") { const { id } = chunk[j]; const reason = results[j] as PromiseRejectedResult; setError(reason.reason instanceof Error ? reason.reason.message : "Audio upload failed"); setAudioItems((prev) => prev.filter((item) => item.id !== id)); } } } } async function handleImageChange(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; setError(null); setImageFile(file); setImageUploading(true); if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl); setImagePreviewUrl(URL.createObjectURL(file)); try { const path = await uploadFile(file, "image"); setImagePath(path.path); } catch (err) { setError(err instanceof Error ? err.message : "Image upload failed"); setImageFile(null); setImagePath(null); if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl); setImagePreviewUrl(null); } finally { setImageUploading(false); } } async function handleItemImageChange(itemId: string, file: File | null) { if (!true) { setError("Matching a unique image per audio requires Pro."); return; } if (!file) return; setError(null); const preview = URL.createObjectURL(file); setAudioItems((prev) => prev.map((item) => { if (item.id !== itemId) return item; if (item.itemImagePreview) URL.revokeObjectURL(item.itemImagePreview); return { ...item, itemImageName: file.name, itemImagePreview: preview, itemImagePath: null, }; }), ); try { const uploaded = await uploadFile(file, "image"); setAudioItems((prev) => prev.map((item) => item.id === itemId ? { ...item, itemImagePath: uploaded.path, metadata: { ...item.metadata, imagePath: uploaded.path }, } : item, ), ); } catch (err) { setError(err instanceof Error ? err.message : "Per-item image upload failed"); setAudioItems((prev) => prev.map((item) => { if (item.id !== itemId) return item; if (item.itemImagePreview) URL.revokeObjectURL(item.itemImagePreview); return { ...item, itemImagePath: null, itemImageName: null, itemImagePreview: null, metadata: { ...item.metadata, imagePath: null }, }; }), ); } } async function handleLogoUpload(file: File) { const preview = URL.createObjectURL(file); if (logoPreviewUrl) URL.revokeObjectURL(logoPreviewUrl); setLogoPreviewUrl(preview); const uploaded = await uploadFile(file, "logo"); return uploaded.path; } async function handleFontUpload(file: File) { const uploaded = await uploadFile(file, "font"); return uploaded.path; } function applyWatermarkToAll(next: WatermarkSettings) { const normalized = { ...DEFAULT_WATERMARK, ...next }; setJobWatermark(normalized); setAudioItems((prev) => prev.map((item) => ({ ...item, metadata: { ...item.metadata, includeWatermark: normalized.mode !== "none", watermark: { ...normalized }, }, })), ); } function applyLayoutToAll(next: LayoutSettings) { const normalized = { ...DEFAULT_LAYOUT, ...next }; setJobLayout(normalized); setAudioItems((prev) => prev.map((item) => ({ ...item, metadata: { ...item.metadata, layout: { ...normalized }, }, })), ); } async function handleAudioChange(e: React.ChangeEvent) { const files = Array.from(e.target.files || []); if (!files.length) return; setError(null); const maxBatch = quota?.maxBatchSize ?? 3; if (audioItems.length + files.length > maxBatch) { setError(`Your plan allows up to ${maxBatch} audio files per batch.`); e.target.value = ""; return; } await uploadFilesWithConcurrency(files); e.target.value = ""; } function updateItemMetadata(id: string, updates: Partial) { setAudioItems((prev) => prev.map((item) => item.id === id ? { ...item, metadata: { ...item.metadata, ...updates } } : item, ), ); } function handlePlaylistChange(nextPlaylistId: string) { setPlaylistId(nextPlaylistId); setAudioItems((prev) => prev.map((item) => ({ ...item, metadata: { ...item.metadata, playlistId: nextPlaylistId || null, }, })), ); } function removeAudioItem(id: string) { setAudioItems((prev) => prev.filter((item) => item.id !== id)); } const readyAudios = audioItems.filter((a) => a.path && !a.uploading); const canSubmit = imagePath && !imageUploading && readyAudios.length > 0 && readyAudios.every((a) => a.metadata.title.trim()) && !submitting; async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!canSubmit || !imagePath) return; setSubmitting(true); setError(null); try { const res = await fetch("/api/jobs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ imagePath, items: readyAudios.map((item) => ({ audioPath: item.path!, audioFilename: item.file.name, metadata: { ...item.metadata, imagePath: true ? item.itemImagePath || item.metadata.imagePath || null : null, includeWatermark: jobWatermark.mode !== "none", watermark: true ? jobWatermark : { mode: jobWatermark.mode === "none" ? "none" : "default", position: "bottom-right", offsetX: 20, offsetY: 20, }, layout: jobLayout, }, })), }), }); const data = await res.json(); if (!res.ok) { if (data.code === "PREMIUM_REQUIRED") { throw new Error(data.error || "This feature requires Pro."); } throw new Error(data.error || "Failed to create job"); } router.push(`/jobs/${data.jobId}`); } catch (err) { setError(err instanceof Error ? err.message : "Submission failed"); setSubmitting(false); } } return (
{quota && (
Self-hosted · {quota.used} videos processed · up to {quota.maxBatchSize} files per batch
)} {error && (
{error}
)}

Files

{imageUploading ? "Uploading…" : imageFile?.name || "No image selected"}

Shared cover for all tracks. Optionally override per audio below.

0} /> {audioItems.length === 0 ? "No audio files selected" : `${readyAudios.length} of ${audioItems.length} ready`}
{audioItems.length > 0 && (

Video details (per audio)

Each audio file gets its own metadata. Title is auto-filled from the filename.

{audioItems.map((item, index) => (

Video {index + 1}: {item.file.name}

{item.uploading && (

Uploading…

)}
updateItemMetadata(item.id, { title: e.target.value })} className="input-field" required /> updateItemMetadata(item.id, { artist: e.target.value })} className="input-field" placeholder="Optional shown in layout templates" maxLength={80} />
updateItemMetadata(item.id, { categoryId: v })} />