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
@@ -211,6 +211,7 @@ export async function adminResetUserCredits(
|
||||
where: { id: userId },
|
||||
data: {
|
||||
plan,
|
||||
subscriptionStatus: plan === "PREMIUM" ? "pro" : "free",
|
||||
monthlyCredits,
|
||||
videosUsed: used,
|
||||
extraCredits: cappedExtras,
|
||||
@@ -245,6 +246,7 @@ export async function activateProPlan(
|
||||
where: { id: userId },
|
||||
data: {
|
||||
plan: "PREMIUM",
|
||||
subscriptionStatus: "pro",
|
||||
subscribedAt: new Date(),
|
||||
...(opts.stripeCustomerId !== undefined
|
||||
? { stripeCustomerId: opts.stripeCustomerId }
|
||||
@@ -267,6 +269,7 @@ export async function downgradeToFreePlan(userId: string) {
|
||||
where: { id: userId },
|
||||
data: {
|
||||
plan: "FREE",
|
||||
subscriptionStatus: "free",
|
||||
stripeSubscriptionId: null,
|
||||
subscribedAt: 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,
|
||||
type WatermarkSettings,
|
||||
} from "../watermark";
|
||||
import { buildBrandWatermarkOverlayFilters } from "./brand-watermark";
|
||||
|
||||
function getFfmpegPath(): string {
|
||||
if (process.env.FFMPEG_PATH) return process.env.FFMPEG_PATH;
|
||||
@@ -217,10 +218,14 @@ export async function encodeVideo(options: {
|
||||
});
|
||||
filterParts.push(`[${baseLabel}]${draw}[vout]`);
|
||||
} else if (applyWm === "default-png" && defaultWmInputIndex !== null) {
|
||||
const { x, y } = overlayXy(settings.position, settings.offsetX, settings.offsetY);
|
||||
filterParts.push(
|
||||
`[${defaultWmInputIndex}:v]scale=${watermarkWidth}:-1[wm]`,
|
||||
`[${baseLabel}][wm]overlay=${x}:${y}[vout]`,
|
||||
...buildBrandWatermarkOverlayFilters({
|
||||
baseLabel,
|
||||
watermarkInputIndex: defaultWmInputIndex,
|
||||
watermarkWidthPx: watermarkWidth,
|
||||
offsetX: settings.offsetX,
|
||||
offsetY: settings.offsetY,
|
||||
}),
|
||||
);
|
||||
} else if (applyWm === "default-text") {
|
||||
const draw = buildDrawtextFilter({
|
||||
|
||||
+13
-1
@@ -41,6 +41,7 @@ import {
|
||||
normalizeWatermarkSettings,
|
||||
WATERMARK_TEXT_MAX,
|
||||
} from "../watermark";
|
||||
import { applyBrandWatermarkPolicy } from "../watermark-policy";
|
||||
|
||||
/** Merge nested + flat layout fields, then normalize (throws on bad template / coords). */
|
||||
export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSettings {
|
||||
@@ -255,6 +256,12 @@ export async function createVideoJob(
|
||||
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 items = body.items.map((item) => ({
|
||||
...item,
|
||||
@@ -280,7 +287,7 @@ export async function createVideoJob(
|
||||
imagePath,
|
||||
items: {
|
||||
create: items.map((item, index) => {
|
||||
const wm = normalizeWatermarkSettings(
|
||||
const wm = applyBrandWatermarkPolicy(
|
||||
item.metadata.watermark
|
||||
? {
|
||||
...item.metadata.watermark,
|
||||
@@ -289,6 +296,11 @@ export async function createVideoJob(
|
||||
}
|
||||
: null,
|
||||
item.metadata.includeWatermark,
|
||||
{
|
||||
plan: user.plan,
|
||||
createdVideoCount,
|
||||
itemIndex: index,
|
||||
},
|
||||
);
|
||||
const layout = resolveLayoutFromMetadata(item.metadata);
|
||||
const pro = hasProFeatures(user.plan);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SUPPORT_EMAIL, SALES_EMAIL, QUOTA_REQUEST_EMAIL } from "@/lib/plans";
|
||||
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 = {
|
||||
name: BRAND_NAME,
|
||||
|
||||
+7
-1
@@ -7,8 +7,9 @@ import {
|
||||
} from "./billing";
|
||||
import { FREE_EXTRA_CREDITS_MAX, MAX_CREDIT_CAP, PRO_UPGRADE_REQUIRED_SUFFIX, monthlyCreditsForPlan } from "./credits";
|
||||
import { prisma } from "./db";
|
||||
import { isSelfHostedEdition } from "./edition";
|
||||
import { hasProFeatures, isSelfHostedEdition } from "./edition";
|
||||
import { getNextMonthlyQuotaReset, getPlanLimits } from "./plans";
|
||||
import { watermarkFreeRendersRemaining } from "./watermark-policy";
|
||||
|
||||
export function formatQuotaResetCountdown(resetsAt: Date | string): string {
|
||||
const target = typeof resetsAt === "string" ? new Date(resetsAt) : resetsAt;
|
||||
@@ -106,6 +107,11 @@ export async function getQuotaInfo(userId: string) {
|
||||
resetsAt: user.quotaResetAt.toISOString(),
|
||||
resetsIn: formatQuotaResetDisplay(user.plan, user.quotaResetAt),
|
||||
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,
|
||||
watermarkOptional: limits.watermarkOptional,
|
||||
customWatermark: limits.customWatermark,
|
||||
|
||||
+5
-1
@@ -1,4 +1,5 @@
|
||||
import path from "path";
|
||||
import { WATERMARK_FILE_PATH } from "./watermark-policy";
|
||||
|
||||
export function getUploadDir(): string {
|
||||
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);
|
||||
}
|
||||
|
||||
/** Brand watermark PNG used by the default overlay pipeline. */
|
||||
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");
|
||||
Reference in New Issue
Block a user