Ship free-tier watermark policy (2 clean renders) and align pricing/legal copy.
This commit is contained in:
@@ -70,7 +70,8 @@ export default function PrivacyPage() {
|
|||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
Plan type, monthly credit allocation, credits used, purchased extra credits, quota reset
|
Plan type, monthly credit allocation, credits used, purchased extra credits, quota reset
|
||||||
dates, and job processing status
|
dates, lifetime successful video render count (used for Free-tier watermark policy), and
|
||||||
|
job processing status
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Quota reset / extension and API rate-limit extension request history (Pro) when you submit
|
Quota reset / extension and API rate-limit extension request history (Pro) when you submit
|
||||||
|
|||||||
+10
-2
@@ -37,8 +37,9 @@ export default function TermsPage() {
|
|||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<strong>Bedroom Producer (Free):</strong> 10 video credits per calendar month, 720p, MP3,
|
<strong>Bedroom Producer (Free):</strong> 10 video credits per calendar month, 720p, MP3,
|
||||||
optional Songs2VID watermark (bottom-right), one static cover image per batch; may purchase
|
first 2 successful videos watermark-free then Songs2VID brand watermark (bottom-right) on
|
||||||
limited extra credits as described in Clause 8.2
|
later Free renders, one static cover image per batch; may purchase limited extra credits as
|
||||||
|
described in Clause 8.2
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Independent Artist (Pro):</strong> 50 video credits per month, higher resolution and
|
<strong>Independent Artist (Pro):</strong> 50 video credits per month, higher resolution and
|
||||||
@@ -170,6 +171,13 @@ export default function TermsPage() {
|
|||||||
available only on the Free plan purchase flow; Pro includes its monthly allocation and does
|
available only on the Free plan purchase flow; Pro includes its monthly allocation and does
|
||||||
not sell the same Free top-up packs.
|
not sell the same Free top-up packs.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
Separately from monthly credits, Free accounts receive{" "}
|
||||||
|
<strong>2 lifetime watermark-free successful video renders</strong>. After those renders,
|
||||||
|
subsequent Free-tier videos include a Songs2VID brand watermark (bottom-right) unless you
|
||||||
|
upgrade to Pro or Enterprise terms that remove that requirement. The lifetime count is based
|
||||||
|
on successfully completed encodes, not monthly credit resets.
|
||||||
|
</p>
|
||||||
|
|
||||||
<h3>8.3 Credit rollover & accumulation cap</h3>
|
<h3>8.3 Credit rollover & accumulation cap</h3>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -61,11 +61,11 @@ const PLANS: PlanConfig[] = [
|
|||||||
"API support": "Not included",
|
"API support": "Not included",
|
||||||
"ID3 tags": "Auto-fill title & metadata from MP3 tags",
|
"ID3 tags": "Auto-fill title & metadata from MP3 tags",
|
||||||
Support: "Community",
|
Support: "Community",
|
||||||
Watermark: "Optional Songs2VID badge · bottom-right",
|
Watermark: "2 clean videos, then Songs2VID badge · bottom-right",
|
||||||
},
|
},
|
||||||
infrastructureChips: ["cloud"],
|
infrastructureChips: ["cloud"],
|
||||||
watermarkNote:
|
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" },
|
cta: { type: "signin" },
|
||||||
highlighted: false,
|
highlighted: false,
|
||||||
hover:
|
hover:
|
||||||
@@ -323,7 +323,8 @@ export function PricingSection() {
|
|||||||
<p className="mx-auto mt-10 max-w-2xl text-center text-xs text-gray-500">
|
<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
|
*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
|
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>
|
||||||
<p className="mx-auto mt-4 max-w-2xl text-center text-xs text-gray-500">
|
<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
|
**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 { ResolutionSelect } from "./ResolutionSelect";
|
||||||
import { QuotaErrorMessage, UpgradeProLink } from "./UpgradeProLink";
|
import { QuotaErrorMessage, UpgradeProLink } from "./UpgradeProLink";
|
||||||
import { UpgradeProButton } from "./UpgradeProButton";
|
import { UpgradeProButton } from "./UpgradeProButton";
|
||||||
|
import { WatermarkPaywallModal } from "./WatermarkPaywallModal";
|
||||||
|
import { FREE_UNWATERMARKED_VIDEO_LIMIT } from "@/lib/watermark-policy";
|
||||||
|
|
||||||
const UPLOAD_CONCURRENCY = 4;
|
const UPLOAD_CONCURRENCY = 4;
|
||||||
|
|
||||||
@@ -82,7 +84,12 @@ export function UploadForm() {
|
|||||||
customWatermark?: boolean;
|
customWatermark?: boolean;
|
||||||
perItemImages?: boolean;
|
perItemImages?: boolean;
|
||||||
artTrackLayouts?: boolean;
|
artTrackLayouts?: boolean;
|
||||||
|
subscriptionStatus?: "free" | "pro";
|
||||||
|
createdVideoCount?: number;
|
||||||
|
watermarkFreeRemaining?: number | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [showWatermarkPaywall, setShowWatermarkPaywall] = useState(false);
|
||||||
|
const [watermarkDefaultsApplied, setWatermarkDefaultsApplied] = useState(false);
|
||||||
|
|
||||||
const isPro = Boolean(quota?.selfHosted || quota?.plan === "PREMIUM");
|
const isPro = Boolean(quota?.selfHosted || quota?.plan === "PREMIUM");
|
||||||
const canPerItemImages = Boolean(quota?.perItemImages || isPro);
|
const canPerItemImages = Boolean(quota?.perItemImages || isPro);
|
||||||
@@ -107,6 +114,12 @@ export function UploadForm() {
|
|||||||
customWatermark: Boolean(data.customWatermark),
|
customWatermark: Boolean(data.customWatermark),
|
||||||
perItemImages: Boolean(data.perItemImages),
|
perItemImages: Boolean(data.perItemImages),
|
||||||
artTrackLayouts: Boolean(data.artTrackLayouts),
|
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();
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
|
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
|
||||||
@@ -383,14 +411,32 @@ export function UploadForm() {
|
|||||||
) &&
|
) &&
|
||||||
!submitting;
|
!submitting;
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function submitJob() {
|
||||||
e.preventDefault();
|
|
||||||
if (!canSubmit || !imagePath) return;
|
if (!canSubmit || !imagePath) return;
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
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", {
|
const res = await fetch("/api/jobs", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -402,15 +448,8 @@ export function UploadForm() {
|
|||||||
metadata: {
|
metadata: {
|
||||||
...item.metadata,
|
...item.metadata,
|
||||||
imagePath: canPerItemImages ? item.itemImagePath || item.metadata.imagePath || null : null,
|
imagePath: canPerItemImages ? item.itemImagePath || item.metadata.imagePath || null : null,
|
||||||
includeWatermark: jobWatermark.mode !== "none",
|
includeWatermark: effectiveWatermark.mode !== "none",
|
||||||
watermark: canCustomWatermark
|
watermark: effectiveWatermark,
|
||||||
? jobWatermark
|
|
||||||
: {
|
|
||||||
mode: jobWatermark.mode === "none" ? "none" : "default",
|
|
||||||
position: "bottom-right",
|
|
||||||
offsetX: 20,
|
|
||||||
offsetY: 20,
|
|
||||||
},
|
|
||||||
layout: canArtTrackLayouts ? jobLayout : { ...DEFAULT_LAYOUT },
|
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 (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<WatermarkPaywallModal
|
||||||
|
open={showWatermarkPaywall}
|
||||||
|
onClose={() => setShowWatermarkPaywall(false)}
|
||||||
|
onContinueWithWatermark={() => {
|
||||||
|
setShowWatermarkPaywall(false);
|
||||||
|
void submitJob();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{quota && (
|
{quota && (
|
||||||
<div className="rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm text-gray-300">
|
<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 ? (
|
{quota.selfHosted ? (
|
||||||
<>
|
<>
|
||||||
Self-hosted · {quota.used} videos processed · up to {quota.maxBatchSize} files per
|
Self-hosted · {quota.used} videos processed · up to {quota.maxBatchSize} files per
|
||||||
@@ -710,14 +790,28 @@ export function UploadForm() {
|
|||||||
/>
|
/>
|
||||||
{!canCustomWatermark && (
|
{!canCustomWatermark && (
|
||||||
<Checkbox
|
<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"}
|
checked={jobWatermark.mode !== "none"}
|
||||||
onChange={(v) =>
|
onChange={(v) => {
|
||||||
|
if (
|
||||||
|
!v &&
|
||||||
|
!isPro &&
|
||||||
|
!quota?.selfHosted &&
|
||||||
|
(quota?.watermarkFreeRemaining ?? 0) <= 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
applyWatermarkToAll({
|
applyWatermarkToAll({
|
||||||
...DEFAULT_WATERMARK,
|
...DEFAULT_WATERMARK,
|
||||||
mode: v ? "default" : "none",
|
mode: v ? "default" : "none",
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -750,12 +844,13 @@ export function UploadForm() {
|
|||||||
{quota && !quota.selfHosted && quota.plan === "FREE" && (
|
{quota && !quota.selfHosted && quota.plan === "FREE" && (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
Free plan: one static cover image for the batch · optional Songs2VID watermark
|
Free plan: first {FREE_UNWATERMARKED_VIDEO_LIMIT} successful videos are watermark-free;
|
||||||
(bottom-right only).
|
later renders include a Songs2VID watermark (bottom-right). One static cover image per
|
||||||
|
batch.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
<UpgradeProLink className="text-accent hover:underline" /> for custom branding, typography,
|
<UpgradeProLink className="text-accent hover:underline" /> for unwatermarked videos,
|
||||||
blurred art-track layouts, watermark positioning, unique image matching, 1080p, and
|
custom branding, typography, blurred art-track layouts, unique image matching, 1080p, and
|
||||||
lossless audio.
|
lossless audio.
|
||||||
</p>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ docs.songs2vid.com {
|
|||||||
encode gzip
|
encode gzip
|
||||||
root * /srv/docs
|
root * /srv/docs
|
||||||
file_server
|
file_server
|
||||||
try_files {path} /index.html
|
try_files {path} {path}/ {path}/index.html /index.html
|
||||||
}
|
}
|
||||||
|
|
||||||
# Basic-auth hash is generated on the server (caddy hash-password).
|
# Basic-auth hash is generated on the server (caddy hash-password).
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ export async function adminResetUserCredits(
|
|||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: {
|
data: {
|
||||||
plan,
|
plan,
|
||||||
|
subscriptionStatus: plan === "PREMIUM" ? "pro" : "free",
|
||||||
monthlyCredits,
|
monthlyCredits,
|
||||||
videosUsed: used,
|
videosUsed: used,
|
||||||
extraCredits: cappedExtras,
|
extraCredits: cappedExtras,
|
||||||
@@ -245,6 +246,7 @@ export async function activateProPlan(
|
|||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: {
|
data: {
|
||||||
plan: "PREMIUM",
|
plan: "PREMIUM",
|
||||||
|
subscriptionStatus: "pro",
|
||||||
subscribedAt: new Date(),
|
subscribedAt: new Date(),
|
||||||
...(opts.stripeCustomerId !== undefined
|
...(opts.stripeCustomerId !== undefined
|
||||||
? { stripeCustomerId: opts.stripeCustomerId }
|
? { stripeCustomerId: opts.stripeCustomerId }
|
||||||
@@ -267,6 +269,7 @@ export async function downgradeToFreePlan(userId: string) {
|
|||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: {
|
data: {
|
||||||
plan: "FREE",
|
plan: "FREE",
|
||||||
|
subscriptionStatus: "free",
|
||||||
stripeSubscriptionId: null,
|
stripeSubscriptionId: null,
|
||||||
subscribedAt: null,
|
subscribedAt: null,
|
||||||
cardLast4: null,
|
cardLast4: null,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Modular FFmpeg filter fragments for the Songs2VID brand watermark overlay.
|
||||||
|
* Always bottom-right with padding; scales to a fraction of frame width.
|
||||||
|
*/
|
||||||
|
export function buildBrandWatermarkOverlayFilters(options: {
|
||||||
|
baseLabel: string;
|
||||||
|
watermarkInputIndex: number;
|
||||||
|
watermarkWidthPx: number;
|
||||||
|
offsetX?: number;
|
||||||
|
offsetY?: number;
|
||||||
|
outLabel?: string;
|
||||||
|
}): string[] {
|
||||||
|
const ox = options.offsetX ?? 20;
|
||||||
|
const oy = options.offsetY ?? 20;
|
||||||
|
const out = options.outLabel ?? "vout";
|
||||||
|
return [
|
||||||
|
`[${options.watermarkInputIndex}:v]scale=${options.watermarkWidthPx}:-1[wm]`,
|
||||||
|
`[${options.baseLabel}][wm]overlay=W-w-${ox}:H-h-${oy}[${out}]`,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
sanitizeDrawtext,
|
sanitizeDrawtext,
|
||||||
type WatermarkSettings,
|
type WatermarkSettings,
|
||||||
} from "../watermark";
|
} from "../watermark";
|
||||||
|
import { buildBrandWatermarkOverlayFilters } from "./brand-watermark";
|
||||||
|
|
||||||
function getFfmpegPath(): string {
|
function getFfmpegPath(): string {
|
||||||
if (process.env.FFMPEG_PATH) return process.env.FFMPEG_PATH;
|
if (process.env.FFMPEG_PATH) return process.env.FFMPEG_PATH;
|
||||||
@@ -217,10 +218,14 @@ export async function encodeVideo(options: {
|
|||||||
});
|
});
|
||||||
filterParts.push(`[${baseLabel}]${draw}[vout]`);
|
filterParts.push(`[${baseLabel}]${draw}[vout]`);
|
||||||
} else if (applyWm === "default-png" && defaultWmInputIndex !== null) {
|
} else if (applyWm === "default-png" && defaultWmInputIndex !== null) {
|
||||||
const { x, y } = overlayXy(settings.position, settings.offsetX, settings.offsetY);
|
|
||||||
filterParts.push(
|
filterParts.push(
|
||||||
`[${defaultWmInputIndex}:v]scale=${watermarkWidth}:-1[wm]`,
|
...buildBrandWatermarkOverlayFilters({
|
||||||
`[${baseLabel}][wm]overlay=${x}:${y}[vout]`,
|
baseLabel,
|
||||||
|
watermarkInputIndex: defaultWmInputIndex,
|
||||||
|
watermarkWidthPx: watermarkWidth,
|
||||||
|
offsetX: settings.offsetX,
|
||||||
|
offsetY: settings.offsetY,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
} else if (applyWm === "default-text") {
|
} else if (applyWm === "default-text") {
|
||||||
const draw = buildDrawtextFilter({
|
const draw = buildDrawtextFilter({
|
||||||
|
|||||||
+13
-1
@@ -41,6 +41,7 @@ import {
|
|||||||
normalizeWatermarkSettings,
|
normalizeWatermarkSettings,
|
||||||
WATERMARK_TEXT_MAX,
|
WATERMARK_TEXT_MAX,
|
||||||
} from "../watermark";
|
} from "../watermark";
|
||||||
|
import { applyBrandWatermarkPolicy } from "../watermark-policy";
|
||||||
|
|
||||||
/** Merge nested + flat layout fields, then normalize (throws on bad template / coords). */
|
/** Merge nested + flat layout fields, then normalize (throws on bad template / coords). */
|
||||||
export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSettings {
|
export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSettings {
|
||||||
@@ -255,6 +256,12 @@ export async function createVideoJob(
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dbUser = await prisma.user.findUnique({
|
||||||
|
where: { id: user.id },
|
||||||
|
select: { createdVideoCount: true },
|
||||||
|
});
|
||||||
|
const createdVideoCount = dbUser?.createdVideoCount ?? 0;
|
||||||
|
|
||||||
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
|
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
|
||||||
const items = body.items.map((item) => ({
|
const items = body.items.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
@@ -280,7 +287,7 @@ export async function createVideoJob(
|
|||||||
imagePath,
|
imagePath,
|
||||||
items: {
|
items: {
|
||||||
create: items.map((item, index) => {
|
create: items.map((item, index) => {
|
||||||
const wm = normalizeWatermarkSettings(
|
const wm = applyBrandWatermarkPolicy(
|
||||||
item.metadata.watermark
|
item.metadata.watermark
|
||||||
? {
|
? {
|
||||||
...item.metadata.watermark,
|
...item.metadata.watermark,
|
||||||
@@ -289,6 +296,11 @@ export async function createVideoJob(
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
item.metadata.includeWatermark,
|
item.metadata.includeWatermark,
|
||||||
|
{
|
||||||
|
plan: user.plan,
|
||||||
|
createdVideoCount,
|
||||||
|
itemIndex: index,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
const layout = resolveLayoutFromMetadata(item.metadata);
|
const layout = resolveLayoutFromMetadata(item.metadata);
|
||||||
const pro = hasProFeatures(user.plan);
|
const pro = hasProFeatures(user.plan);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { SUPPORT_EMAIL, SALES_EMAIL, QUOTA_REQUEST_EMAIL } from "@/lib/plans";
|
import { SUPPORT_EMAIL, SALES_EMAIL, QUOTA_REQUEST_EMAIL } from "@/lib/plans";
|
||||||
import { BRAND_DOMAIN, BRAND_NAME } from "@/lib/branding";
|
import { BRAND_DOMAIN, BRAND_NAME } from "@/lib/branding";
|
||||||
|
|
||||||
export const LEGAL_LAST_UPDATED = "July 30, 2026";
|
export const LEGAL_LAST_UPDATED = "August 3, 2026";
|
||||||
|
|
||||||
export const LEGAL_OPERATOR = {
|
export const LEGAL_OPERATOR = {
|
||||||
name: BRAND_NAME,
|
name: BRAND_NAME,
|
||||||
|
|||||||
+7
-1
@@ -7,8 +7,9 @@ import {
|
|||||||
} from "./billing";
|
} from "./billing";
|
||||||
import { FREE_EXTRA_CREDITS_MAX, MAX_CREDIT_CAP, PRO_UPGRADE_REQUIRED_SUFFIX, monthlyCreditsForPlan } from "./credits";
|
import { FREE_EXTRA_CREDITS_MAX, MAX_CREDIT_CAP, PRO_UPGRADE_REQUIRED_SUFFIX, monthlyCreditsForPlan } from "./credits";
|
||||||
import { prisma } from "./db";
|
import { prisma } from "./db";
|
||||||
import { isSelfHostedEdition } from "./edition";
|
import { hasProFeatures, isSelfHostedEdition } from "./edition";
|
||||||
import { getNextMonthlyQuotaReset, getPlanLimits } from "./plans";
|
import { getNextMonthlyQuotaReset, getPlanLimits } from "./plans";
|
||||||
|
import { watermarkFreeRendersRemaining } from "./watermark-policy";
|
||||||
|
|
||||||
export function formatQuotaResetCountdown(resetsAt: Date | string): string {
|
export function formatQuotaResetCountdown(resetsAt: Date | string): string {
|
||||||
const target = typeof resetsAt === "string" ? new Date(resetsAt) : resetsAt;
|
const target = typeof resetsAt === "string" ? new Date(resetsAt) : resetsAt;
|
||||||
@@ -106,6 +107,11 @@ export async function getQuotaInfo(userId: string) {
|
|||||||
resetsAt: user.quotaResetAt.toISOString(),
|
resetsAt: user.quotaResetAt.toISOString(),
|
||||||
resetsIn: formatQuotaResetDisplay(user.plan, user.quotaResetAt),
|
resetsIn: formatQuotaResetDisplay(user.plan, user.quotaResetAt),
|
||||||
plan: user.plan,
|
plan: user.plan,
|
||||||
|
subscriptionStatus: hasProFeatures(user.plan) ? ("pro" as const) : ("free" as const),
|
||||||
|
createdVideoCount: user.createdVideoCount,
|
||||||
|
watermarkFreeRemaining: hasProFeatures(user.plan)
|
||||||
|
? null
|
||||||
|
: watermarkFreeRendersRemaining(user.createdVideoCount),
|
||||||
maxBatchSize: limits.maxBatchSize,
|
maxBatchSize: limits.maxBatchSize,
|
||||||
watermarkOptional: limits.watermarkOptional,
|
watermarkOptional: limits.watermarkOptional,
|
||||||
customWatermark: limits.customWatermark,
|
customWatermark: limits.customWatermark,
|
||||||
|
|||||||
+5
-1
@@ -1,4 +1,5 @@
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { WATERMARK_FILE_PATH } from "./watermark-policy";
|
||||||
|
|
||||||
export function getUploadDir(): string {
|
export function getUploadDir(): string {
|
||||||
return process.env.UPLOAD_DIR || path.join(process.cwd(), "uploads");
|
return process.env.UPLOAD_DIR || path.join(process.cwd(), "uploads");
|
||||||
@@ -8,6 +9,9 @@ export function getJobDir(userId: string, jobId: string): string {
|
|||||||
return path.join(getUploadDir(), userId, jobId);
|
return path.join(getUploadDir(), userId, jobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Brand watermark PNG used by the default overlay pipeline. */
|
||||||
export function getWatermarkPath(): string {
|
export function getWatermarkPath(): string {
|
||||||
return path.join(process.cwd(), "assets", "watermark.png");
|
return process.env.WATERMARK_FILE_PATH?.trim() || WATERMARK_FILE_PATH;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { WATERMARK_FILE_PATH };
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import path from "path";
|
||||||
|
import type { Plan } from "@prisma/client";
|
||||||
|
import { hasProFeatures } from "./edition";
|
||||||
|
import {
|
||||||
|
DEFAULT_WATERMARK,
|
||||||
|
normalizeWatermarkSettings,
|
||||||
|
type WatermarkSettings,
|
||||||
|
} from "./watermark";
|
||||||
|
|
||||||
|
/** Free users get this many lifetime watermark-free successful videos. */
|
||||||
|
export const FREE_UNWATERMARKED_VIDEO_LIMIT = 2;
|
||||||
|
|
||||||
|
export type SubscriptionStatusLabel = "free" | "pro";
|
||||||
|
|
||||||
|
export function subscriptionStatusFromPlan(plan: Plan): SubscriptionStatusLabel {
|
||||||
|
return hasProFeatures(plan) ? "pro" : "free";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the Songs2VID brand watermark must be applied for this render slot.
|
||||||
|
* Pro / self-hosted: never forced. Free: forced after FREE_UNWATERMARKED_VIDEO_LIMIT.
|
||||||
|
*/
|
||||||
|
export function shouldApplyBrandWatermark(options: {
|
||||||
|
plan: Plan;
|
||||||
|
createdVideoCount: number;
|
||||||
|
/** 0-based index within the current batch (accounts for multi-file jobs). */
|
||||||
|
itemIndex?: number;
|
||||||
|
}): boolean {
|
||||||
|
if (hasProFeatures(options.plan)) return false;
|
||||||
|
const slot = options.createdVideoCount + (options.itemIndex ?? 0);
|
||||||
|
return slot >= FREE_UNWATERMARKED_VIDEO_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply free/pro brand-watermark policy to normalized settings.
|
||||||
|
* - Pro: strip default brand watermark (custom text/logo kept).
|
||||||
|
* - Free under limit: force mode `none`.
|
||||||
|
* - Free at/over limit: force default brand overlay (bottom-right).
|
||||||
|
*/
|
||||||
|
export function applyBrandWatermarkPolicy(
|
||||||
|
input: Partial<WatermarkSettings> | null | undefined,
|
||||||
|
includeWatermarkFallback: boolean,
|
||||||
|
options: {
|
||||||
|
plan: Plan;
|
||||||
|
createdVideoCount: number;
|
||||||
|
itemIndex?: number;
|
||||||
|
},
|
||||||
|
): WatermarkSettings {
|
||||||
|
const base = normalizeWatermarkSettings(input, includeWatermarkFallback);
|
||||||
|
|
||||||
|
if (hasProFeatures(options.plan)) {
|
||||||
|
if (base.mode === "default") {
|
||||||
|
return { ...base, mode: "none" };
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldApplyBrandWatermark(options)) {
|
||||||
|
return {
|
||||||
|
...DEFAULT_WATERMARK,
|
||||||
|
mode: "default",
|
||||||
|
position: "bottom-right",
|
||||||
|
offsetX: 20,
|
||||||
|
offsetY: 20,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
mode: "none",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function watermarkFreeRendersRemaining(createdVideoCount: number): number {
|
||||||
|
return Math.max(0, FREE_UNWATERMARKED_VIDEO_LIMIT - createdVideoCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Absolute path to the Songs2VID brand watermark asset (PNG). */
|
||||||
|
export const WATERMARK_FILE_PATH = path.join(process.cwd(), "assets", "watermark.png");
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
CREATE TYPE "SubscriptionStatus" AS ENUM ('free', 'pro');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "subscriptionStatus" "SubscriptionStatus" NOT NULL DEFAULT 'free';
|
||||||
|
ALTER TABLE "User" ADD COLUMN "createdVideoCount" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Backfill from existing Plan
|
||||||
|
UPDATE "User" SET "subscriptionStatus" = 'pro' WHERE "plan" = 'PREMIUM';
|
||||||
|
UPDATE "User" SET "subscriptionStatus" = 'free' WHERE "plan" = 'FREE';
|
||||||
@@ -12,6 +12,12 @@ enum Plan {
|
|||||||
PREMIUM
|
PREMIUM
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Public-facing subscription tier (kept in sync with `plan`: FREE→free, PREMIUM→pro)
|
||||||
|
enum SubscriptionStatus {
|
||||||
|
free
|
||||||
|
pro
|
||||||
|
}
|
||||||
|
|
||||||
enum Privacy {
|
enum Privacy {
|
||||||
PUBLIC
|
PUBLIC
|
||||||
PRIVATE
|
PRIVATE
|
||||||
@@ -52,6 +58,10 @@ model User {
|
|||||||
image String?
|
image String?
|
||||||
/// FREE = free tier; PREMIUM = Pro (€5/mo, formerly starter_5eur)
|
/// FREE = free tier; PREMIUM = Pro (€5/mo, formerly starter_5eur)
|
||||||
plan Plan @default(FREE)
|
plan Plan @default(FREE)
|
||||||
|
/// Mirrors plan for product/UI (`free` | `pro`). Prefer `plan` for entitlements.
|
||||||
|
subscriptionStatus SubscriptionStatus @default(free)
|
||||||
|
/// Lifetime count of successfully completed video renders (not monthly quota).
|
||||||
|
createdVideoCount Int @default(0)
|
||||||
/// Credits consumed in the current billing cycle (credits_used)
|
/// Credits consumed in the current billing cycle (credits_used)
|
||||||
videosUsed Int @default(0)
|
videosUsed Int @default(0)
|
||||||
/// Monthly allocation for the current cycle (10 free / 50 pro)
|
/// Monthly allocation for the current cycle (10 free / 50 pro)
|
||||||
|
|||||||
+6
-1
@@ -128,7 +128,12 @@ async function processJobItem(data: VideoJobData) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Quota was reserved at job creation; do not increment again on success.
|
// Lifetime successful renders only (not monthly quota — that was reserved at create).
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: data.userId },
|
||||||
|
data: { createdVideoCount: { increment: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
await cleanupFiles([outputPath]);
|
await cleanupFiles([outputPath]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = formatYouTubeErrorForUser(err);
|
const message = formatYouTubeErrorForUser(err);
|
||||||
|
|||||||
Reference in New Issue
Block a user