Strip Songs2VID to a payment-free self-hosted OSS core.
Remove Stripe/billing/pricing/marketing, simplify schema and entitlements for unlimited self-host use, and keep auth, encode, and YouTube upload.
This commit is contained in:
+22
-276
@@ -2,9 +2,8 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plan, Privacy } from "@prisma/client";
|
||||
import { Privacy } from "@prisma/client";
|
||||
import { filenameWithoutExtension } from "@/lib/constants";
|
||||
import { getVideoAttributionText } from "@/lib/branding";
|
||||
import { audioTagsToMetadata } from "@/lib/audio-tags";
|
||||
import { resolveYouTubeTitle } from "@/lib/titles";
|
||||
import { SONG_TITLE_MAX } from "@/lib/layout";
|
||||
@@ -16,10 +15,6 @@ import { LayoutStudio } from "./LayoutStudio";
|
||||
import { PlaylistSelect } from "./PlaylistSelect";
|
||||
import { PrivacyToggle } from "./PrivacyToggle";
|
||||
import { ResolutionSelect } from "./ResolutionSelect";
|
||||
import { QuotaErrorMessage, UpgradeProLink } from "./UpgradeProLink";
|
||||
import { UpgradeProButton } from "./UpgradeProButton";
|
||||
import { WatermarkPaywallModal } from "./WatermarkPaywallModal";
|
||||
import { FREE_UNWATERMARKED_VIDEO_LIMIT } from "@/lib/watermark-policy";
|
||||
|
||||
const UPLOAD_CONCURRENCY = 4;
|
||||
|
||||
@@ -71,55 +66,17 @@ export function UploadForm() {
|
||||
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;
|
||||
extraCredits: number;
|
||||
totalAvailable: number;
|
||||
used: number;
|
||||
selfHosted?: boolean;
|
||||
customWatermark?: boolean;
|
||||
perItemImages?: boolean;
|
||||
artTrackLayouts?: boolean;
|
||||
subscriptionStatus?: "free" | "pro";
|
||||
createdVideoCount?: number;
|
||||
watermarkFreeRemaining?: number | null;
|
||||
} | null>(null);
|
||||
const [showWatermarkPaywall, setShowWatermarkPaywall] = useState(false);
|
||||
const [watermarkDefaultsApplied, setWatermarkDefaultsApplied] = useState(false);
|
||||
|
||||
const isPro = Boolean(quota?.selfHosted || quota?.plan === "PREMIUM");
|
||||
const canPerItemImages = Boolean(quota?.perItemImages || isPro);
|
||||
const canCustomWatermark = Boolean(quota?.customWatermark || isPro);
|
||||
const canArtTrackLayouts = Boolean(quota?.artTrackLayouts || isPro);
|
||||
|
||||
const loadQuota = useCallback(async () => {
|
||||
const res = await fetch("/api/quota");
|
||||
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 ?? data.extraCredits ?? 0,
|
||||
extraCredits: data.extraCredits ?? data.videoCredits ?? 0,
|
||||
totalAvailable: data.totalAvailable ?? data.remaining,
|
||||
used: data.used ?? 0,
|
||||
selfHosted: Boolean(data.selfHosted),
|
||||
customWatermark: Boolean(data.customWatermark),
|
||||
perItemImages: Boolean(data.perItemImages),
|
||||
artTrackLayouts: Boolean(data.artTrackLayouts),
|
||||
subscriptionStatus: data.subscriptionStatus === "pro" ? "pro" : "free",
|
||||
createdVideoCount: typeof data.createdVideoCount === "number" ? data.createdVideoCount : 0,
|
||||
watermarkFreeRemaining:
|
||||
data.watermarkFreeRemaining === null || data.watermarkFreeRemaining === undefined
|
||||
? null
|
||||
: Number(data.watermarkFreeRemaining),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
@@ -128,21 +85,6 @@ export function UploadForm() {
|
||||
loadQuota();
|
||||
}, [loadQuota]);
|
||||
|
||||
// Align free-tier watermark default with remaining watermark-free slots (once).
|
||||
useEffect(() => {
|
||||
if (!quota || watermarkDefaultsApplied) return;
|
||||
if (quota.selfHosted || quota.plan === "PREMIUM") {
|
||||
setWatermarkDefaultsApplied(true);
|
||||
return;
|
||||
}
|
||||
const remaining = quota.watermarkFreeRemaining ?? 0;
|
||||
setJobWatermark({
|
||||
...DEFAULT_WATERMARK,
|
||||
mode: remaining > 0 ? "none" : "default",
|
||||
});
|
||||
setWatermarkDefaultsApplied(true);
|
||||
}, [quota, watermarkDefaultsApplied]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
|
||||
@@ -257,10 +199,6 @@ export function UploadForm() {
|
||||
}
|
||||
|
||||
async function handleItemImageChange(itemId: string, file: File | null) {
|
||||
if (!canPerItemImages) {
|
||||
setError("Matching a unique image per audio requires Pro.");
|
||||
return;
|
||||
}
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
const preview = URL.createObjectURL(file);
|
||||
@@ -354,9 +292,9 @@ export function UploadForm() {
|
||||
if (!files.length) return;
|
||||
setError(null);
|
||||
|
||||
const maxBatch = quota?.maxBatchSize ?? 3;
|
||||
const maxBatch = quota?.maxBatchSize ?? 100;
|
||||
if (audioItems.length + files.length > maxBatch) {
|
||||
setError(`Your plan allows up to ${maxBatch} audio files per batch.`);
|
||||
setError(`You can upload up to ${maxBatch} audio files per batch.`);
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
@@ -405,7 +343,6 @@ export function UploadForm() {
|
||||
songTitle: a.metadata.songTitle,
|
||||
artist: a.metadata.artist,
|
||||
},
|
||||
isPro ? Plan.PREMIUM : Plan.FREE,
|
||||
),
|
||||
),
|
||||
) &&
|
||||
@@ -418,24 +355,7 @@ export function UploadForm() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const forcedWatermark =
|
||||
!isPro &&
|
||||
!quota?.selfHosted &&
|
||||
(quota?.createdVideoCount ?? 0) >= FREE_UNWATERMARKED_VIDEO_LIMIT;
|
||||
const effectiveWatermark: WatermarkSettings = forcedWatermark
|
||||
? { ...DEFAULT_WATERMARK, mode: "default" }
|
||||
: canCustomWatermark
|
||||
? jobWatermark
|
||||
: {
|
||||
mode: jobWatermark.mode === "none" ? "none" : "default",
|
||||
position: "bottom-right",
|
||||
offsetX: 20,
|
||||
offsetY: 20,
|
||||
text: null,
|
||||
logoPath: null,
|
||||
fontKey: "system",
|
||||
fontPath: null,
|
||||
};
|
||||
const effectiveWatermark: WatermarkSettings = jobWatermark;
|
||||
|
||||
const res = await fetch("/api/jobs", {
|
||||
method: "POST",
|
||||
@@ -447,10 +367,10 @@ export function UploadForm() {
|
||||
audioFilename: item.file.name,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
imagePath: canPerItemImages ? item.itemImagePath || item.metadata.imagePath || null : null,
|
||||
imagePath: item.itemImagePath || item.metadata.imagePath || null,
|
||||
includeWatermark: effectiveWatermark.mode !== "none",
|
||||
watermark: effectiveWatermark,
|
||||
layout: canArtTrackLayouts ? jobLayout : { ...DEFAULT_LAYOUT },
|
||||
layout: jobLayout,
|
||||
},
|
||||
})),
|
||||
}),
|
||||
@@ -458,9 +378,6 @@ export function UploadForm() {
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -475,79 +392,20 @@ export function UploadForm() {
|
||||
e.preventDefault();
|
||||
if (!canSubmit || !imagePath) return;
|
||||
|
||||
const needsPaywall =
|
||||
!isPro &&
|
||||
!quota?.selfHosted &&
|
||||
(quota?.createdVideoCount ?? 0) >= FREE_UNWATERMARKED_VIDEO_LIMIT;
|
||||
|
||||
if (needsPaywall) {
|
||||
setShowWatermarkPaywall(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await submitJob();
|
||||
}
|
||||
|
||||
function watermarkStatusLabel(): string | null {
|
||||
if (!quota || quota.selfHosted) return null;
|
||||
if (quota.plan === "PREMIUM" || quota.subscriptionStatus === "pro") {
|
||||
return "Pro Member (No Watermark)";
|
||||
}
|
||||
const remaining = quota.watermarkFreeRemaining ?? 0;
|
||||
if (remaining > 0) {
|
||||
return `Free Tier: ${remaining}/${FREE_UNWATERMARKED_VIDEO_LIMIT} Watermark-Free Renders Left`;
|
||||
}
|
||||
return "Free Tier (Watermark Active)";
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<WatermarkPaywallModal
|
||||
open={showWatermarkPaywall}
|
||||
onClose={() => setShowWatermarkPaywall(false)}
|
||||
onContinueWithWatermark={() => {
|
||||
setShowWatermarkPaywall(false);
|
||||
void submitJob();
|
||||
}}
|
||||
/>
|
||||
|
||||
{quota && (
|
||||
<div className="rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm text-gray-300">
|
||||
{watermarkStatusLabel() ? (
|
||||
<p className="mb-2 font-medium text-white">{watermarkStatusLabel()}</p>
|
||||
) : null}
|
||||
{quota.selfHosted ? (
|
||||
<>
|
||||
Self-hosted · {quota.used} videos processed · up to {quota.maxBatchSize} files per
|
||||
batch
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{quota.remaining} of {quota.limit} plan videos remaining
|
||||
{quota.extraCredits > 0
|
||||
? ` · ${quota.extraCredits} extra credits (${quota.totalAvailable} total available)`
|
||||
: ""}{" "}
|
||||
·{" "}
|
||||
{quota.plan === "FREE"
|
||||
? `resets in ${quota.resetsIn}`
|
||||
: `monthly quota resets on ${quota.resetsIn}`}{" "}
|
||||
· up to {quota.maxBatchSize} files per batch
|
||||
{quota.plan === "FREE" ? (
|
||||
<>
|
||||
{" · "}
|
||||
<a href="/dashboard/settings" className="text-accent hover:underline">
|
||||
Buy 1–15 credits
|
||||
</a>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
Self-hosted · {quota.used} videos created · 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>
|
||||
)}
|
||||
|
||||
@@ -570,9 +428,7 @@ export function UploadForm() {
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
{canPerItemImages
|
||||
? "Shared cover for all tracks. Optionally override per audio below (Pro)."
|
||||
: "Free plan: one static cover for the whole batch."}
|
||||
Shared cover for all tracks. You can override it per audio below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -580,11 +436,7 @@ export function UploadForm() {
|
||||
<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"
|
||||
@@ -606,14 +458,14 @@ export function UploadForm() {
|
||||
<h2 className="text-lg font-medium text-white">Video details (per audio)</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Each audio file gets its own metadata. Video title is used for YouTube; song title and
|
||||
artist appear in Pro art-track layouts.
|
||||
artist appear in art-track layouts.
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg border border-gray-700 bg-surface p-6">
|
||||
<PlaylistSelect
|
||||
value={playlistId}
|
||||
onChange={handlePlaylistChange}
|
||||
enabled={Boolean(quota?.selfHosted || quota?.plan === "PREMIUM")}
|
||||
enabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -652,39 +504,29 @@ export function UploadForm() {
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<ProLockedField
|
||||
label="Song title (on-video)"
|
||||
locked={!isPro}
|
||||
hint="Pro unlocks custom song title burned into art-track layouts."
|
||||
>
|
||||
<Field label="Song title (on-video)">
|
||||
<input
|
||||
type="text"
|
||||
value={item.metadata.songTitle ?? ""}
|
||||
onChange={(e) => updateItemMetadata(item.id, { songTitle: e.target.value })}
|
||||
className="input-field disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={!isPro}
|
||||
placeholder={isPro ? "Shown in art-track layout" : "Pro feature"}
|
||||
className="input-field"
|
||||
placeholder="Shown in art-track layout"
|
||||
maxLength={SONG_TITLE_MAX}
|
||||
/>
|
||||
</ProLockedField>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ProLockedField
|
||||
label="Artist (on-video)"
|
||||
locked={!isPro}
|
||||
hint="Pro unlocks custom artist line in art-track layouts."
|
||||
>
|
||||
<Field label="Artist (on-video)">
|
||||
<input
|
||||
type="text"
|
||||
value={item.metadata.artist ?? ""}
|
||||
onChange={(e) => updateItemMetadata(item.id, { artist: e.target.value })}
|
||||
className="input-field disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={!isPro}
|
||||
placeholder={isPro ? "Shown in art-track layout" : "Pro feature"}
|
||||
className="input-field"
|
||||
placeholder="Shown in art-track layout"
|
||||
maxLength={80}
|
||||
/>
|
||||
</ProLockedField>
|
||||
</Field>
|
||||
<Field label="Category">
|
||||
<CategorySelect
|
||||
value={item.metadata.categoryId}
|
||||
@@ -723,30 +565,14 @@ export function UploadForm() {
|
||||
<ResolutionSelect
|
||||
value={item.metadata.resolution}
|
||||
onChange={(v) => updateItemMetadata(item.id, { resolution: v })}
|
||||
plan={quota?.plan}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="relative rounded border border-gray-800 bg-surface-light/40 p-3">
|
||||
{!canPerItemImages && (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="rounded border border-accent/40 bg-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-accent">
|
||||
Pro
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">Unique image per track</span>
|
||||
</div>
|
||||
)}
|
||||
<Field
|
||||
label={
|
||||
canPerItemImages
|
||||
? "Cover for this track (optional)"
|
||||
: "Cover for this track (Pro)"
|
||||
}
|
||||
>
|
||||
<Field label="Cover for this track (optional)">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
disabled={!canPerItemImages}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0] ?? null;
|
||||
void handleItemImageChange(item.id, f);
|
||||
@@ -760,11 +586,6 @@ export function UploadForm() {
|
||||
{item.itemImagePath ? `Using ${item.itemImageName}` : `Uploading ${item.itemImageName}…`}
|
||||
</p>
|
||||
)}
|
||||
{!canPerItemImages && (
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
<UpgradeProButton label="Upgrade for bulk unique image matching" />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
@@ -788,32 +609,6 @@ export function UploadForm() {
|
||||
checked={item.metadata.creativeCommons}
|
||||
onChange={(v) => updateItemMetadata(item.id, { creativeCommons: v })}
|
||||
/>
|
||||
{!canCustomWatermark && (
|
||||
<Checkbox
|
||||
label={
|
||||
!isPro &&
|
||||
(quota?.watermarkFreeRemaining ?? 0) <= 0 &&
|
||||
!quota?.selfHosted
|
||||
? "Songs2VID watermark (required on Free after 2 clean renders)"
|
||||
: `Show '${getVideoAttributionText()}' watermark (support open-source)`
|
||||
}
|
||||
checked={jobWatermark.mode !== "none"}
|
||||
onChange={(v) => {
|
||||
if (
|
||||
!v &&
|
||||
!isPro &&
|
||||
!quota?.selfHosted &&
|
||||
(quota?.watermarkFreeRemaining ?? 0) <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
applyWatermarkToAll({
|
||||
...DEFAULT_WATERMARK,
|
||||
mode: v ? "default" : "none",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -821,7 +616,7 @@ export function UploadForm() {
|
||||
)}
|
||||
|
||||
<LayoutStudio
|
||||
locked={!canArtTrackLayouts && !canCustomWatermark}
|
||||
locked={false}
|
||||
previewImageUrl={
|
||||
audioItems.find((a) => a.itemImagePreview)?.itemImagePreview || imagePreviewUrl
|
||||
}
|
||||
@@ -841,21 +636,6 @@ export function UploadForm() {
|
||||
logoPreviewUrl={logoPreviewUrl}
|
||||
/>
|
||||
|
||||
{quota && !quota.selfHosted && quota.plan === "FREE" && (
|
||||
<>
|
||||
<p className="text-sm text-gray-400">
|
||||
Free plan: first {FREE_UNWATERMARKED_VIDEO_LIMIT} successful videos are watermark-free;
|
||||
later renders include a Songs2VID watermark (bottom-right). One static cover image per
|
||||
batch.
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
<UpgradeProLink className="text-accent hover:underline" /> for unwatermarked videos,
|
||||
custom branding, typography, blurred art-track layouts, unique image matching, 1080p, and
|
||||
lossless audio.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
@@ -886,40 +666,6 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
||||
);
|
||||
}
|
||||
|
||||
function ProLockedField({
|
||||
label,
|
||||
locked,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
locked: boolean;
|
||||
hint: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-2 text-sm text-gray-400">
|
||||
{label}
|
||||
{locked && (
|
||||
<span
|
||||
className="rounded border border-accent/50 bg-accent/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-accent"
|
||||
title={hint}
|
||||
>
|
||||
Pro
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="relative">
|
||||
{children}
|
||||
{locked && (
|
||||
<p className="mt-1 text-xs text-gray-500">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Checkbox({
|
||||
label,
|
||||
checked,
|
||||
|
||||
Reference in New Issue
Block a user