mirror of
https://git.atakanozban.com/Songs2VID/songs2vid.git
synced 2026-10-04 10:38:32 +02:00
- 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
1425 lines
48 KiB
TypeScript
1425 lines
48 KiB
TypeScript
"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";
|
|
|
|
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<string>(crypto.randomUUID());
|
|
const [imageFile, setImageFile] = useState<File | null>(null);
|
|
const [imagePath, setImagePath] = useState<string | null>(null);
|
|
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
|
|
const [imageUploading, setImageUploading] = useState(false);
|
|
const [audioItems, setAudioItems] = useState<AudioItem[]>([]);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [playlistId, setPlaylistId] = useState("");
|
|
const [jobWatermark, setJobWatermark] = useState<WatermarkSettings>({ ...DEFAULT_WATERMARK });
|
|
const [jobLayout, setJobLayout] = useState<LayoutSettings>({ ...DEFAULT_LAYOUT });
|
|
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
|
|
const [backgroundImagePath, setBackgroundImagePath] = useState<string | null>(null);
|
|
const [backgroundPreviewUrl, setBackgroundPreviewUrl] = useState<string | null>(null);
|
|
const [quota, setQuota] = useState<QuotaState | null>(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<string | null>(null);
|
|
const [shareVerifying, setShareVerifying] = useState(false);
|
|
const [shareVerifyError, setShareVerifyError] = useState<string | null>(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<typeof audioTagsToMetadata>[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 = crypto.randomUUID();
|
|
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 > 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.");
|
|
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<ItemMetadata>) {
|
|
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 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<T extends { imagePath: string; items: Array<{ audioPath: string; metadata: ItemMetadata }> }>(
|
|
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 (
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
<RenderChoiceModal
|
|
open={choiceOpen && !isPro}
|
|
shareUnlocked={shareUnlocked}
|
|
busy={submitting}
|
|
verifying={shareVerifying}
|
|
verifyError={shareVerifyError}
|
|
youtubeNeedsSignIn={!quota?.youtubeConnected}
|
|
onClose={() => !submitting && !shareVerifying && setChoiceOpen(false)}
|
|
onOpenShare={handleOpenShare}
|
|
onVerifyShare={(platform, url) => void handleVerifyShare(platform, url)}
|
|
onContinueDownload={handleContinueDownload}
|
|
onConnectYouTube={() => void handleConnectYouTube()}
|
|
/>
|
|
{quota && (
|
|
<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 ? (
|
|
<>
|
|
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" ? (
|
|
<>
|
|
{" - "}
|
|
<Link href="/dashboard/settings" className="text-accent hover:underline">
|
|
Buy 1-15 credits
|
|
</Link>
|
|
</>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
|
<QuotaErrorMessage message={error} />
|
|
</div>
|
|
)}
|
|
|
|
<section className="rounded-lg border border-gray-700 bg-surface p-6 space-y-4">
|
|
<h2 className="text-lg font-medium text-white">Files</h2>
|
|
|
|
<MediaDropzone
|
|
disabled={submitting || demoLoading}
|
|
acceptAudio={
|
|
quota?.selfHosted || isPro
|
|
? "audio/mpeg,audio/wav,audio/flac,audio/aac,audio/ogg,audio/mp4,.mp3,.wav,.flac,.aac,.ogg,.m4a"
|
|
: "audio/mpeg,.mp3"
|
|
}
|
|
onFiles={({ images, audios }) => {
|
|
void ingestMediaFiles(images, audios);
|
|
}}
|
|
/>
|
|
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<button
|
|
type="button"
|
|
onClick={() => void loadDemoTrack()}
|
|
disabled={submitting || demoLoading}
|
|
className="inline-flex items-center justify-center gap-2 rounded-lg border border-accent/40 bg-accent/10 px-4 py-2.5 text-sm font-medium text-accent transition hover:bg-accent/20 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
<span aria-hidden>⚡</span>
|
|
{demoLoading ? "Loading demo..." : "Try with Demo Track"}
|
|
</button>
|
|
<p className="text-xs text-gray-500">
|
|
{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."}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div className="flex items-start gap-3 rounded-lg border border-gray-800 bg-surface-light/40 px-3 py-2.5">
|
|
<StatusDot
|
|
state={imageUploading ? "pending" : imagePath ? "ready" : "idle"}
|
|
/>
|
|
<div className="min-w-0">
|
|
<p className="text-xs font-medium uppercase tracking-wider text-gray-500">
|
|
Cover image
|
|
</p>
|
|
<p className="truncate text-sm text-gray-300">
|
|
{imageUploading
|
|
? "Uploading..."
|
|
: imageFile?.name || "Drop a JPG/PNG cover to preview"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-start gap-3 rounded-lg border border-gray-800 bg-surface-light/40 px-3 py-2.5">
|
|
<StatusDot
|
|
state={
|
|
audioItems.some((a) => a.uploading)
|
|
? "pending"
|
|
: readyAudios.length > 0
|
|
? "ready"
|
|
: "idle"
|
|
}
|
|
/>
|
|
<div className="min-w-0">
|
|
<p className="text-xs font-medium uppercase tracking-wider text-gray-500">
|
|
Audio
|
|
</p>
|
|
<p className="truncate text-sm text-gray-300">
|
|
{audioItems.length === 0
|
|
? "Drop MP3/WAV tracks to render"
|
|
: `${readyAudios.length} of ${audioItems.length} ready`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<LayoutStudio
|
|
locked={!canArtTrackLayouts && !canCustomWatermark}
|
|
previewImageUrl={
|
|
audioItems.find((a) => 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 && (
|
|
<section className="space-y-4">
|
|
<h2 className="text-lg font-medium text-white">Video details (per audio)</h2>
|
|
<p className="text-sm text-gray-400">
|
|
Each audio file gets its own metadata. Video title is used for YouTube; song title and
|
|
artist appear in Pro art-track layouts.
|
|
</p>
|
|
|
|
<div className="rounded-lg border border-gray-700 bg-surface p-6">
|
|
<PlaylistSelect
|
|
value={playlistId}
|
|
onChange={handlePlaylistChange}
|
|
enabled={Boolean(quota?.selfHosted || isPro)}
|
|
/>
|
|
</div>
|
|
|
|
{audioItems.map((item, index) => (
|
|
<div
|
|
key={item.id}
|
|
className="rounded-lg border border-gray-700 bg-surface p-6 space-y-4"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="font-medium text-white">
|
|
Video {index + 1}: {item.file.name}
|
|
</h3>
|
|
{item.uploading && (
|
|
<p className="text-xs text-yellow-400">Uploading...</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeAudioItem(item.id)}
|
|
className="text-sm text-red-400 hover:text-red-300"
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<Field label="Video title (YouTube)">
|
|
<input
|
|
type="text"
|
|
value={item.metadata.title}
|
|
onChange={(e) => updateItemMetadata(item.id, { title: e.target.value })}
|
|
className="input-field"
|
|
required
|
|
placeholder="Title shown on YouTube"
|
|
/>
|
|
</Field>
|
|
|
|
<ProLockedField
|
|
label="Song title (on-video)"
|
|
locked={!isPro}
|
|
hint="Pro unlocks custom song title burned into art-track layouts."
|
|
>
|
|
<input
|
|
type="text"
|
|
value={item.metadata.songTitle ?? ""}
|
|
onChange={(e) => 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}
|
|
/>
|
|
</ProLockedField>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<ProLockedField
|
|
label="Artist (on-video)"
|
|
locked={!isPro}
|
|
hint="Pro unlocks custom artist line in art-track layouts."
|
|
>
|
|
<input
|
|
type="text"
|
|
value={item.metadata.artist ?? ""}
|
|
onChange={(e) => 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}
|
|
/>
|
|
</ProLockedField>
|
|
<Field label="Category">
|
|
<CategorySelect
|
|
value={item.metadata.categoryId}
|
|
onChange={(v) => updateItemMetadata(item.id, { categoryId: v })}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
|
|
<Field label="Description">
|
|
<textarea
|
|
value={item.metadata.description}
|
|
onChange={(e) => updateItemMetadata(item.id, { description: e.target.value })}
|
|
rows={3}
|
|
className="input-field resize-y"
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="Tags">
|
|
<input
|
|
type="text"
|
|
value={item.metadata.tags}
|
|
onChange={(e) => updateItemMetadata(item.id, { tags: e.target.value })}
|
|
placeholder='Separate with spaces or commas. Use "quoted phrases" for multi-word tags.'
|
|
className="input-field"
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="Privacy">
|
|
<PrivacyToggle
|
|
value={item.metadata.privacy}
|
|
onChange={(v) => updateItemMetadata(item.id, { privacy: v })}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="Video size">
|
|
<ResolutionSelect
|
|
value={item.metadata.resolution}
|
|
onChange={(v) => updateItemMetadata(item.id, { resolution: v })}
|
|
plan={quota?.plan}
|
|
/>
|
|
</Field>
|
|
|
|
<div className="relative rounded border border-gray-800 bg-surface-light/40 p-3">
|
|
{!canPerItemImages && (
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<span className="rounded border border-accent/40 bg-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-accent">
|
|
Pro
|
|
</span>
|
|
<span className="text-xs text-gray-500">Unique image per track</span>
|
|
</div>
|
|
)}
|
|
<Field
|
|
label={
|
|
canPerItemImages
|
|
? "Cover for this track (optional)"
|
|
: "Cover for this track (Pro)"
|
|
}
|
|
>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
disabled={!canPerItemImages}
|
|
onChange={(e) => {
|
|
const f = e.target.files?.[0] ?? null;
|
|
void handleItemImageChange(item.id, f);
|
|
e.target.value = "";
|
|
}}
|
|
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white disabled:opacity-50"
|
|
/>
|
|
</Field>
|
|
{item.itemImageName && (
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
{item.itemImagePath ? `Using ${item.itemImageName}` : `Uploading ${item.itemImageName}...`}
|
|
</p>
|
|
)}
|
|
{!canPerItemImages && (
|
|
<p className="mt-2 text-xs text-gray-500">
|
|
<UpgradeProButton label="Upgrade for bulk unique image matching" />
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<Checkbox
|
|
label="Notify subscribers about this upload"
|
|
checked={item.metadata.notifySubscribers}
|
|
onChange={(v) => updateItemMetadata(item.id, { notifySubscribers: v })}
|
|
/>
|
|
<Checkbox
|
|
label="Made For Kids?"
|
|
checked={item.metadata.madeForKids}
|
|
onChange={(v) => updateItemMetadata(item.id, { madeForKids: v })}
|
|
/>
|
|
<Checkbox
|
|
label="Embeddable?"
|
|
checked={item.metadata.embeddable}
|
|
onChange={(v) => updateItemMetadata(item.id, { embeddable: v })}
|
|
/>
|
|
<Checkbox
|
|
label="Creative Commons?"
|
|
checked={item.metadata.creativeCommons}
|
|
onChange={(v) => updateItemMetadata(item.id, { creativeCommons: v })}
|
|
/>
|
|
{!canCustomWatermark && (
|
|
<Checkbox
|
|
label={
|
|
!isPro && !quota?.selfHosted
|
|
? "Songs2VID watermark (required on Free)"
|
|
: `Show '${getVideoAttributionText()}' watermark (support open-source)`
|
|
}
|
|
checked={jobWatermark.mode !== "none"}
|
|
onChange={(v) => {
|
|
if (!v && !isPro && !quota?.selfHosted) {
|
|
return;
|
|
}
|
|
applyWatermarkToAll({
|
|
...DEFAULT_WATERMARK,
|
|
mode: v ? "default" : "none",
|
|
});
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</section>
|
|
)}
|
|
|
|
{quota && !quota.selfHosted && quota.plan === "FREE" && (
|
|
<>
|
|
<p className="text-sm text-gray-400">
|
|
Free plan: 3 videos/month at 720p with a Songs2VID badge on every render (bottom-right).
|
|
One static cover image per batch. No API access.
|
|
</p>
|
|
<p className="text-sm text-gray-400">
|
|
<UpgradeProLink className="text-accent hover:underline" /> for zero watermark & Full
|
|
HD batch output, custom branding, typography, art-track layouts, unique image matching,
|
|
1080p, and API / n8n workflows.
|
|
</p>
|
|
</>
|
|
)}
|
|
|
|
{/* In-flow submit (always available); sticky bar appears once media is ready */}
|
|
{!hasSelectedMedia && (
|
|
<button
|
|
type="submit"
|
|
disabled={!canSubmit}
|
|
className="w-full rounded-lg bg-accent px-6 py-3.5 text-base font-semibold text-white transition-colors hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{submitting
|
|
? "Creating videos..."
|
|
: `Render Video${readyAudios.length > 1 ? "s" : ""}`}
|
|
</button>
|
|
)}
|
|
|
|
{hasSelectedMedia && (
|
|
<>
|
|
<div className="h-24" aria-hidden />
|
|
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-gray-700/80 bg-[#0a0c10]/95 px-4 py-3 shadow-[0_-8px_32px_rgba(0,0,0,0.45)] backdrop-blur-md">
|
|
<div className="mx-auto flex max-w-4xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="min-w-0 text-sm text-gray-300">
|
|
<span className="font-medium text-white">
|
|
{readyAudios.length} track{readyAudios.length === 1 ? "" : "s"}
|
|
</span>
|
|
{imageFile ? (
|
|
<span className="text-gray-500"> - {imageFile.name}</span>
|
|
) : (
|
|
<span className="text-gray-500"> - add a cover to continue</span>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
disabled={!canSubmit}
|
|
className="w-full shrink-0 rounded-lg bg-accent px-8 py-3.5 text-base font-bold tracking-wide text-white shadow-lg shadow-accent/25 transition hover:bg-accent-hover hover:shadow-accent/40 disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto"
|
|
>
|
|
{submitting
|
|
? "Creating videos..."
|
|
: `Render Video${readyAudios.length > 1 ? "s" : ""}`}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function StatusDot({ state }: { state: "idle" | "pending" | "ready" }) {
|
|
const color =
|
|
state === "ready"
|
|
? "bg-emerald-400"
|
|
: state === "pending"
|
|
? "bg-amber-400"
|
|
: "bg-gray-600";
|
|
return (
|
|
<span
|
|
className={`mt-1 inline-block h-2.5 w-2.5 shrink-0 rounded-full ${color}`}
|
|
aria-hidden
|
|
/>
|
|
);
|
|
}
|
|
|
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div>
|
|
<label className="mb-1 block text-sm text-gray-400">{label}</label>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProLockedField({
|
|
label,
|
|
locked,
|
|
hint,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
locked: boolean;
|
|
hint: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<label className="mb-1 flex items-center gap-2 text-sm text-gray-400">
|
|
{label}
|
|
{locked && (
|
|
<span
|
|
className="rounded border border-accent/50 bg-accent/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-accent"
|
|
title={hint}
|
|
>
|
|
Pro
|
|
</span>
|
|
)}
|
|
</label>
|
|
<div className="relative">
|
|
{children}
|
|
{locked && (
|
|
<p className="mt-1 text-xs text-gray-500">{hint}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Checkbox({
|
|
label,
|
|
checked,
|
|
onChange,
|
|
disabled,
|
|
}: {
|
|
label: string;
|
|
checked: boolean;
|
|
onChange: (v: boolean) => void;
|
|
disabled?: boolean;
|
|
}) {
|
|
return (
|
|
<label className={`flex items-center gap-2 text-sm text-gray-300 ${disabled ? "opacity-60" : ""}`}>
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
onChange={(e) => onChange(e.target.checked)}
|
|
disabled={disabled}
|
|
className="rounded border-gray-600 bg-surface-light text-accent focus:ring-accent"
|
|
/>
|
|
{label}
|
|
</label>
|
|
);
|
|
}
|