Separate YouTube video titles from on-video song/artist fields with Pro gating, serve curated fonts and watermark assets for 1:1 preview parity, and include billing/API/docs/deploy stack for self-hosted release.
852 lines
29 KiB
TypeScript
852 lines
29 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
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 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";
|
||
|
||
const UPLOAD_CONCURRENCY = 4;
|
||
|
||
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 [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;
|
||
} | null>(null);
|
||
|
||
const isPro = Boolean(quota?.selfHosted || quota?.plan === "PREMIUM");
|
||
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),
|
||
});
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadQuota();
|
||
}, [loadQuota]);
|
||
|
||
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 handleImageChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
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 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;
|
||
}
|
||
|
||
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);
|
||
setAudioItems((prev) =>
|
||
prev.map((item) => ({
|
||
...item,
|
||
metadata: {
|
||
...item.metadata,
|
||
layout: { ...normalized },
|
||
},
|
||
})),
|
||
);
|
||
}
|
||
|
||
async function handleAudioChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||
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<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 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.PREMIUM : Plan.FREE,
|
||
),
|
||
),
|
||
) &&
|
||
!submitting;
|
||
|
||
async function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!canSubmit || !imagePath) return;
|
||
|
||
setSubmitting(true);
|
||
setError(null);
|
||
|
||
try {
|
||
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: jobWatermark.mode !== "none",
|
||
watermark: canCustomWatermark
|
||
? jobWatermark
|
||
: {
|
||
mode: jobWatermark.mode === "none" ? "none" : "default",
|
||
position: "bottom-right",
|
||
offsetX: 20,
|
||
offsetY: 20,
|
||
},
|
||
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");
|
||
}
|
||
|
||
router.push(`/jobs/${data.jobId}`);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Submission failed");
|
||
setSubmitting(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit} className="space-y-6">
|
||
{quota && (
|
||
<div className="rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm text-gray-300">
|
||
{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" ? (
|
||
<>
|
||
{" · "}
|
||
<a href="/dashboard/settings" className="text-accent hover:underline">
|
||
Buy 1–15 credits
|
||
</a>
|
||
</>
|
||
) : 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>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div>
|
||
<label className="mb-2 block text-sm text-gray-400">Image</label>
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handleImageChange}
|
||
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"
|
||
/>
|
||
<div className="mt-2 flex items-center gap-2 text-sm">
|
||
<StatusDot ok={!!imagePath && !imageUploading} />
|
||
<span className="text-gray-400">
|
||
{imageUploading ? "Uploading…" : imageFile?.name || "No image selected"}
|
||
</span>
|
||
</div>
|
||
<p className="mt-2 text-xs text-gray-500">
|
||
{canPerItemImages
|
||
? "Shared cover for all tracks. Optionally override per audio below (Pro)."
|
||
: "Free plan: one static cover for the whole batch."}
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-2 block text-sm text-gray-400">Audio files</label>
|
||
<input
|
||
type="file"
|
||
accept={
|
||
quota?.selfHosted || quota?.plan === "PREMIUM"
|
||
? "audio/mpeg,audio/wav,audio/flac,.mp3,.wav,.flac"
|
||
: "audio/mpeg,.mp3"
|
||
}
|
||
multiple
|
||
onChange={handleAudioChange}
|
||
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"
|
||
/>
|
||
<div className="mt-2 flex items-center gap-2 text-sm">
|
||
<StatusDot ok={readyAudios.length > 0} />
|
||
<span className="text-gray-400">
|
||
{audioItems.length === 0
|
||
? "No audio files selected"
|
||
: `${readyAudios.length} of ${audioItems.length} ready`}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{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 || quota?.plan === "PREMIUM")}
|
||
/>
|
||
</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={`Show '${getVideoAttributionText()}' watermark (support open-source)`}
|
||
checked={jobWatermark.mode !== "none"}
|
||
onChange={(v) =>
|
||
applyWatermarkToAll({
|
||
...DEFAULT_WATERMARK,
|
||
mode: v ? "default" : "none",
|
||
})
|
||
}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</section>
|
||
)}
|
||
|
||
<LayoutStudio
|
||
locked={!canArtTrackLayouts && !canCustomWatermark}
|
||
previewImageUrl={
|
||
audioItems.find((a) => a.itemImagePreview)?.itemImagePreview || imagePreviewUrl
|
||
}
|
||
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}
|
||
logoPreviewUrl={logoPreviewUrl}
|
||
/>
|
||
|
||
{quota && !quota.selfHosted && quota.plan === "FREE" && (
|
||
<>
|
||
<p className="text-sm text-gray-400">
|
||
Free plan: one static cover image for the batch · optional Songs2VID watermark
|
||
(bottom-right only).
|
||
</p>
|
||
<p className="text-sm text-gray-400">
|
||
<UpgradeProLink className="text-accent hover:underline" /> for custom branding, typography,
|
||
blurred art-track layouts, watermark positioning, unique image matching, 1080p, and
|
||
lossless audio.
|
||
</p>
|
||
</>
|
||
)}
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={!canSubmit}
|
||
className="w-full rounded bg-accent px-6 py-3 font-medium text-white transition-colors hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
{submitting
|
||
? "Creating videos…"
|
||
: `Create ${readyAudios.length || ""} Video${readyAudios.length !== 1 ? "s" : ""}`}
|
||
</button>
|
||
</form>
|
||
);
|
||
}
|
||
|
||
function StatusDot({ ok }: { ok: boolean }) {
|
||
return (
|
||
<span
|
||
className={`inline-block h-3 w-3 rounded-full ${ok ? "bg-green-500" : "bg-red-500"}`}
|
||
/>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|