Sync layouts, typography, and watermark studio for self-hosted OSS.
Unlock features without credits/Stripe, point docs to docs.songs2vid.com, and drop leftover billing admin surfaces. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+213
-60
@@ -4,14 +4,16 @@ 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";
|
||||
import { QuotaErrorMessage, UpgradeProLink } from "./UpgradeProLink";
|
||||
|
||||
const UPLOAD_CONCURRENCY = 4;
|
||||
|
||||
type AudioItem = {
|
||||
@@ -19,6 +21,9 @@ type AudioItem = {
|
||||
file: File;
|
||||
path: string | null;
|
||||
uploading: boolean;
|
||||
itemImagePath: string | null;
|
||||
itemImageName: string | null;
|
||||
itemImagePreview: string | null;
|
||||
metadata: ItemMetadata;
|
||||
};
|
||||
|
||||
@@ -36,6 +41,10 @@ function defaultMetadata(title = ""): ItemMetadata {
|
||||
creativeCommons: false,
|
||||
includeWatermark: true,
|
||||
playlistId: null,
|
||||
imagePath: null,
|
||||
artist: null,
|
||||
watermark: { ...DEFAULT_WATERMARK },
|
||||
layout: { ...DEFAULT_LAYOUT },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,21 +53,19 @@ export function UploadForm() {
|
||||
const uploadSessionRef = useRef<string>(crypto.randomUUID());
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePath, setImagePath] = useState<string | null>(null);
|
||||
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
|
||||
const [imageUploading, setImageUploading] = useState(false);
|
||||
const [audioItems, setAudioItems] = useState<AudioItem[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [playlistId, setPlaylistId] = useState("");
|
||||
const [jobWatermark, setJobWatermark] = useState<WatermarkSettings>({ ...DEFAULT_WATERMARK });
|
||||
const [jobLayout, setJobLayout] = useState<LayoutSettings>({ ...DEFAULT_LAYOUT });
|
||||
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
|
||||
const [quota, setQuota] = useState<{
|
||||
remaining: number;
|
||||
limit: number;
|
||||
plan: Plan;
|
||||
maxBatchSize: number;
|
||||
resetsIn: string;
|
||||
videoCredits: number;
|
||||
totalAvailable: number;
|
||||
used: number;
|
||||
selfHosted?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const loadQuota = useCallback(async () => {
|
||||
@@ -66,15 +73,9 @@ export function UploadForm() {
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setQuota({
|
||||
remaining: data.remaining,
|
||||
limit: data.limit,
|
||||
plan: data.plan,
|
||||
maxBatchSize: data.maxBatchSize,
|
||||
resetsIn: data.resetsIn,
|
||||
videoCredits: data.videoCredits ?? 0,
|
||||
totalAvailable: data.totalAvailable ?? data.remaining,
|
||||
used: data.used ?? 0,
|
||||
selfHosted: Boolean(data.selfHosted),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
@@ -83,20 +84,30 @@ export function UploadForm() {
|
||||
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",
|
||||
type: "image" | "audio" | "logo" | "font",
|
||||
): Promise<{ path: string; audioTags?: Parameters<typeof audioTagsToMetadata>[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) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Upload failed");
|
||||
}
|
||||
const data = await res.json();
|
||||
return { path: data.path, audioTags: data.audioTags };
|
||||
}
|
||||
|
||||
@@ -112,9 +123,14 @@ export function UploadForm() {
|
||||
file,
|
||||
path: null,
|
||||
uploading: true,
|
||||
itemImagePath: null,
|
||||
itemImageName: null,
|
||||
itemImagePreview: null,
|
||||
metadata: {
|
||||
...defaultMetadata(autoTitle),
|
||||
playlistId: playlistId || null,
|
||||
watermark: { ...jobWatermark },
|
||||
layout: { ...jobLayout },
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -165,6 +181,8 @@ export function UploadForm() {
|
||||
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);
|
||||
@@ -172,11 +190,106 @@ export function UploadForm() {
|
||||
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<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
@@ -244,13 +357,31 @@ export function UploadForm() {
|
||||
items: readyAudios.map((item) => ({
|
||||
audioPath: item.path!,
|
||||
audioFilename: item.file.name,
|
||||
metadata: item.metadata,
|
||||
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) throw new Error(data.error || "Failed to create job");
|
||||
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) {
|
||||
@@ -263,30 +394,13 @@ export function UploadForm() {
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{quota && (
|
||||
<div className="rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm text-gray-300">
|
||||
{quota.selfHosted ? (
|
||||
<>
|
||||
Self-hosted · {quota.used} videos processed · up to {quota.maxBatchSize} files per
|
||||
batch
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{quota.remaining} of {quota.limit} plan videos remaining
|
||||
{quota.plan === "FREE" && quota.videoCredits > 0
|
||||
? ` · ${quota.videoCredits} pay-as-you-go credits`
|
||||
: ""}{" "}
|
||||
·{" "}
|
||||
{quota.plan === "FREE"
|
||||
? `resets in ${quota.resetsIn}`
|
||||
: `monthly quota resets on ${quota.resetsIn}`}{" "}
|
||||
· up to {quota.maxBatchSize} files per batch
|
||||
</>
|
||||
)}
|
||||
Self-hosted · {quota.used} videos processed · up to {quota.maxBatchSize} files per batch
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
<QuotaErrorMessage message={error} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -308,17 +422,16 @@ export function UploadForm() {
|
||||
{imageUploading ? "Uploading…" : imageFile?.name || "No image selected"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Shared cover for all tracks. Optionally override per audio below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm text-gray-400">Audio files</label>
|
||||
<input
|
||||
type="file"
|
||||
accept={
|
||||
quota?.selfHosted || quota?.plan === "PREMIUM"
|
||||
? "audio/mpeg,audio/wav,audio/flac,.mp3,.wav,.flac"
|
||||
: "audio/mpeg,.mp3"
|
||||
}
|
||||
accept="audio/mpeg,audio/wav,audio/flac,.mp3,.wav,.flac"
|
||||
multiple
|
||||
onChange={handleAudioChange}
|
||||
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white"
|
||||
@@ -346,7 +459,7 @@ export function UploadForm() {
|
||||
<PlaylistSelect
|
||||
value={playlistId}
|
||||
onChange={handlePlaylistChange}
|
||||
enabled={Boolean(quota?.selfHosted || quota?.plan === "PREMIUM")}
|
||||
enabled={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -384,6 +497,19 @@ export function UploadForm() {
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Artist (art-track layouts)">
|
||||
<input
|
||||
type="text"
|
||||
value={item.metadata.artist ?? ""}
|
||||
onChange={(e) => updateItemMetadata(item.id, { artist: e.target.value })}
|
||||
className="input-field"
|
||||
placeholder="Optional shown in layout templates"
|
||||
maxLength={80}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Category">
|
||||
<CategorySelect
|
||||
value={item.metadata.categoryId}
|
||||
@@ -426,6 +552,31 @@ export function UploadForm() {
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="relative rounded border border-gray-800 bg-surface-light/40 p-3">
|
||||
|
||||
<Field
|
||||
label="Cover for this track (optional)"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
disabled={false}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0] ?? null;
|
||||
void handleItemImageChange(item.id, f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white disabled:opacity-50"
|
||||
/>
|
||||
</Field>
|
||||
{item.itemImageName && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{item.itemImagePath ? `Using ${item.itemImageName}` : `Uploading ${item.itemImageName}…`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Checkbox
|
||||
label="Notify subscribers about this upload"
|
||||
@@ -447,28 +598,30 @@ export function UploadForm() {
|
||||
checked={item.metadata.creativeCommons}
|
||||
onChange={(v) => updateItemMetadata(item.id, { creativeCommons: v })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Show 'Uploaded through Songs2YT.com' watermark (support open-source)"
|
||||
checked={item.metadata.includeWatermark}
|
||||
onChange={(v) => updateItemMetadata(item.id, { includeWatermark: v })}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{quota && !quota.selfHosted && quota.plan === "FREE" && (
|
||||
<>
|
||||
<p className="text-sm text-gray-400">
|
||||
Watermark is optional on the free plan - turn it off anytime for a clean video.
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
<UpgradeProLink className="text-accent hover:underline" /> for 50 videos/month, 1080p,
|
||||
lossless audio, and full watermark control.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<LayoutStudio
|
||||
locked={false}
|
||||
previewImageUrl={
|
||||
audioItems.find((a) => a.itemImagePreview)?.itemImagePreview || imagePreviewUrl
|
||||
}
|
||||
title={audioItems[0]?.metadata.title || "Track title"}
|
||||
artist={audioItems[0]?.metadata.artist || ""}
|
||||
layout={jobLayout}
|
||||
onLayoutChange={applyLayoutToAll}
|
||||
watermark={jobWatermark}
|
||||
onWatermarkChange={applyWatermarkToAll}
|
||||
onUploadLogo={handleLogoUpload}
|
||||
onUploadFont={handleFontUpload}
|
||||
logoPreviewUrl={logoPreviewUrl}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
Reference in New Issue
Block a user