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:
Atakan Doğan Özban
2026-08-03 07:41:11 +02:00
co-authored by Cursor
parent 26c4b22fbb
commit 8f6b894f80
17 changed files with 346 additions and 34 deletions
+114 -19
View File
@@ -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>
</>