Ship free-tier watermark policy (2 clean renders) and align pricing/legal copy.

This commit is contained in:
Atakan Doğan Özban
2026-08-03 07:41:11 +02:00
parent 605487db0f
commit 5bc39d0692
17 changed files with 346 additions and 34 deletions
+2 -1
View File
@@ -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
View File
@@ -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 &amp; accumulation cap</h3> <h3>8.3 Credit rollover &amp; accumulation cap</h3>
<p> <p>
+4 -3
View File
@@ -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 115 extras at 0.25 each (max 15 extras). After both *Free includes 10 videos/month; buy 115 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
View File
@@ -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>
</> </>
+53
View File
@@ -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&apos;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>
);
}
+1 -1
View File
@@ -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).
+3
View File
@@ -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,
+20
View File
@@ -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}]`,
];
}
+8 -3
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
+79
View File
@@ -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';
+10
View File
@@ -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
View File
@@ -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);