Ship free-tier watermark policy (2 clean renders) and align pricing/legal copy.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
26c4b22fbb
commit
8f6b894f80
@@ -61,11 +61,11 @@ const PLANS: PlanConfig[] = [
|
||||
"API support": "Not included",
|
||||
"ID3 tags": "Auto-fill title & metadata from MP3 tags",
|
||||
Support: "Community",
|
||||
Watermark: "Optional Songs2VID badge · bottom-right",
|
||||
Watermark: "2 clean videos, then Songs2VID badge · bottom-right",
|
||||
},
|
||||
infrastructureChips: ["cloud"],
|
||||
watermarkNote:
|
||||
`Show "${getVideoAttributionText()}" to support open-source development - opt out anytime for a clean video.`,
|
||||
`First 2 successful Free renders are watermark-free; later Free renders include the "${getVideoAttributionText()}" badge (bottom-right). Upgrade to Pro for unwatermarked videos.`,
|
||||
cta: { type: "signin" },
|
||||
highlighted: false,
|
||||
hover:
|
||||
@@ -323,7 +323,8 @@ export function PricingSection() {
|
||||
<p className="mx-auto mt-10 max-w-2xl text-center text-xs text-gray-500">
|
||||
*Free includes 10 videos/month; buy 1–15 extras at €0.25 each (max 15 extras). After both
|
||||
are used, Pro (€5/mo · 50 videos) is required. Total balance capped at 30. Deduction
|
||||
order: monthly first, then extras.
|
||||
order: monthly first, then extras. Free also includes 2 lifetime watermark-free
|
||||
successful videos; later Free renders include a Songs2VID watermark unless you upgrade.
|
||||
</p>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-center text-xs text-gray-500">
|
||||
**Technical support is strictly reserved for Managed Cloud and paid Professional Setup
|
||||
|
||||
+114
-19
@@ -18,6 +18,8 @@ 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;
|
||||
|
||||
@@ -82,7 +84,12 @@ export function UploadForm() {
|
||||
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);
|
||||
@@ -107,6 +114,12 @@ export function UploadForm() {
|
||||
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),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
@@ -115,6 +128,21 @@ 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);
|
||||
@@ -383,14 +411,32 @@ export function UploadForm() {
|
||||
) &&
|
||||
!submitting;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
async function submitJob() {
|
||||
if (!canSubmit || !imagePath) return;
|
||||
|
||||
setSubmitting(true);
|
||||
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 res = await fetch("/api/jobs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -402,15 +448,8 @@ export function UploadForm() {
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
imagePath: canPerItemImages ? item.itemImagePath || item.metadata.imagePath || null : null,
|
||||
includeWatermark: jobWatermark.mode !== "none",
|
||||
watermark: canCustomWatermark
|
||||
? jobWatermark
|
||||
: {
|
||||
mode: jobWatermark.mode === "none" ? "none" : "default",
|
||||
position: "bottom-right",
|
||||
offsetX: 20,
|
||||
offsetY: 20,
|
||||
},
|
||||
includeWatermark: effectiveWatermark.mode !== "none",
|
||||
watermark: effectiveWatermark,
|
||||
layout: canArtTrackLayouts ? jobLayout : { ...DEFAULT_LAYOUT },
|
||||
},
|
||||
})),
|
||||
@@ -432,10 +471,51 @@ export function UploadForm() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
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
|
||||
@@ -710,14 +790,28 @@ export function UploadForm() {
|
||||
/>
|
||||
{!canCustomWatermark && (
|
||||
<Checkbox
|
||||
label={`Show '${getVideoAttributionText()}' watermark (support open-source)`}
|
||||
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) =>
|
||||
onChange={(v) => {
|
||||
if (
|
||||
!v &&
|
||||
!isPro &&
|
||||
!quota?.selfHosted &&
|
||||
(quota?.watermarkFreeRemaining ?? 0) <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
applyWatermarkToAll({
|
||||
...DEFAULT_WATERMARK,
|
||||
mode: v ? "default" : "none",
|
||||
})
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -750,12 +844,13 @@ export function UploadForm() {
|
||||
{quota && !quota.selfHosted && quota.plan === "FREE" && (
|
||||
<>
|
||||
<p className="text-sm text-gray-400">
|
||||
Free plan: one static cover image for the batch · optional Songs2VID watermark
|
||||
(bottom-right only).
|
||||
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 custom branding, typography,
|
||||
blurred art-track layouts, watermark positioning, unique image matching, 1080p, and
|
||||
<UpgradeProLink className="text-accent hover:underline" /> for unwatermarked videos,
|
||||
custom branding, typography, blurred art-track layouts, unique image matching, 1080p, and
|
||||
lossless audio.
|
||||
</p>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { UpgradeProButton } from "./UpgradeProButton";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onContinueWithWatermark: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shown when a free user who already used their 2 watermark-free videos
|
||||
* starts another render.
|
||||
*/
|
||||
export function WatermarkPaywallModal({ open, onContinueWithWatermark, onClose }: Props) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="watermark-paywall-title"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-lg border border-gray-700 bg-surface-light p-6 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 id="watermark-paywall-title" className="text-lg font-semibold text-white">
|
||||
You've used your 2 free watermark-free videos
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-relaxed text-gray-300">
|
||||
Subsequent videos in the Free tier will include a small Songs2VID watermark. Upgrade to Pro
|
||||
for unwatermarked videos, 4K export, and priority rendering.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onContinueWithWatermark}
|
||||
className="rounded border border-gray-600 px-4 py-2 text-sm text-gray-200 hover:border-gray-400 hover:text-white"
|
||||
>
|
||||
Continue with Watermark
|
||||
</button>
|
||||
<UpgradeProButton
|
||||
label="Upgrade to Pro"
|
||||
className="inline-flex items-center justify-center rounded bg-accent px-4 py-2 text-sm font-medium text-black hover:opacity-90"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user