From 3c733fec4172652b04d67b2fca07a41c864b7353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atakan=20Do=C4=9Fan=20=C3=96zban?= Date: Wed, 19 Aug 2026 17:15:44 +0200 Subject: [PATCH] fix: ultrafast preset + 1fps output, move LayoutStudio before per-audio section - Add -preset ultrafast and -r 1 to both libx264 encode paths for major 1080p speed improvement (still image needs no motion estimation) - Move LayoutStudio above the per-audio details list so layout/watermark settings are immediately visible and applied before submit --- components/UploadForm.tsx | 954 ++++++++++++++++++++++++++++---------- lib/ffmpeg/encode.ts | 187 +++++++- 2 files changed, 884 insertions(+), 257 deletions(-) diff --git a/components/UploadForm.tsx b/components/UploadForm.tsx index 376b93b..4844c21 100644 --- a/components/UploadForm.tsx +++ b/components/UploadForm.tsx @@ -1,13 +1,15 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import Link from "next/link"; import { useRouter } from "next/navigation"; +import { getSession, signIn } from "next-auth/react"; import { Plan, Privacy } from "@prisma/client"; import { filenameWithoutExtension } from "@/lib/constants"; import { getVideoAttributionText } from "@/lib/branding"; import { audioTagsToMetadata } from "@/lib/audio-tags"; import { resolveYouTubeTitle } from "@/lib/titles"; -import { SONG_TITLE_MAX } from "@/lib/layout"; +import { SONG_TITLE_MAX, isLowerCornerTemplate } from "@/lib/layout"; import type { ItemMetadata } from "@/lib/types"; import { DEFAULT_LAYOUT, type LayoutSettings } from "@/lib/layout"; import { DEFAULT_WATERMARK, type WatermarkSettings } from "@/lib/watermark"; @@ -18,10 +20,92 @@ 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"; +import { MediaDropzone } from "./MediaDropzone"; +import { + buildRedditShareIntentUrl, + buildXShareIntentUrl, + RenderChoiceModal, +} from "./RenderChoiceModal"; +import { + DEMO_ARTIST, + DEMO_TITLE, + loadDemoMediaFiles, +} from "@/lib/demo-media"; +import { + isProOnlyAudioExtension, + PRO_LOSSLESS_AUDIO_MESSAGE, +} from "@/lib/plans"; +import type { SharePlatform } from "@/lib/share-verify"; +import { VERIFY_FAIL_MESSAGE } from "@/lib/share-verify"; +import type { JobDeliveryMode } from "@/lib/types"; const UPLOAD_CONCURRENCY = 4; +const SHARE_UNLOCK_TOKEN_KEY = "s2vid_share_download_unlock_token_v1"; +const PENDING_YOUTUBE_KEY = "s2vid_pending_youtube_render"; + +type QuotaState = { + remaining: number; + limit: number; + plan: Plan; + maxBatchSize: number; + resetsIn: string; + videoCredits: number; + extraCredits: number; + totalAvailable: number; + used: number; + selfHosted?: boolean; + customWatermark?: boolean; + perItemImages?: boolean; + artTrackLayouts?: boolean; + watermarkOptional?: boolean; + subscriptionStatus?: "free" | "pro" | "producer" | "developer" | "enterprise"; + createdVideoCount?: number; + watermarkFreeRemaining?: number | null; + maxAudioFileSizeBytes?: number; + audioBitrateKbps?: number; + isGuest?: boolean; + youtubeConnected?: boolean; + hasPaidBilling?: boolean; +}; + +function normalizePlan(raw: unknown): Plan { + const p = String(raw || ""); + if (p === "DEVELOPER_PRO") return Plan.DEVELOPER_PRO; + if (p === "ENTERPRISE") return Plan.ENTERPRISE; + if (p === "PRODUCER_PRO" || p === "PREMIUM" || p === "premium") return Plan.PRODUCER_PRO; + return Plan.FREE; +} + +/** Paid / self-hosted entitlements — skip share-or-YouTube growth gate. */ +function userHasProEntitlements(quota: QuotaState | null | undefined): boolean { + if (!quota) return false; + if (quota.selfHosted) return true; + if (quota.hasPaidBilling) return true; + if ( + quota.plan === Plan.PRODUCER_PRO || + quota.plan === Plan.DEVELOPER_PRO || + quota.plan === Plan.ENTERPRISE + ) { + return true; + } + const status = quota.subscriptionStatus; + if ( + status === "producer" || + status === "developer" || + status === "enterprise" || + status === "pro" + ) { + return true; + } + return Boolean( + quota.artTrackLayouts || quota.customWatermark || quota.watermarkOptional, + ); +} + +function rewriteUserInPath(filePath: string, fromUserId: string, toUserId: string): string { + if (!filePath || fromUserId === toUserId) return filePath; + return filePath.split(fromUserId).join(toUserId); +} type AudioItem = { id: string; @@ -70,75 +154,84 @@ export function UploadForm() { const [jobWatermark, setJobWatermark] = useState({ ...DEFAULT_WATERMARK }); const [jobLayout, setJobLayout] = useState({ ...DEFAULT_LAYOUT }); const [logoPreviewUrl, setLogoPreviewUrl] = useState(null); - const [quota, setQuota] = useState<{ - remaining: number; - limit: number; - plan: Plan; - maxBatchSize: number; - resetsIn: string; - videoCredits: number; - extraCredits: number; - totalAvailable: number; - used: number; - selfHosted?: boolean; - customWatermark?: boolean; - perItemImages?: boolean; - artTrackLayouts?: boolean; - subscriptionStatus?: "free" | "pro"; - createdVideoCount?: number; - watermarkFreeRemaining?: number | null; - } | null>(null); - const [showWatermarkPaywall, setShowWatermarkPaywall] = useState(false); + const [backgroundImagePath, setBackgroundImagePath] = useState(null); + const [backgroundPreviewUrl, setBackgroundPreviewUrl] = useState(null); + const [quota, setQuota] = useState(null); const [watermarkDefaultsApplied, setWatermarkDefaultsApplied] = useState(false); + const [demoLoading, setDemoLoading] = useState(false); + const [choiceOpen, setChoiceOpen] = useState(false); + const [shareUnlocked, setShareUnlocked] = useState(false); + const [shareUnlockToken, setShareUnlockToken] = useState(null); + const [shareVerifying, setShareVerifying] = useState(false); + const [shareVerifyError, setShareVerifyError] = useState(null); - const isPro = Boolean(quota?.selfHosted || quota?.plan === "PREMIUM"); + const isPro = userHasProEntitlements(quota); const canPerItemImages = Boolean(quota?.perItemImages || isPro); const canCustomWatermark = Boolean(quota?.customWatermark || isPro); const canArtTrackLayouts = Boolean(quota?.artTrackLayouts || isPro); const loadQuota = useCallback(async () => { const res = await fetch("/api/quota"); - if (res.ok) { - const data = await res.json(); - setQuota({ - remaining: data.remaining, - limit: data.limit, - plan: data.plan, - maxBatchSize: data.maxBatchSize, - resetsIn: data.resetsIn, - videoCredits: data.videoCredits ?? data.extraCredits ?? 0, - extraCredits: data.extraCredits ?? data.videoCredits ?? 0, - totalAvailable: data.totalAvailable ?? data.remaining, - used: data.used ?? 0, - selfHosted: Boolean(data.selfHosted), - 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), - }); - } + if (!res.ok) return null; + const data = await res.json(); + const next = { + remaining: data.remaining, + limit: data.limit, + plan: normalizePlan(data.plan), + maxBatchSize: data.maxBatchSize, + resetsIn: data.resetsIn, + videoCredits: data.videoCredits ?? data.extraCredits ?? 0, + extraCredits: data.extraCredits ?? data.videoCredits ?? 0, + totalAvailable: data.totalAvailable ?? data.remaining, + used: data.used ?? 0, + selfHosted: Boolean(data.selfHosted), + customWatermark: Boolean(data.customWatermark), + perItemImages: Boolean(data.perItemImages), + artTrackLayouts: Boolean(data.artTrackLayouts), + watermarkOptional: Boolean(data.watermarkOptional), + subscriptionStatus: + data.subscriptionStatus === "developer" || + data.subscriptionStatus === "enterprise" || + data.subscriptionStatus === "producer" || + data.subscriptionStatus === "pro" + ? data.subscriptionStatus + : ("free" as const), + createdVideoCount: typeof data.createdVideoCount === "number" ? data.createdVideoCount : 0, + watermarkFreeRemaining: + data.watermarkFreeRemaining === null || data.watermarkFreeRemaining === undefined + ? null + : Number(data.watermarkFreeRemaining), + maxAudioFileSizeBytes: + typeof data.maxAudioFileSizeBytes === "number" ? data.maxAudioFileSizeBytes : undefined, + audioBitrateKbps: + typeof data.audioBitrateKbps === "number" ? data.audioBitrateKbps : undefined, + isGuest: Boolean(data.isGuest), + youtubeConnected: Boolean(data.youtubeConnected), + hasPaidBilling: Boolean(data.hasPaidBilling), + }; + setQuota(next); + return next; }, []); useEffect(() => { loadQuota(); }, [loadQuota]); - // Align free-tier watermark default with remaining watermark-free slots (once). + // If plan loads or upgrades after the modal opened, dismiss the free-tier gate. + useEffect(() => { + if (isPro && choiceOpen) setChoiceOpen(false); + }, [isPro, choiceOpen]); + + // Free tier always forces the Songs2VID badge (once defaults applied). useEffect(() => { if (!quota || watermarkDefaultsApplied) return; - if (quota.selfHosted || quota.plan === "PREMIUM") { + if (quota.selfHosted || isPro) { setWatermarkDefaultsApplied(true); return; } - const remaining = quota.watermarkFreeRemaining ?? 0; setJobWatermark({ ...DEFAULT_WATERMARK, - mode: remaining > 0 ? "none" : "default", + mode: "default", }); setWatermarkDefaultsApplied(true); }, [quota, watermarkDefaultsApplied]); @@ -234,9 +327,7 @@ export function UploadForm() { } } - async function handleImageChange(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - if (!file) return; + async function processImageFile(file: File) { setError(null); setImageFile(file); setImageUploading(true); @@ -256,6 +347,129 @@ export function UploadForm() { } } + async function ingestMediaFiles(images: File[], audios: File[]) { + setError(null); + if (images.length > 1) { + setError("Only one cover image is used per batch - using the first image."); + } + if (images[0]) { + await processImageFile(images[0]); + } + + if (!audios.length) return; + + if (!isPro) { + const proOnly = audios.find((f) => isProOnlyAudioExtension(f.name)); + if (proOnly) { + setError(PRO_LOSSLESS_AUDIO_MESSAGE); + return; + } + } + const maxAudioBytes = + quota?.maxAudioFileSizeBytes ?? (isPro ? 500 * 1024 * 1024 : 50 * 1024 * 1024); + const oversize = audios.find((f) => f.size > maxAudioBytes); + if (oversize) { + const mb = Math.round(maxAudioBytes / (1024 * 1024)); + setError( + isPro + ? `Pro plan audio limit is ${mb} MB per track.` + : `Free plan audio limit is ${mb} MB per track. Upgrade to Pro for up to 500 MB.`, + ); + return; + } + + const maxBatch = quota?.maxBatchSize ?? 3; + const room = Math.max(0, maxBatch - audioItems.length); + if (room === 0) { + setError(`Your plan allows up to ${maxBatch} audio files per batch.`); + return; + } + if (audios.length > room) { + setError( + `Only ${room} more audio file${room === 1 ? "" : "s"} fit this batch - adding the first ${room}.`, + ); + } + await uploadFilesWithConcurrency(audios.slice(0, room)); + } + + async function loadDemoTrack() { + setDemoLoading(true); + setError(null); + try { + const { audio, cover } = await loadDemoMediaFiles(); + + audioItems.forEach((a) => { + if (a.itemImagePreview) URL.revokeObjectURL(a.itemImagePreview); + }); + if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl); + + const id = crypto.randomUUID(); + setAudioItems([ + { + id, + file: audio, + path: null, + uploading: true, + itemImagePath: null, + itemImageName: null, + itemImagePreview: null, + metadata: { + ...defaultMetadata(DEMO_TITLE), + title: DEMO_TITLE, + songTitle: DEMO_TITLE, + artist: DEMO_ARTIST, + description: "Demo render created with Songs2VID sample media.", + tags: "songs2vid, demo, audio to video", + playlistId: playlistId || null, + watermark: { ...jobWatermark }, + layout: { ...jobLayout }, + }, + }, + ]); + + setImageFile(cover); + setImageUploading(true); + setImagePreviewUrl(URL.createObjectURL(cover)); + setImagePath(null); + + const [imageUpload, audioUpload] = await Promise.all([ + uploadFile(cover, "image"), + uploadFile(audio, "audio"), + ]); + + setImagePath(imageUpload.path); + setImageUploading(false); + setAudioItems((prev) => + prev.map((item) => + item.id === id + ? { + ...item, + path: audioUpload.path, + uploading: false, + metadata: audioUpload.audioTags + ? { + ...item.metadata, + ...audioTagsToMetadata(audioUpload.audioTags, DEMO_TITLE), + title: DEMO_TITLE, + songTitle: DEMO_TITLE, + artist: DEMO_ARTIST, + } + : item.metadata, + } + : item, + ), + ); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load demo track"); + setAudioItems([]); + setImageFile(null); + setImagePath(null); + setImageUploading(false); + } finally { + setDemoLoading(false); + } + } + async function handleItemImageChange(itemId: string, file: File | null) { if (!canPerItemImages) { setError("Matching a unique image per audio requires Pro."); @@ -320,6 +534,21 @@ export function UploadForm() { return uploaded.path; } + async function handleBackgroundUpload(file: File) { + const preview = URL.createObjectURL(file); + if (backgroundPreviewUrl) URL.revokeObjectURL(backgroundPreviewUrl); + setBackgroundPreviewUrl(preview); + const uploaded = await uploadFile(file, "image"); + setBackgroundImagePath(uploaded.path); + return uploaded.path; + } + + function clearBackgroundImage() { + if (backgroundPreviewUrl) URL.revokeObjectURL(backgroundPreviewUrl); + setBackgroundPreviewUrl(null); + setBackgroundImagePath(null); + } + function applyWatermarkToAll(next: WatermarkSettings) { const normalized = { ...DEFAULT_WATERMARK, ...next }; setJobWatermark(normalized); @@ -338,6 +567,9 @@ export function UploadForm() { function applyLayoutToAll(next: LayoutSettings) { const normalized = { ...DEFAULT_LAYOUT, ...next }; setJobLayout(normalized); + if (!isLowerCornerTemplate(normalized.template)) { + clearBackgroundImage(); + } setAudioItems((prev) => prev.map((item) => ({ ...item, @@ -349,22 +581,6 @@ export function UploadForm() { ); } - async function handleAudioChange(e: React.ChangeEvent) { - const files = Array.from(e.target.files || []); - if (!files.length) return; - setError(null); - - const maxBatch = quota?.maxBatchSize ?? 3; - if (audioItems.length + files.length > maxBatch) { - setError(`Your plan allows up to ${maxBatch} audio files per batch.`); - e.target.value = ""; - return; - } - - await uploadFilesWithConcurrency(files); - e.target.value = ""; - } - function updateItemMetadata(id: string, updates: Partial) { setAudioItems((prev) => prev.map((item) => @@ -393,6 +609,7 @@ export function UploadForm() { } const readyAudios = audioItems.filter((a) => a.path && !a.uploading); + const hasSelectedMedia = Boolean(imageFile || audioItems.length > 0); const canSubmit = imagePath && !imageUploading && @@ -405,65 +622,132 @@ export function UploadForm() { songTitle: a.metadata.songTitle, artist: a.metadata.artist, }, - isPro ? Plan.PREMIUM : Plan.FREE, + isPro ? Plan.PRODUCER_PRO : Plan.FREE, ), ), ) && !submitting; - async function submitJob() { + function buildJobBody(delivery: JobDeliveryMode, unlockTokenOverride?: string | null) { + if (!imagePath) return null; + const forcedWatermark = !isPro && !quota?.selfHosted; + 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 unlockToken = + delivery === "download" ? (unlockTokenOverride ?? shareUnlockToken) : undefined; + + // Free keeps classic blur fill/amount/opacity; art-track templates stay Pro-only. + const effectiveLayout: LayoutSettings = canArtTrackLayouts + ? jobLayout + : { + ...DEFAULT_LAYOUT, + blurFill: jobLayout.template ? false : Boolean(jobLayout.blurFill), + blurAmount: jobLayout.blurAmount, + blurOpacity: jobLayout.blurOpacity, + }; + + return { + imagePath, + delivery, + unlockToken: unlockToken || undefined, + items: readyAudios.map((item) => ({ + audioPath: item.path!, + audioFilename: item.file.name, + metadata: { + ...item.metadata, + imagePath: canPerItemImages ? item.itemImagePath || item.metadata.imagePath || null : null, + includeWatermark: effectiveWatermark.mode !== "none", + watermark: effectiveWatermark, + layout: effectiveLayout, + backgroundImagePath: + canArtTrackLayouts && + isLowerCornerTemplate(jobLayout.template) && + backgroundImagePath + ? backgroundImagePath + : null, + }, + })), + }; + } + + function rewriteJobBodyPaths }>( + body: T, + fromUserId: string, + toUserId: string, + ): T { + const rw = (p: string | null | undefined) => + p ? rewriteUserInPath(p, fromUserId, toUserId) : p; + return { + ...body, + imagePath: rewriteUserInPath(body.imagePath, fromUserId, toUserId), + items: body.items.map((item) => ({ + ...item, + audioPath: rewriteUserInPath(item.audioPath, fromUserId, toUserId), + metadata: { + ...item.metadata, + imagePath: rw(item.metadata.imagePath) ?? null, + backgroundImagePath: rw(item.metadata.backgroundImagePath) ?? null, + watermark: item.metadata.watermark + ? { + ...item.metadata.watermark, + logoPath: rw(item.metadata.watermark.logoPath) ?? null, + fontPath: rw(item.metadata.watermark.fontPath) ?? null, + } + : item.metadata.watermark, + }, + })), + }; + } + + async function postJob(body: object) { + const res = await fetch("/api/jobs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) { + if (data.code === "PREMIUM_REQUIRED") { + throw new Error(data.error || "This feature requires Pro."); + } + if (data.code === "YOUTUBE_REQUIRED") { + throw new Error(data.error || "Sign in with Google to upload to YouTube."); + } + if (data.code === "SHARE_UNLOCK_REQUIRED") { + throw new Error(data.error || "Share verification required before downloading."); + } + throw new Error(data.error || "Failed to create job"); + } + return data as { jobId: string }; + } + + async function submitJob( + delivery: JobDeliveryMode = "youtube", + unlockTokenOverride?: string | null, + ) { if (!canSubmit || !imagePath) return; + const body = buildJobBody(delivery, unlockTokenOverride); + if (!body) return; setSubmitting(true); setError(null); + setChoiceOpen(false); 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" }, - body: JSON.stringify({ - imagePath, - items: readyAudios.map((item) => ({ - audioPath: item.path!, - audioFilename: item.file.name, - metadata: { - ...item.metadata, - imagePath: canPerItemImages ? item.itemImagePath || item.metadata.imagePath || null : null, - includeWatermark: effectiveWatermark.mode !== "none", - watermark: effectiveWatermark, - layout: canArtTrackLayouts ? jobLayout : { ...DEFAULT_LAYOUT }, - }, - })), - }), - }); - - const data = await res.json(); - if (!res.ok) { - if (data.code === "PREMIUM_REQUIRED") { - throw new Error(data.error || "This feature requires Pro."); - } - throw new Error(data.error || "Failed to create job"); - } - + const data = await postJob(body); router.push(`/jobs/${data.jobId}`); } catch (err) { setError(err instanceof Error ? err.message : "Submission failed"); @@ -475,42 +759,179 @@ export function UploadForm() { e.preventDefault(); if (!canSubmit || !imagePath) return; - const needsPaywall = - !isPro && - !quota?.selfHosted && - (quota?.createdVideoCount ?? 0) >= FREE_UNWATERMARKED_VIDEO_LIMIT; - - if (needsPaywall) { - setShowWatermarkPaywall(true); + // Fresh quota before the growth gate — avoids opening the share modal + // when plan/flags haven't loaded yet, or after a mid-session upgrade. + const latest = (await loadQuota()) ?? quota; + if (userHasProEntitlements(latest)) { + setChoiceOpen(false); + void handleConnectYouTube(); return; } - await submitJob(); + try { + const token = sessionStorage.getItem(SHARE_UNLOCK_TOKEN_KEY); + setShareUnlockToken(token); + setShareUnlocked(Boolean(token)); + } catch { + setShareUnlockToken(null); + setShareUnlocked(false); + } + setShareVerifyError(null); + setChoiceOpen(true); } + function handleOpenShare(platform: SharePlatform) { + const url = platform === "reddit" ? buildRedditShareIntentUrl() : buildXShareIntentUrl(); + window.open(url, "_blank", "noopener,noreferrer,width=720,height=720"); + } + + async function handleVerifyShare(platform: SharePlatform, postUrl: string) { + setShareVerifying(true); + setShareVerifyError(null); + try { + const res = await fetch("/api/share/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ platform, url: postUrl }), + }); + const data = (await res.json()) as { + ok?: boolean; + error?: string; + unlockToken?: string; + }; + if (!res.ok || !data.ok || !data.unlockToken) { + setShareVerifyError(data.error || VERIFY_FAIL_MESSAGE); + return; + } + try { + sessionStorage.setItem(SHARE_UNLOCK_TOKEN_KEY, data.unlockToken); + } catch { + /* ignore */ + } + setShareUnlockToken(data.unlockToken); + setShareUnlocked(true); + // Immediately start the download render after a verified share. + await submitJob("download", data.unlockToken); + } catch { + setShareVerifyError(VERIFY_FAIL_MESSAGE); + } finally { + setShareVerifying(false); + } + } + + function handleContinueDownload() { + if (!shareUnlockToken) { + setShareVerifyError("Share verification required before downloading."); + return; + } + void submitJob("download"); + } + + async function claimGuestPaths() { + const res = await fetch("/api/guest/claim", { method: "POST" }); + if (!res.ok) return null; + return (await res.json()) as { + claimed?: boolean; + fromUserId?: string | null; + toUserId?: string; + }; + } + + async function handleConnectYouTube() { + const session = await getSession(); + const connected = + Boolean(quota?.youtubeConnected) || Boolean(session?.user?.youtubeConnected); + if (!session?.user || !connected) { + const body = buildJobBody("youtube"); + if (!body) return; + try { + sessionStorage.setItem(PENDING_YOUTUBE_KEY, JSON.stringify(body)); + } catch { + setError("Could not save your render for sign-in. Try again."); + return; + } + await signIn("google", { callbackUrl: "/dashboard?resume=youtube" }); + return; + } + await claimGuestPaths(); + await submitJob("youtube"); + } + + // After Google OAuth, restore the pending YouTube job (React state was wiped by redirect). + useEffect(() => { + if (typeof window === "undefined") return; + const params = new URLSearchParams(window.location.search); + if (params.get("resume") !== "youtube") return; + + let raw: string | null = null; + try { + raw = sessionStorage.getItem(PENDING_YOUTUBE_KEY); + } catch { + raw = null; + } + if (!raw || raw === "1") return; + + let cancelled = false; + void (async () => { + try { + sessionStorage.removeItem(PENDING_YOUTUBE_KEY); + } catch { + /* ignore */ + } + window.history.replaceState({}, "", "/dashboard"); + + setSubmitting(true); + setError(null); + try { + let body = JSON.parse(raw) as { + imagePath: string; + delivery: JobDeliveryMode; + items: Array<{ audioPath: string; audioFilename: string; metadata: ItemMetadata }>; + }; + const claim = await claimGuestPaths(); + if (claim?.claimed && claim.fromUserId && claim.toUserId) { + body = rewriteJobBodyPaths(body, claim.fromUserId, claim.toUserId); + } + if (cancelled) return; + const data = await postJob({ ...body, delivery: "youtube" }); + router.push(`/jobs/${data.jobId}`); + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to start YouTube upload"); + setSubmitting(false); + } + } + })(); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + function watermarkStatusLabel(): string | null { if (!quota || quota.selfHosted) return null; - if (quota.plan === "PREMIUM" || quota.subscriptionStatus === "pro") { - return "Pro Member (No Watermark)"; + if (isPro) { + return "Paid plan (Zero 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 "Free Tier - Songs2VID badge on every render"; } return (
- setShowWatermarkPaywall(false)} - onContinueWithWatermark={() => { - setShowWatermarkPaywall(false); - void submitJob(); - }} + !submitting && !shareVerifying && setChoiceOpen(false)} + onOpenShare={handleOpenShare} + onVerifyShare={(platform, url) => void handleVerifyShare(platform, url)} + onContinueDownload={handleContinueDownload} + onConnectYouTube={() => void handleConnectYouTube()} /> - {quota && (
{watermarkStatusLabel() ? ( @@ -518,26 +939,26 @@ export function UploadForm() { ) : null} {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 batch ) : ( <> {quota.remaining} of {quota.limit} plan videos remaining {quota.extraCredits > 0 - ? ` · ${quota.extraCredits} extra credits (${quota.totalAvailable} total available)` + ? ` - ${quota.extraCredits} extra credits (${quota.totalAvailable} total available)` : ""}{" "} - ·{" "} + -{" "} {quota.plan === "FREE" ? `resets in ${quota.resetsIn}` : `monthly quota resets on ${quota.resetsIn}`}{" "} - · up to {quota.maxBatchSize} files per batch + - up to {quota.maxBatchSize} files per batch {quota.plan === "FREE" ? ( <> - {" · "} - - Buy 1–15 credits - + {" - "} + + Buy 1-15 credits + ) : null} @@ -554,53 +975,100 @@ export function UploadForm() {

Files

-
-
- - -
- - - {imageUploading ? "Uploading…" : imageFile?.name || "No image selected"} - -
-

- {canPerItemImages - ? "Shared cover for all tracks. Optionally override per audio below (Pro)." - : "Free plan: one static cover for the whole batch."} -

-
+ { + void ingestMediaFiles(images, audios); + }} + /> -
- - + +

+ {canPerItemImages + ? "Shared cover for all tracks. Optionally override per audio below (Pro)." + : "Free: MP3 only - max 50 MB - rendered at 192 kbps AAC. Pro unlocks WAV/FLAC & 320 kbps."} +

+
+ +
+
+ -
- 0} /> - +
+

+ Cover image +

+

+ {imageUploading + ? "Uploading..." + : imageFile?.name || "Drop a JPG/PNG cover to preview"} +

+
+
+
+ a.uploading) + ? "pending" + : readyAudios.length > 0 + ? "ready" + : "idle" + } + /> +
+

+ Audio +

+

{audioItems.length === 0 - ? "No audio files selected" + ? "Drop MP3/WAV tracks to render" : `${readyAudios.length} of ${audioItems.length} ready`} - +

+ a.itemImagePreview)?.itemImagePreview || imagePreviewUrl + } + previewBackgroundUrl={backgroundPreviewUrl} + songTitle={ + audioItems[0]?.metadata.songTitle || + audioItems[0]?.metadata.title || + "Track title" + } + artist={audioItems[0]?.metadata.artist || ""} + encodeWidth={parseInt(readyAudios[0]?.metadata.resolution?.split("x")[0] || "1280", 10)} + layout={jobLayout} + onLayoutChange={applyLayoutToAll} + watermark={jobWatermark} + onWatermarkChange={applyWatermarkToAll} + onUploadLogo={handleLogoUpload} + onUploadFont={handleFontUpload} + onUploadBackground={handleBackgroundUpload} + onClearBackground={clearBackgroundImage} + hasCustomBackground={Boolean(backgroundImagePath)} + logoPreviewUrl={logoPreviewUrl} + /> + {audioItems.length > 0 && (

Video details (per audio)

@@ -613,7 +1081,7 @@ export function UploadForm() {
@@ -628,7 +1096,7 @@ export function UploadForm() { Video {index + 1}: {item.file.name} {item.uploading && ( -

Uploading…

+

Uploading...

)} + {/* In-flow submit (always available); sticky bar appears once media is ready */} + {!hasSelectedMedia && ( + + )} + + {hasSelectedMedia && ( + <> +
+
+
+
+ + {readyAudios.length} track{readyAudios.length === 1 ? "" : "s"} + + {imageFile ? ( + - {imageFile.name} + ) : ( + - add a cover to continue + )} +
+ +
+
+ + )} ); } -function StatusDot({ ok }: { ok: boolean }) { +function StatusDot({ state }: { state: "idle" | "pending" | "ready" }) { + const color = + state === "ready" + ? "bg-emerald-400" + : state === "pending" + ? "bg-amber-400" + : "bg-gray-600"; return ( ); } diff --git a/lib/ffmpeg/encode.ts b/lib/ffmpeg/encode.ts index c55945b..3c47076 100644 --- a/lib/ffmpeg/encode.ts +++ b/lib/ffmpeg/encode.ts @@ -2,15 +2,19 @@ import { spawn } from "child_process"; import fs from "fs/promises"; import path from "path"; import ffmpegStatic from "ffmpeg-static"; +import { parseFile } from "music-metadata"; import { getVideoAttributionText } from "../branding"; import { getResolution } from "../constants"; import { isCuratedFontKey, sanitizeFontfileForFilter, } from "../fonts"; -import { resolveCuratedFontPath } from "../fonts-server"; +import { resolveCuratedFontPath, resolveSystemFontPath, assertFontFile } from "../fonts-server"; import { buildArtTrackFilterComplex, + buildClassicBlurFillFilterComplex, + isLowerCornerTemplate, + normalizeLayoutSettings, type LayoutSettings, } from "../layout"; import { @@ -35,6 +39,20 @@ function getFfmpegPath(): string { export { getFfmpegPath }; +async function getAudioDurationSeconds(audioPath: string): Promise { + const { format } = await parseFile(audioPath); + const duration = format.duration; + if (typeof duration !== "number" || !Number.isFinite(duration) || duration <= 0) { + throw new Error(`Could not determine audio duration for ${audioPath}`); + } + return duration; +} + +/** FFmpeg 5.x mishandles `-shortest` with `-loop 1` still inputs (adds ~50s tail silence). */ +function formatOutputDurationSeconds(seconds: number): string { + return Math.max(0.01, seconds - 0.02).toFixed(3); +} + async function fileExists(p: string): Promise { try { await fs.access(p); @@ -44,7 +62,7 @@ async function fileExists(p: string): Promise { } } -/** Verify PNG magic bytes (prevents MIME spoof → FFmpeg surprises). */ +/** Verify PNG magic bytes (prevents MIME spoof - FFmpeg surprises). */ export async function assertPngFile(filePath: string): Promise { const fh = await fs.open(filePath, "r"); try { @@ -61,9 +79,26 @@ export async function assertPngFile(filePath: string): Promise { async function resolveFontfileEscaped( settings: WatermarkSettings, + weight: "regular" | "bold" = "regular", ): Promise { const key = settings.fontKey ?? "system"; - if (key === "system") return null; + if (key === "system") { + const preferred = resolveSystemFontPath(weight); + const fallback = weight === "bold" ? resolveSystemFontPath("regular") : preferred; + for (const fontPath of weight === "bold" ? [preferred, fallback] : [preferred]) { + if (!(await fileExists(fontPath))) continue; + try { + await assertFontFile(fontPath); + return sanitizeFontfileForFilter(fontPath); + } catch (err) { + console.warn( + `[ffmpeg] system font invalid at ${fontPath}: ${err instanceof Error ? err.message : err}`, + ); + } + } + console.warn(`[ffmpeg] system font missing; using FFmpeg default`); + return null; + } if (key === "custom") { if (!settings.fontPath) return null; @@ -74,12 +109,19 @@ async function resolveFontfileEscaped( } if (isCuratedFontKey(key)) { - const fontPath = resolveCuratedFontPath(key); - if (!(await fileExists(fontPath))) { - console.warn(`[ffmpeg] curated font missing: ${key} at ${fontPath}; using system font`); - return null; + const preferred = resolveCuratedFontPath(key, weight); + const fallback = weight === "bold" ? resolveCuratedFontPath(key, "regular") : preferred; + for (const fontPath of weight === "bold" && preferred !== fallback ? [preferred, fallback] : [preferred]) { + if (!(await fileExists(fontPath))) continue; + try { + await assertFontFile(fontPath); + return sanitizeFontfileForFilter(fontPath); + } catch { + /* try next */ + } } - return sanitizeFontfileForFilter(fontPath); + console.warn(`[ffmpeg] curated font missing: ${key}; using system font`); + return null; } return null; @@ -89,6 +131,39 @@ function classicScaleFilter(width: number, height: number): string { return `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`; } +/** + * Plan-aware audio for MP4: + * - Free: AAC 192k (compressed) + * - Pro: AAC 320k, or stream copy for already-compatible AAC/M4A + */ +export type AudioEncodeOptions = { + bitrateKbps: 192 | 320; + copyWhenSafe: boolean; + audioPath: string; +}; + +function isCopySafeAudioPath(audioPath: string): boolean { + const ext = path.extname(audioPath).toLowerCase(); + return ext === ".aac" || ext === ".m4a" || ext === ".mp4"; +} + +function pushAudioEncodeArgs(args: string[], audio: AudioEncodeOptions): void { + if (audio.copyWhenSafe && isCopySafeAudioPath(audio.audioPath)) { + args.push("-c:a", "copy"); + return; + } + args.push( + "-c:a", + "aac", + "-b:a", + `${audio.bitrateKbps}k`, + "-ar", + "44100", + "-ac", + "2", + ); +} + /** * Art-track filter that ends at `outLabel` instead of hardcoded [laid]. */ @@ -99,6 +174,13 @@ function artTrackFilterEndingAt( return buildArtTrackFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`); } +function classicBlurFillEndingAt( + opts: Parameters[0], + outLabel: string, +): string { + return buildClassicBlurFillFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`); +} + export async function encodeVideo(options: { imagePath: string; audioPath: string; @@ -110,24 +192,64 @@ export async function encodeVideo(options: { /** On-video song title (art-track layouts). */ songTitle?: string; artist?: string | null; + /** + * Optional full-frame background for lower-corner templates. + * When set with LOWER_LEFT / LOWER_RIGHT, blur fill uses this image; cover stays sharp. + */ + backgroundImagePath?: string | null; + /** Free = 192k AAC; Pro = 320k AAC (or copy when safe). Defaults to Free. */ + audioBitrateKbps?: 192 | 320; + audioCopyWhenSafe?: boolean; }): Promise { const res = getResolution(options.resolution); if (!res) throw new Error(`Invalid resolution: ${options.resolution}`); + const audioEncode: AudioEncodeOptions = { + bitrateKbps: options.audioBitrateKbps ?? 192, + copyWhenSafe: Boolean(options.audioCopyWhenSafe), + audioPath: options.audioPath, + }; + await fs.mkdir(path.dirname(options.outputPath), { recursive: true }); + const outputDurationSec = formatOutputDurationSeconds( + await getAudioDurationSeconds(options.audioPath), + ); + const settings = normalizeWatermarkSettings(options.watermark, options.includeWatermark); - const layout = options.layout?.template ? options.layout : null; + const layoutSettings = options.layout + ? normalizeLayoutSettings(options.layout) + : null; + const artTrack = Boolean(layoutSettings?.template); + const classicBlurFill = Boolean(layoutSettings && !artTrack && layoutSettings.blurFill); + const layout = artTrack && layoutSettings ? layoutSettings : null; const watermarkWidth = Math.max(1, Math.round(res.width * WATERMARK_WIDTH_FRACTION)); const fontSize = watermarkFontSizeForWidth(res.width); - const fontfile = await resolveFontfileEscaped(settings); + const fontfile = await resolveFontfileEscaped(settings, "regular"); + const titleFontfile = + layout?.titleBold ? await resolveFontfileEscaped(settings, "bold") : null; + // Only pass a distinct bold face when it differs from regular (avoids faux-bold when bold TTF exists). + const titleFontfileDistinct = + titleFontfile && titleFontfile !== fontfile ? titleFontfile : null; const args = ["-y", "-loop", "1", "-r", "1", "-i", options.imagePath, "-i", options.audioPath]; let nextInput = 2; + let separateBackgroundInputIndex: number | null = null; let logoInputIndex: number | null = null; let defaultWmInputIndex: number | null = null; + const useSeparateBackground = + Boolean(layout && isLowerCornerTemplate(layout.template) && options.backgroundImagePath); + + if (useSeparateBackground && options.backgroundImagePath) { + if (!(await fileExists(options.backgroundImagePath))) { + throw new Error("Background image file not found"); + } + args.push("-loop", "1", "-r", "1", "-i", options.backgroundImagePath); + separateBackgroundInputIndex = nextInput++; + } + const needsLogo = settings.mode === "logo" && Boolean(settings.logoPath); const defaultPath = getWatermarkPath(); const useDefaultPng = @@ -156,17 +278,23 @@ export async function encodeVideo(options: { ? ("default-text" as const) : ("none" as const); - // Fast path: classic letterbox, no watermark - if (!layout && applyWm === "none") { + // Fast path: classic letterbox (black bars), no watermark, no blur fill + if (!layout && !classicBlurFill && applyWm === "none") { args.push("-vf", classicScaleFilter(res.width, res.height)); args.push( "-c:v", "libx264", + "-preset", + "ultrafast", "-tune", "stillimage", - "-c:a", - "copy", - "-shortest", + "-r", + "1", + ); + pushAudioEncodeArgs(args, audioEncode); + args.push( + "-t", + outputDurationSec, "-pix_fmt", "yuv420p", options.outputPath, @@ -192,6 +320,20 @@ export async function encodeVideo(options: { titleEscaped, artistEscaped, fontfileEscaped: fontfile, + titleFontfileEscaped: titleFontfileDistinct, + separateBackgroundInputIndex, + }, + baseLabel, + ), + ); + } else if (classicBlurFill && layoutSettings) { + filterParts.push( + classicBlurFillEndingAt( + { + width: res.width, + height: res.height, + blurAmount: layoutSettings.blurAmount, + blurOpacity: layoutSettings.blurOpacity, }, baseLabel, ), @@ -223,6 +365,7 @@ export async function encodeVideo(options: { baseLabel, watermarkInputIndex: defaultWmInputIndex, watermarkWidthPx: watermarkWidth, + position: settings.position, offsetX: settings.offsetX, offsetY: settings.offsetY, }), @@ -235,7 +378,7 @@ export async function encodeVideo(options: { position: settings.position, offsetX: settings.offsetX, offsetY: settings.offsetY, - fontfileEscaped: null, + fontfileEscaped: fontfile, }); filterParts.push(`[${baseLabel}]${draw}[vout]`); } @@ -244,11 +387,17 @@ export async function encodeVideo(options: { args.push( "-c:v", "libx264", + "-preset", + "ultrafast", "-tune", "stillimage", - "-c:a", - "copy", - "-shortest", + "-r", + "1", + ); + pushAudioEncodeArgs(args, audioEncode); + args.push( + "-t", + outputDurationSec, "-pix_fmt", "yuv420p", options.outputPath,