"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, 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"; import { CategorySelect } from "./CategorySelect"; import { LayoutStudio } from "./LayoutStudio"; import { PlaylistSelect } from "./PlaylistSelect"; import { PrivacyToggle } from "./PrivacyToggle"; import { ResolutionSelect } from "./ResolutionSelect"; import { QuotaErrorMessage, UpgradeProLink } from "./UpgradeProLink"; import { UpgradeProButton } from "./UpgradeProButton"; 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"; import { createClientId } from "@/lib/client-id"; 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; file: File; path: string | null; uploading: boolean; itemImagePath: string | null; itemImageName: string | null; itemImagePreview: string | null; metadata: ItemMetadata; }; function defaultMetadata(title = ""): ItemMetadata { return { title, description: "", tags: "", privacy: "PUBLIC" as Privacy, categoryId: "10", resolution: "1280x720", notifySubscribers: true, madeForKids: false, embeddable: true, creativeCommons: false, includeWatermark: true, playlistId: null, imagePath: null, artist: null, songTitle: null, watermark: { ...DEFAULT_WATERMARK }, layout: { ...DEFAULT_LAYOUT }, }; } export function UploadForm() { const router = useRouter(); const uploadSessionRef = useRef(createClientId()); const [imageFile, setImageFile] = useState(null); const [imagePath, setImagePath] = useState(null); const [imagePreviewUrl, setImagePreviewUrl] = useState(null); const [imageUploading, setImageUploading] = useState(false); const [audioItems, setAudioItems] = useState([]); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [playlistId, setPlaylistId] = useState(""); const [jobWatermark, setJobWatermark] = useState({ ...DEFAULT_WATERMARK }); const [jobLayout, setJobLayout] = useState({ ...DEFAULT_LAYOUT }); const [logoPreviewUrl, setLogoPreviewUrl] = useState(null); 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 = 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) 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]); // 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 || isPro) { setWatermarkDefaultsApplied(true); return; } setJobWatermark({ ...DEFAULT_WATERMARK, mode: "default", }); setWatermarkDefaultsApplied(true); }, [quota, watermarkDefaultsApplied]); useEffect(() => { return () => { if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl); if (logoPreviewUrl) URL.revokeObjectURL(logoPreviewUrl); audioItems.forEach((a) => { if (a.itemImagePreview) URL.revokeObjectURL(a.itemImagePreview); }); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup on unmount only }, []); async function uploadFile( file: File, type: "image" | "audio" | "logo" | "font", ): Promise<{ path: string; audioTags?: Parameters[0] | null }> { const formData = new FormData(); formData.append("file", file); formData.append("type", type); formData.append("session", uploadSessionRef.current); const res = await fetch("/api/upload", { method: "POST", body: formData }); const data = await res.json(); if (!res.ok) { throw new Error(data.error || "Upload failed"); } return { path: data.path, audioTags: data.audioTags }; } async function uploadFilesWithConcurrency(files: File[]) { const pending = files.map((file) => { const id = createClientId(); const autoTitle = filenameWithoutExtension(file.name); setAudioItems((prev) => [ ...prev, { id, file, path: null, uploading: true, itemImagePath: null, itemImageName: null, itemImagePreview: null, metadata: { ...defaultMetadata(autoTitle), playlistId: playlistId || null, watermark: { ...jobWatermark }, layout: { ...jobLayout }, }, }, ]); return { id, file, autoTitle }; }); for (let i = 0; i < pending.length; i += UPLOAD_CONCURRENCY) { const chunk = pending.slice(i, i + UPLOAD_CONCURRENCY); const results = await Promise.allSettled( chunk.map(async ({ id, file, autoTitle }) => { const upload = await uploadFile(file, "audio"); const tagMetadata = upload.audioTags ? audioTagsToMetadata(upload.audioTags, autoTitle) : null; setAudioItems((prev) => prev.map((item) => item.id === id ? { ...item, path: upload.path, uploading: false, metadata: tagMetadata ? { ...item.metadata, ...tagMetadata } : item.metadata, } : item, ), ); }), ); for (let j = 0; j < results.length; j++) { if (results[j].status === "rejected") { const { id } = chunk[j]; const reason = results[j] as PromiseRejectedResult; setError(reason.reason instanceof Error ? reason.reason.message : "Audio upload failed"); setAudioItems((prev) => prev.filter((item) => item.id !== id)); } } } } async function processImageFile(file: File) { setError(null); setImageFile(file); setImageUploading(true); if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl); setImagePreviewUrl(URL.createObjectURL(file)); try { const path = await uploadFile(file, "image"); setImagePath(path.path); } catch (err) { setError(err instanceof Error ? err.message : "Image upload failed"); setImageFile(null); setImagePath(null); if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl); setImagePreviewUrl(null); } finally { setImageUploading(false); } } async function ingestMediaFiles(images: File[], audios: File[]) { setError(null); if (!images.length && !audios.length) { setError("Choose a JPG/PNG cover or an MP3. Videos and other file types are not supported."); return; } 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 = createClientId(); 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."); return; } if (!file) return; setError(null); const preview = URL.createObjectURL(file); setAudioItems((prev) => prev.map((item) => { if (item.id !== itemId) return item; if (item.itemImagePreview) URL.revokeObjectURL(item.itemImagePreview); return { ...item, itemImageName: file.name, itemImagePreview: preview, itemImagePath: null, }; }), ); try { const uploaded = await uploadFile(file, "image"); setAudioItems((prev) => prev.map((item) => item.id === itemId ? { ...item, itemImagePath: uploaded.path, metadata: { ...item.metadata, imagePath: uploaded.path }, } : item, ), ); } catch (err) { setError(err instanceof Error ? err.message : "Per-item image upload failed"); setAudioItems((prev) => prev.map((item) => { if (item.id !== itemId) return item; if (item.itemImagePreview) URL.revokeObjectURL(item.itemImagePreview); return { ...item, itemImagePath: null, itemImageName: null, itemImagePreview: null, metadata: { ...item.metadata, imagePath: null }, }; }), ); } } async function handleLogoUpload(file: File) { const preview = URL.createObjectURL(file); if (logoPreviewUrl) URL.revokeObjectURL(logoPreviewUrl); setLogoPreviewUrl(preview); const uploaded = await uploadFile(file, "logo"); return uploaded.path; } async function handleFontUpload(file: File) { const uploaded = await uploadFile(file, "font"); 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); setAudioItems((prev) => prev.map((item) => ({ ...item, metadata: { ...item.metadata, includeWatermark: normalized.mode !== "none", watermark: { ...normalized }, }, })), ); } function applyLayoutToAll(next: LayoutSettings) { const normalized = { ...DEFAULT_LAYOUT, ...next }; setJobLayout(normalized); if (!isLowerCornerTemplate(normalized.template)) { clearBackgroundImage(); } setAudioItems((prev) => prev.map((item) => ({ ...item, metadata: { ...item.metadata, layout: { ...normalized }, }, })), ); } function updateItemMetadata(id: string, updates: Partial) { setAudioItems((prev) => prev.map((item) => item.id === id ? { ...item, metadata: { ...item.metadata, ...updates } } : item, ), ); } function handlePlaylistChange(nextPlaylistId: string) { setPlaylistId(nextPlaylistId); setAudioItems((prev) => prev.map((item) => ({ ...item, metadata: { ...item.metadata, playlistId: nextPlaylistId || null, }, })), ); } function removeAudioItem(id: string) { setAudioItems((prev) => prev.filter((item) => item.id !== id)); } const readyAudios = audioItems.filter((a) => a.path && !a.uploading); const hasSelectedMedia = Boolean(imageFile || audioItems.length > 0); const canSubmit = imagePath && !imageUploading && readyAudios.length > 0 && readyAudios.every((a) => Boolean( resolveYouTubeTitle( { title: a.metadata.title, songTitle: a.metadata.songTitle, artist: a.metadata.artist, }, isPro ? Plan.PRODUCER_PRO : Plan.FREE, ), ), ) && !submitting; function submitBlockReason(): string | null { if (submitting) return null; if (!imagePath && !imageUploading) return "Add a cover image to continue."; if (imageUploading) return "Cover image is still uploading…"; if (readyAudios.length === 0) { return audioItems.some((a) => a.uploading) ? "Wait for audio uploads to finish." : "Add at least one audio file."; } if ( !readyAudios.every((a) => Boolean( resolveYouTubeTitle( { title: a.metadata.title, songTitle: a.metadata.songTitle, artist: a.metadata.artist, }, isPro ? Plan.PRODUCER_PRO : Plan.FREE, ), ), ) ) { return "Enter a video title for each track."; } return null; } function handleRenderPress(e: React.SyntheticEvent) { e.preventDefault(); if (!canSubmit) { const reason = submitBlockReason(); if (reason) setError(reason); return; } void handleSubmit(e as React.FormEvent); } 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 data = await postJob(body); router.push(`/jobs/${data.jobId}`); } catch (err) { setError(err instanceof Error ? err.message : "Submission failed"); setSubmitting(false); } } async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!canSubmit || !imagePath) return; // 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; } 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 (isPro) { return "Paid plan (Zero Watermark)"; } return "Free Tier - Songs2VID badge on every render"; } return (
!submitting && !shareVerifying && setChoiceOpen(false)} onOpenShare={handleOpenShare} onVerifyShare={(platform, url) => void handleVerifyShare(platform, url)} onContinueDownload={handleContinueDownload} onConnectYouTube={() => void handleConnectYouTube()} /> {quota && (
{watermarkStatusLabel() ? (

{watermarkStatusLabel()}

) : null} {quota.selfHosted ? ( <> 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.plan === "FREE" ? `resets in ${quota.resetsIn}` : `monthly quota resets on ${quota.resetsIn}`}{" "} - up to {quota.maxBatchSize} files per batch {quota.plan === "FREE" ? ( <> {" - "} Buy 1-15 credits ) : null} )}
)} {error && (
)}

Files

{ 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."}

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 ? "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} onProRequired={() => setError( "Art-track layouts and custom watermarks require Pro. Classic blurred letterbox works on Free — or try the demo track above.", ) } /> {audioItems.length > 0 && (

Video details (per audio)

Each audio file gets its own metadata. Video title is used for YouTube; song title and artist appear in Pro art-track layouts.

{audioItems.map((item, index) => (

Video {index + 1}: {item.file.name}

{item.uploading && (

Uploading...

)}
updateItemMetadata(item.id, { title: e.target.value })} className="input-field" required placeholder="Title shown on YouTube" /> updateItemMetadata(item.id, { songTitle: e.target.value })} className="input-field disabled:cursor-not-allowed disabled:opacity-60" disabled={!isPro} placeholder={isPro ? "Shown in art-track layout" : "Pro feature"} maxLength={SONG_TITLE_MAX} />
updateItemMetadata(item.id, { artist: e.target.value })} className="input-field disabled:cursor-not-allowed disabled:opacity-60" disabled={!isPro} placeholder={isPro ? "Shown in art-track layout" : "Pro feature"} maxLength={80} /> updateItemMetadata(item.id, { categoryId: v })} />