mirror of
https://git.atakanozban.com/Songs2VID/songs2vid.git
synced 2026-10-04 10:38:32 +02:00
fix: mobile dashboard dropzone and dead-click UX for ad traffic
- Split mobile file picker into separate cover/audio native inputs (iOS-safe) - Overlay file input on desktop instead of programmatic click() - Safe createClientId() fallback when crypto.randomUUID is missing - Pro-locked layout controls show feedback instead of silent disabled taps - Render button explains what is missing when tapped before ready
This commit is contained in:
parent
3c733fec41
commit
18c32fb569
@ -10,6 +10,7 @@ import {
|
|||||||
import { getVideoAttributionText } from "@/lib/branding";
|
import { getVideoAttributionText } from "@/lib/branding";
|
||||||
import {
|
import {
|
||||||
CURATED_FONTS,
|
CURATED_FONTS,
|
||||||
|
SYSTEM_FONT,
|
||||||
type CuratedFontKey,
|
type CuratedFontKey,
|
||||||
type WatermarkFontKey,
|
type WatermarkFontKey,
|
||||||
} from "@/lib/fonts";
|
} from "@/lib/fonts";
|
||||||
@ -19,15 +20,18 @@ import {
|
|||||||
BLUR_OPACITY_DEFAULT,
|
BLUR_OPACITY_DEFAULT,
|
||||||
BLUR_OPACITY_MAX,
|
BLUR_OPACITY_MAX,
|
||||||
BLUR_OPACITY_MIN,
|
BLUR_OPACITY_MIN,
|
||||||
|
COMPOSITION_FAMILIES,
|
||||||
DEFAULT_LAYOUT,
|
DEFAULT_LAYOUT,
|
||||||
LAYOUT_TEMPLATE_LABELS,
|
LAYOUT_TEMPLATE_LABELS,
|
||||||
LAYOUT_TEMPLATES,
|
|
||||||
TEXT_OFFSET_MAX,
|
TEXT_OFFSET_MAX,
|
||||||
TEXT_OFFSET_MIN,
|
TEXT_OFFSET_MIN,
|
||||||
TEXT_PADDING_MAX,
|
TEXT_PADDING_MAX,
|
||||||
TEXT_PADDING_MIN,
|
TEXT_PADDING_MIN,
|
||||||
TITLE_ARTIST_GAP_MAX,
|
TITLE_ARTIST_GAP_MAX,
|
||||||
TITLE_ARTIST_GAP_MIN,
|
TITLE_ARTIST_GAP_MIN,
|
||||||
|
compositionFamilyForTemplate,
|
||||||
|
isLowerCornerTemplate,
|
||||||
|
lowerCornerCoverSide,
|
||||||
type LayoutSettings,
|
type LayoutSettings,
|
||||||
type LayoutTemplate,
|
type LayoutTemplate,
|
||||||
} from "@/lib/layout";
|
} from "@/lib/layout";
|
||||||
@ -55,6 +59,8 @@ import { UpgradeProButton } from "./UpgradeProButton";
|
|||||||
type Props = {
|
type Props = {
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
previewImageUrl: string | null;
|
previewImageUrl: string | null;
|
||||||
|
/** Optional custom blur-fill for lower-corner layouts. */
|
||||||
|
previewBackgroundUrl?: string | null;
|
||||||
/** On-video song title (art-track layouts). */
|
/** On-video song title (art-track layouts). */
|
||||||
songTitle: string;
|
songTitle: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
@ -66,7 +72,12 @@ type Props = {
|
|||||||
onWatermarkChange: (next: WatermarkSettings) => void;
|
onWatermarkChange: (next: WatermarkSettings) => void;
|
||||||
onUploadLogo: (file: File) => Promise<string>;
|
onUploadLogo: (file: File) => Promise<string>;
|
||||||
onUploadFont: (file: File) => Promise<string>;
|
onUploadFont: (file: File) => Promise<string>;
|
||||||
|
onUploadBackground?: (file: File) => Promise<string>;
|
||||||
|
onClearBackground?: () => void;
|
||||||
logoPreviewUrl?: string | null;
|
logoPreviewUrl?: string | null;
|
||||||
|
hasCustomBackground?: boolean;
|
||||||
|
/** Called when a Free user taps a Pro-only control (avoids silent dead clicks). */
|
||||||
|
onProRequired?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const WM_POSITION_LABELS: Record<WatermarkPosition, string> = {
|
const WM_POSITION_LABELS: Record<WatermarkPosition, string> = {
|
||||||
@ -122,26 +133,29 @@ function watermarkOverlayStyle(
|
|||||||
|
|
||||||
function MiniThumb({
|
function MiniThumb({
|
||||||
template,
|
template,
|
||||||
|
label,
|
||||||
active,
|
active,
|
||||||
onClick,
|
onClick,
|
||||||
disabled,
|
proLocked = false,
|
||||||
}: {
|
}: {
|
||||||
template: LayoutTemplate | null;
|
template: LayoutTemplate | null;
|
||||||
|
label: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
disabled: boolean;
|
proLocked?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const isClassic = template === null;
|
const isClassic = template === null;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`overflow-hidden rounded border text-left transition-colors ${
|
className={`overflow-hidden rounded border text-left transition-colors ${
|
||||||
active
|
active
|
||||||
? "border-accent bg-accent/10"
|
? "border-accent bg-accent/10"
|
||||||
: "border-gray-700 bg-black/40 hover:border-gray-500"
|
: proLocked
|
||||||
} disabled:cursor-not-allowed disabled:opacity-50`}
|
? "border-gray-700 bg-black/40 opacity-60"
|
||||||
|
: "border-gray-700 bg-black/40 hover:border-gray-500"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="relative aspect-video w-full overflow-hidden bg-gradient-to-br from-gray-800 to-gray-950 p-1">
|
<div className="relative aspect-video w-full overflow-hidden bg-gradient-to-br from-gray-800 to-gray-950 p-1">
|
||||||
{isClassic ? (
|
{isClassic ? (
|
||||||
@ -151,20 +165,14 @@ function MiniThumb({
|
|||||||
) : (
|
) : (
|
||||||
<div className="relative h-full w-full overflow-hidden bg-gray-700/50">
|
<div className="relative h-full w-full overflow-hidden bg-gray-700/50">
|
||||||
<div className="absolute inset-0 scale-110 bg-gray-600/40 blur-[3px]" />
|
<div className="absolute inset-0 scale-110 bg-gray-600/40 blur-[3px]" />
|
||||||
{template === "COVER_LEFT_TEXT_RIGHT" && (
|
{(template === "COVER_LEFT_TEXT_RIGHT" ||
|
||||||
|
template === "COVER_RIGHT_TEXT_LEFT") && (
|
||||||
<>
|
<>
|
||||||
<div className="absolute left-[6%] top-[18%] h-[64%] w-[38%] bg-gray-300" />
|
<div className="absolute left-[6%] top-[18%] h-[64%] w-[38%] bg-gray-300" />
|
||||||
<div className="absolute right-[8%] top-[38%] h-1 w-[40%] rounded bg-white/80" />
|
<div className="absolute right-[8%] top-[38%] h-1 w-[40%] rounded bg-white/80" />
|
||||||
<div className="absolute right-[8%] top-[52%] h-0.5 w-[28%] rounded bg-white/50" />
|
<div className="absolute right-[8%] top-[52%] h-0.5 w-[28%] rounded bg-white/50" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{template === "COVER_RIGHT_TEXT_LEFT" && (
|
|
||||||
<>
|
|
||||||
<div className="absolute right-[6%] top-[18%] h-[64%] w-[38%] bg-gray-300" />
|
|
||||||
<div className="absolute left-[8%] top-[38%] h-1 w-[40%] rounded bg-white/80" />
|
|
||||||
<div className="absolute left-[8%] top-[52%] h-0.5 w-[28%] rounded bg-white/50" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{template === "COVER_TOP_TEXT_BOTTOM" && (
|
{template === "COVER_TOP_TEXT_BOTTOM" && (
|
||||||
<>
|
<>
|
||||||
<div className="absolute left-[8%] top-[6%] h-[52%] w-[84%] bg-gray-300" />
|
<div className="absolute left-[8%] top-[6%] h-[52%] w-[84%] bg-gray-300" />
|
||||||
@ -179,12 +187,18 @@ function MiniThumb({
|
|||||||
<div className="absolute left-[34%] bottom-[12%] h-0.5 w-[32%] rounded bg-white/50" />
|
<div className="absolute left-[34%] bottom-[12%] h-0.5 w-[32%] rounded bg-white/50" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{(template === "LOWER_LEFT_COVER_TEXT" ||
|
||||||
|
template === "LOWER_RIGHT_COVER_TEXT") && (
|
||||||
|
<>
|
||||||
|
<div className="absolute bottom-[8%] left-[5%] h-[36%] w-[24%] bg-gray-300" />
|
||||||
|
<div className="absolute bottom-[26%] left-[34%] h-1 w-[40%] rounded bg-white/80" />
|
||||||
|
<div className="absolute bottom-[14%] left-[34%] h-0.5 w-[28%] rounded bg-white/50" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="px-2 py-1.5 text-[10px] font-medium text-gray-300">
|
<p className="px-2 py-1.5 text-[10px] font-medium text-gray-300">{label}</p>
|
||||||
{isClassic ? "Classic letterbox" : LAYOUT_TEMPLATE_LABELS[template]}
|
|
||||||
</p>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -192,8 +206,18 @@ function MiniThumb({
|
|||||||
function previewCoverStyle(
|
function previewCoverStyle(
|
||||||
template: LayoutTemplate,
|
template: LayoutTemplate,
|
||||||
padPct: number,
|
padPct: number,
|
||||||
|
textPadding: number,
|
||||||
|
encodeWidth: number,
|
||||||
): CSSProperties {
|
): CSSProperties {
|
||||||
const p = `${padPct}%`;
|
const p = `${padPct}%`;
|
||||||
|
// Equal pixel inset on both axes (not % - % of width ≠ % of height on 16:9).
|
||||||
|
const cornerEdgePx = scaleFontToPreview(textPadding, encodeWidth);
|
||||||
|
// Assume 16:9 when only width is known; side is usually width-driven for lower corner.
|
||||||
|
const encodeHeight = Math.round((encodeWidth * 9) / 16);
|
||||||
|
const cornerSidePx = scaleFontToPreview(
|
||||||
|
lowerCornerCoverSide(encodeWidth, encodeHeight, textPadding),
|
||||||
|
encodeWidth,
|
||||||
|
);
|
||||||
switch (template) {
|
switch (template) {
|
||||||
case "COVER_LEFT_TEXT_RIGHT":
|
case "COVER_LEFT_TEXT_RIGHT":
|
||||||
return {
|
return {
|
||||||
@ -235,6 +259,24 @@ function previewCoverStyle(
|
|||||||
maxHeight: "38%",
|
maxHeight: "38%",
|
||||||
objectFit: "contain",
|
objectFit: "contain",
|
||||||
};
|
};
|
||||||
|
case "LOWER_LEFT_COVER_TEXT":
|
||||||
|
return {
|
||||||
|
position: "absolute",
|
||||||
|
left: cornerEdgePx,
|
||||||
|
bottom: cornerEdgePx,
|
||||||
|
width: cornerSidePx,
|
||||||
|
height: cornerSidePx,
|
||||||
|
objectFit: "contain",
|
||||||
|
};
|
||||||
|
case "LOWER_RIGHT_COVER_TEXT":
|
||||||
|
return {
|
||||||
|
position: "absolute",
|
||||||
|
right: cornerEdgePx,
|
||||||
|
bottom: cornerEdgePx,
|
||||||
|
width: cornerSidePx,
|
||||||
|
height: cornerSidePx,
|
||||||
|
objectFit: "contain",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -244,11 +286,20 @@ function previewTextStyle(
|
|||||||
textOffsetX: number,
|
textOffsetX: number,
|
||||||
textOffsetY: number,
|
textOffsetY: number,
|
||||||
titleArtistGap: number,
|
titleArtistGap: number,
|
||||||
|
textPadding: number,
|
||||||
|
encodeWidth: number,
|
||||||
): CSSProperties {
|
): CSSProperties {
|
||||||
const p = `${padPct}%`;
|
const p = `${padPct}%`;
|
||||||
const shift = {
|
const cornerEdgePx = scaleFontToPreview(textPadding, encodeWidth);
|
||||||
transform: undefined as string | undefined,
|
const coverTextGapPx = scaleFontToPreview(
|
||||||
};
|
Math.max(10, Math.round(textPadding * 0.55)),
|
||||||
|
encodeWidth,
|
||||||
|
);
|
||||||
|
const encodeHeight = Math.round((encodeWidth * 9) / 16);
|
||||||
|
const cornerSidePx = scaleFontToPreview(
|
||||||
|
lowerCornerCoverSide(encodeWidth, encodeHeight, textPadding),
|
||||||
|
encodeWidth,
|
||||||
|
);
|
||||||
|
|
||||||
const baseGap = { display: "flex", flexDirection: "column" as const, gap: `${titleArtistGap}px` };
|
const baseGap = { display: "flex", flexDirection: "column" as const, gap: `${titleArtistGap}px` };
|
||||||
|
|
||||||
@ -295,13 +346,38 @@ function previewTextStyle(
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
};
|
};
|
||||||
|
case "LOWER_LEFT_COVER_TEXT":
|
||||||
|
return {
|
||||||
|
...baseGap,
|
||||||
|
position: "absolute",
|
||||||
|
left: cornerEdgePx + cornerSidePx + coverTextGapPx + textOffsetX,
|
||||||
|
right: p,
|
||||||
|
bottom: cornerEdgePx,
|
||||||
|
height: cornerSidePx,
|
||||||
|
justifyContent: "center",
|
||||||
|
textAlign: "left",
|
||||||
|
transform: textOffsetY ? `translateY(${textOffsetY}px)` : undefined,
|
||||||
|
};
|
||||||
|
case "LOWER_RIGHT_COVER_TEXT":
|
||||||
|
return {
|
||||||
|
...baseGap,
|
||||||
|
position: "absolute",
|
||||||
|
left: p,
|
||||||
|
right: cornerEdgePx + cornerSidePx + coverTextGapPx - textOffsetX,
|
||||||
|
bottom: cornerEdgePx,
|
||||||
|
height: cornerSidePx,
|
||||||
|
justifyContent: "center",
|
||||||
|
textAlign: "right",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
transform: textOffsetY ? `translateY(${textOffsetY}px)` : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
void shift;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LayoutStudio({
|
export function LayoutStudio({
|
||||||
locked,
|
locked,
|
||||||
previewImageUrl,
|
previewImageUrl,
|
||||||
|
previewBackgroundUrl = null,
|
||||||
songTitle,
|
songTitle,
|
||||||
artist,
|
artist,
|
||||||
encodeWidth = 1280,
|
encodeWidth = 1280,
|
||||||
@ -311,24 +387,32 @@ export function LayoutStudio({
|
|||||||
onWatermarkChange,
|
onWatermarkChange,
|
||||||
onUploadLogo,
|
onUploadLogo,
|
||||||
onUploadFont,
|
onUploadFont,
|
||||||
|
onUploadBackground,
|
||||||
|
onClearBackground,
|
||||||
logoPreviewUrl,
|
logoPreviewUrl,
|
||||||
|
hasCustomBackground = false,
|
||||||
|
onProRequired,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [logoUploading, setLogoUploading] = useState(false);
|
const [logoUploading, setLogoUploading] = useState(false);
|
||||||
const [logoError, setLogoError] = useState<string | null>(null);
|
const [logoError, setLogoError] = useState<string | null>(null);
|
||||||
const [fontUploading, setFontUploading] = useState(false);
|
const [fontUploading, setFontUploading] = useState(false);
|
||||||
const [fontError, setFontError] = useState<string | null>(null);
|
const [fontError, setFontError] = useState<string | null>(null);
|
||||||
const [customFontObjectUrl, setCustomFontObjectUrl] = useState<string | null>(null);
|
const [customFontObjectUrl, setCustomFontObjectUrl] = useState<string | null>(null);
|
||||||
|
const [bgUploading, setBgUploading] = useState(false);
|
||||||
|
const [bgError, setBgError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Always coalesce HMR / older session state may omit newly added fields
|
// Always coalesce HMR / older session state may omit newly added fields
|
||||||
const L: LayoutSettings = {
|
const L: LayoutSettings = {
|
||||||
...DEFAULT_LAYOUT,
|
...DEFAULT_LAYOUT,
|
||||||
...layout,
|
...layout,
|
||||||
|
blurFill: layout.blurFill ?? DEFAULT_LAYOUT.blurFill,
|
||||||
blurAmount: layout.blurAmount ?? DEFAULT_LAYOUT.blurAmount,
|
blurAmount: layout.blurAmount ?? DEFAULT_LAYOUT.blurAmount,
|
||||||
blurOpacity: layout.blurOpacity ?? BLUR_OPACITY_DEFAULT,
|
blurOpacity: layout.blurOpacity ?? BLUR_OPACITY_DEFAULT,
|
||||||
textPadding: layout.textPadding ?? DEFAULT_LAYOUT.textPadding,
|
textPadding: layout.textPadding ?? DEFAULT_LAYOUT.textPadding,
|
||||||
titleArtistGap: layout.titleArtistGap ?? DEFAULT_LAYOUT.titleArtistGap,
|
titleArtistGap: layout.titleArtistGap ?? DEFAULT_LAYOUT.titleArtistGap,
|
||||||
textOffsetX: layout.textOffsetX ?? DEFAULT_LAYOUT.textOffsetX,
|
textOffsetX: layout.textOffsetX ?? DEFAULT_LAYOUT.textOffsetX,
|
||||||
textOffsetY: layout.textOffsetY ?? DEFAULT_LAYOUT.textOffsetY,
|
textOffsetY: layout.textOffsetY ?? DEFAULT_LAYOUT.textOffsetY,
|
||||||
|
titleBold: layout.titleBold ?? DEFAULT_LAYOUT.titleBold,
|
||||||
};
|
};
|
||||||
const W: WatermarkSettings = {
|
const W: WatermarkSettings = {
|
||||||
...DEFAULT_WATERMARK,
|
...DEFAULT_WATERMARK,
|
||||||
@ -387,13 +471,34 @@ export function LayoutStudio({
|
|||||||
el.id = styleId;
|
el.id = styleId;
|
||||||
document.head.appendChild(el);
|
document.head.appendChild(el);
|
||||||
}
|
}
|
||||||
el.textContent = CURATED_FONTS.map(
|
el.textContent = [
|
||||||
(f) => `@font-face {
|
`@font-face {
|
||||||
font-family: 'S2VIDPreview-${f.key}';
|
font-family: '${SYSTEM_FONT.previewFamily}';
|
||||||
src: url('${curatedFontApiUrl(f.key)}') format('truetype');
|
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}') format('truetype');
|
||||||
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}`,
|
}`,
|
||||||
).join("\n");
|
`@font-face {
|
||||||
|
font-family: '${SYSTEM_FONT.previewFamily}';
|
||||||
|
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}?weight=bold') format('truetype');
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
}`,
|
||||||
|
`@font-face {
|
||||||
|
font-family: '${SYSTEM_FONT.previewFamily}';
|
||||||
|
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}?weight=bold') format('truetype');
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
}`,
|
||||||
|
...CURATED_FONTS.map(
|
||||||
|
(f) => `@font-face {
|
||||||
|
font-family: 'S2VIDPreview-${f.key}';
|
||||||
|
src: url('${curatedFontApiUrl(f.key)}') format('truetype');
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
}`,
|
||||||
|
),
|
||||||
|
].join("\n");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -420,12 +525,29 @@ export function LayoutStudio({
|
|||||||
}, [customFontObjectUrl]);
|
}, [customFontObjectUrl]);
|
||||||
|
|
||||||
function patchLayout(partial: Partial<LayoutSettings>) {
|
function patchLayout(partial: Partial<LayoutSettings>) {
|
||||||
if (locked) return;
|
if (!locked) {
|
||||||
onLayoutChange({ ...DEFAULT_LAYOUT, ...layout, ...partial });
|
onLayoutChange({ ...DEFAULT_LAYOUT, ...layout, ...partial });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Free / locked: classic letterbox blur only (no art-track template).
|
||||||
|
if (partial.template !== undefined && partial.template !== null) {
|
||||||
|
onProRequired?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const safe: Partial<LayoutSettings> = {};
|
||||||
|
if (partial.template === null) safe.template = null;
|
||||||
|
if (partial.blurFill !== undefined) safe.blurFill = partial.blurFill;
|
||||||
|
if (partial.blurAmount !== undefined) safe.blurAmount = partial.blurAmount;
|
||||||
|
if (partial.blurOpacity !== undefined) safe.blurOpacity = partial.blurOpacity;
|
||||||
|
if (Object.keys(safe).length === 0) return;
|
||||||
|
onLayoutChange({ ...DEFAULT_LAYOUT, ...layout, ...safe, template: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchWm(partial: Partial<WatermarkSettings>) {
|
function patchWm(partial: Partial<WatermarkSettings>) {
|
||||||
if (locked) return;
|
if (locked) {
|
||||||
|
onProRequired?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
onWatermarkChange({ ...DEFAULT_WATERMARK, ...watermark, ...partial });
|
onWatermarkChange({ ...DEFAULT_WATERMARK, ...watermark, ...partial });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -464,6 +586,21 @@ export function LayoutStudio({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleBackground(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (!file || locked || !onUploadBackground) return;
|
||||||
|
setBgError(null);
|
||||||
|
setBgUploading(true);
|
||||||
|
try {
|
||||||
|
await onUploadBackground(file);
|
||||||
|
} catch (err) {
|
||||||
|
setBgError(err instanceof Error ? err.message : "Background upload failed");
|
||||||
|
} finally {
|
||||||
|
setBgUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setFontKey(key: WatermarkFontKey) {
|
function setFontKey(key: WatermarkFontKey) {
|
||||||
if (key === "custom") {
|
if (key === "custom") {
|
||||||
patchWm({ fontKey: "custom", fontPath: watermark.fontPath ?? null });
|
patchWm({ fontKey: "custom", fontPath: watermark.fontPath ?? null });
|
||||||
@ -473,24 +610,23 @@ export function LayoutStudio({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const artTrack = Boolean(L.template);
|
const artTrack = Boolean(L.template);
|
||||||
|
const lowerCorner = isLowerCornerTemplate(L.template);
|
||||||
|
const blurPreviewUrl = previewBackgroundUrl || previewImageUrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative rounded-lg border border-gray-700 bg-surface p-6 ${
|
className={`relative rounded-lg border border-gray-700 bg-surface p-6`}
|
||||||
locked ? "opacity-80" : ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{locked && (
|
{locked && (
|
||||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 rounded-lg bg-black/55 px-4 text-center backdrop-blur-[1px]">
|
<div className="mb-4 rounded border border-accent/40 bg-accent/10 px-3 py-2 text-sm text-gray-200">
|
||||||
<span className="rounded border border-accent/50 bg-accent/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-accent">
|
<span className="font-medium text-accent">Pro unlocks</span> art-track
|
||||||
Pro
|
layouts, custom backgrounds, typography, and watermark studio.{" "}
|
||||||
</span>
|
<span className="text-gray-300">
|
||||||
<p className="max-w-sm text-sm text-gray-200">
|
Classic blurred letterbox (blur amount / opacity) works on Free.
|
||||||
Art-track layouts, blur backgrounds, typography, and watermark studio are Pro features.
|
</span>{" "}
|
||||||
</p>
|
|
||||||
<UpgradeProButton
|
<UpgradeProButton
|
||||||
label="Unlock video layout studio"
|
label="Upgrade"
|
||||||
className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
|
className="ml-1 inline text-sm font-medium text-accent underline hover:text-accent-hover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -504,8 +640,8 @@ export function LayoutStudio({
|
|||||||
|
|
||||||
{/* Single live preview: art-track + watermark */}
|
{/* Single live preview: art-track + watermark */}
|
||||||
<div
|
<div
|
||||||
className="relative mx-auto aspect-video w-full max-w-xl overflow-hidden rounded border border-gray-800 bg-black"
|
className="relative mx-auto aspect-video w-full max-w-xl overflow-hidden rounded border border-gray-800 bg-[#0a0c10]"
|
||||||
aria-hidden={locked}
|
aria-hidden={false}
|
||||||
>
|
>
|
||||||
{previewImageUrl ? (
|
{previewImageUrl ? (
|
||||||
<>
|
<>
|
||||||
@ -514,7 +650,7 @@ export function LayoutStudio({
|
|||||||
<div className="absolute inset-0 bg-black" />
|
<div className="absolute inset-0 bg-black" />
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={previewImageUrl}
|
src={blurPreviewUrl!}
|
||||||
alt=""
|
alt=""
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
style={{
|
style={{
|
||||||
@ -527,7 +663,12 @@ export function LayoutStudio({
|
|||||||
<img
|
<img
|
||||||
src={previewImageUrl}
|
src={previewImageUrl}
|
||||||
alt=""
|
alt=""
|
||||||
style={previewCoverStyle(L.template!, padPct)}
|
style={previewCoverStyle(
|
||||||
|
L.template!,
|
||||||
|
padPct,
|
||||||
|
L.textPadding,
|
||||||
|
encodeWidth,
|
||||||
|
)}
|
||||||
className="pointer-events-none"
|
className="pointer-events-none"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@ -537,12 +678,17 @@ export function LayoutStudio({
|
|||||||
L.textOffsetX,
|
L.textOffsetX,
|
||||||
L.textOffsetY,
|
L.textOffsetY,
|
||||||
L.titleArtistGap,
|
L.titleArtistGap,
|
||||||
|
L.textPadding,
|
||||||
|
encodeWidth,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
className="max-w-full truncate font-semibold text-white drop-shadow"
|
className={`max-w-full truncate text-white drop-shadow ${
|
||||||
|
L.titleBold ? "font-semibold" : "font-normal"
|
||||||
|
}`}
|
||||||
style={{
|
style={{
|
||||||
...textFontStyle,
|
...textFontStyle,
|
||||||
|
fontWeight: L.titleBold ? 600 : 400,
|
||||||
fontSize: `${titlePreviewPx}px`,
|
fontSize: `${titlePreviewPx}px`,
|
||||||
lineHeight: 1.15,
|
lineHeight: 1.15,
|
||||||
}}
|
}}
|
||||||
@ -562,13 +708,28 @@ export function LayoutStudio({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black">
|
<div className="absolute inset-0 bg-black">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{L.blurFill && (
|
||||||
<img
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
src={previewImageUrl}
|
<img
|
||||||
alt=""
|
src={previewImageUrl}
|
||||||
className="max-h-full max-w-full object-contain"
|
alt=""
|
||||||
/>
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
style={{
|
||||||
|
filter: L.blurAmount > 0 ? `blur(${blurPx}px)` : undefined,
|
||||||
|
transform: "scale(1.15)",
|
||||||
|
opacity: L.blurOpacity / 100,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={previewImageUrl}
|
||||||
|
alt=""
|
||||||
|
className="max-h-full max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -605,8 +766,40 @@ export function LayoutStudio({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-gray-600">
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
Upload a cover image to preview
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"radial-gradient(ellipse at 30% 40%, rgba(74,158,255,0.35), transparent 55%), radial-gradient(ellipse at 70% 60%, rgba(239,68,68,0.22), transparent 50%), linear-gradient(135deg, #12151c 0%, #0a0c10 50%, #151820 100%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-[18%] flex h-[28%] items-end justify-center gap-[3px] px-8 opacity-70"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{Array.from({ length: 48 }).map((_, i) => {
|
||||||
|
const h = 18 + ((i * 17) % 55);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="w-[3px] rounded-full bg-accent/50"
|
||||||
|
style={{ height: `${h}%` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 px-6 pb-[22%] text-center">
|
||||||
|
<div className="flex h-20 w-20 items-center justify-center rounded-lg border border-white/10 bg-white/5 shadow-inner">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-gray-400">
|
||||||
|
Cover
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-gray-300">Preview template</p>
|
||||||
|
<p className="max-w-xs text-xs text-gray-500">
|
||||||
|
Drop a cover image to see your layout live
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -614,22 +807,125 @@ export function LayoutStudio({
|
|||||||
{/* Art-track templates */}
|
{/* Art-track templates */}
|
||||||
<p className="mb-2 mt-5 text-sm font-medium text-gray-300">Composition</p>
|
<p className="mb-2 mt-5 text-sm font-medium text-gray-300">Composition</p>
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
<MiniThumb
|
{COMPOSITION_FAMILIES.map((family) => {
|
||||||
template={null}
|
const active =
|
||||||
active={L.template === null}
|
family.id === "classic"
|
||||||
disabled={locked}
|
? L.template === null
|
||||||
onClick={() => patchLayout({ template: null })}
|
: family.variants.includes(L.template as LayoutTemplate);
|
||||||
/>
|
return (
|
||||||
{LAYOUT_TEMPLATES.map((t) => (
|
<MiniThumb
|
||||||
<MiniThumb
|
key={family.id}
|
||||||
key={t}
|
template={family.thumb}
|
||||||
template={t}
|
label={family.label}
|
||||||
active={L.template === t}
|
active={active}
|
||||||
disabled={locked}
|
proLocked={locked && family.id !== "classic"}
|
||||||
onClick={() => patchLayout({ template: t })}
|
onClick={() => {
|
||||||
/>
|
if (family.id === "classic") {
|
||||||
))}
|
patchLayout({ template: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (locked) {
|
||||||
|
onProRequired?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
L.template &&
|
||||||
|
family.variants.includes(L.template as LayoutTemplate)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
patchLayout({ template: family.defaultTemplate, blurFill: false });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const family = compositionFamilyForTemplate(L.template);
|
||||||
|
if (family.variants.length < 2) return null;
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{family.variants.map((variant) => (
|
||||||
|
<button
|
||||||
|
key={variant}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (locked) {
|
||||||
|
onProRequired?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
patchLayout({ template: variant });
|
||||||
|
}}
|
||||||
|
className={`rounded border px-2.5 py-1.5 text-[11px] font-medium transition-colors ${
|
||||||
|
L.template === variant
|
||||||
|
? "border-accent bg-accent/15 text-white"
|
||||||
|
: locked
|
||||||
|
? "cursor-pointer border-gray-700 bg-black/40 text-gray-500 opacity-60"
|
||||||
|
: "border-gray-700 bg-black/40 text-gray-300 hover:border-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{LAYOUT_TEMPLATE_LABELS[variant]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{!artTrack && (
|
||||||
|
<label className="mt-3 flex cursor-pointer items-start gap-2 text-sm text-gray-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5"
|
||||||
|
disabled={false}
|
||||||
|
checked={L.blurFill}
|
||||||
|
onChange={(e) => patchLayout({ blurFill: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium text-gray-200">Blurred cover background</span>
|
||||||
|
<span className="mt-0.5 block text-xs text-gray-500">
|
||||||
|
Fill letterbox bars with a blurred cover instead of black. Adjust blur and opacity
|
||||||
|
below when enabled.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lowerCorner && (
|
||||||
|
<div className="mt-3 rounded border border-gray-700 bg-black/30 p-3">
|
||||||
|
<p className="mb-2 text-sm font-medium text-gray-300">Background image</p>
|
||||||
|
<p className="mb-2 text-xs text-gray-500">
|
||||||
|
Optional. Used as the blurred full-frame fill; the cover stays the sharp corner square.
|
||||||
|
Leave empty to blur the cover itself.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<label
|
||||||
|
className={`cursor-pointer rounded border border-gray-600 bg-surface-dark px-3 py-1.5 text-xs text-gray-200 hover:border-gray-400 ${
|
||||||
|
locked || bgUploading ? "pointer-events-none opacity-40" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{bgUploading ? "Uploading..." : hasCustomBackground ? "Replace image" : "Upload image"}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
disabled={locked || bgUploading || !onUploadBackground}
|
||||||
|
onChange={handleBackground}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{hasCustomBackground && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={locked}
|
||||||
|
onClick={() => onClearBackground?.()}
|
||||||
|
className="rounded border border-gray-700 px-3 py-1.5 text-xs text-gray-400 hover:border-gray-500 hover:text-gray-200 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Use cover as background
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{bgError && <p className="mt-2 text-xs text-red-400">{bgError}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
<label className="text-sm text-gray-400">
|
<label className="text-sm text-gray-400">
|
||||||
@ -638,7 +934,7 @@ export function LayoutStudio({
|
|||||||
type="range"
|
type="range"
|
||||||
min={BLUR_AMOUNT_MIN}
|
min={BLUR_AMOUNT_MIN}
|
||||||
max={BLUR_AMOUNT_MAX}
|
max={BLUR_AMOUNT_MAX}
|
||||||
disabled={locked || !artTrack}
|
disabled={artTrack ? locked : !L.blurFill}
|
||||||
value={L.blurAmount}
|
value={L.blurAmount}
|
||||||
onChange={(e) => patchLayout({ blurAmount: Number(e.target.value) })}
|
onChange={(e) => patchLayout({ blurAmount: Number(e.target.value) })}
|
||||||
className="mt-1 w-full disabled:opacity-40"
|
className="mt-1 w-full disabled:opacity-40"
|
||||||
@ -650,7 +946,7 @@ export function LayoutStudio({
|
|||||||
type="range"
|
type="range"
|
||||||
min={BLUR_OPACITY_MIN}
|
min={BLUR_OPACITY_MIN}
|
||||||
max={BLUR_OPACITY_MAX}
|
max={BLUR_OPACITY_MAX}
|
||||||
disabled={locked || !artTrack}
|
disabled={artTrack ? locked : !L.blurFill}
|
||||||
value={L.blurOpacity}
|
value={L.blurOpacity}
|
||||||
onChange={(e) => patchLayout({ blurOpacity: Number(e.target.value) })}
|
onChange={(e) => patchLayout({ blurOpacity: Number(e.target.value) })}
|
||||||
className="mt-1 w-full disabled:opacity-40"
|
className="mt-1 w-full disabled:opacity-40"
|
||||||
@ -706,6 +1002,20 @@ export function LayoutStudio({
|
|||||||
className="mt-1 w-full disabled:opacity-40"
|
className="mt-1 w-full disabled:opacity-40"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label
|
||||||
|
className={`flex items-center gap-2 text-sm text-gray-300 sm:col-span-2 ${
|
||||||
|
locked || !artTrack ? "opacity-40" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-gray-600 bg-surface-dark text-accent focus:ring-accent"
|
||||||
|
disabled={locked || !artTrack}
|
||||||
|
checked={L.titleBold}
|
||||||
|
onChange={(e) => patchLayout({ titleBold: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Bold song title
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Typography (matches FFmpeg art-track + watermark text fonts) */}
|
{/* Typography (matches FFmpeg art-track + watermark text fonts) */}
|
||||||
@ -719,7 +1029,7 @@ export function LayoutStudio({
|
|||||||
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
|
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
|
||||||
className="input-field mt-1"
|
className="input-field mt-1"
|
||||||
>
|
>
|
||||||
<option value="system">Arial (system default)</option>
|
<option value="system">{SYSTEM_FONT.label}</option>
|
||||||
{CURATED_FONTS.map((f) => (
|
{CURATED_FONTS.map((f) => (
|
||||||
<option key={f.key} value={f.key}>
|
<option key={f.key} value={f.key}>
|
||||||
{f.label}
|
{f.label}
|
||||||
@ -741,7 +1051,7 @@ export function LayoutStudio({
|
|||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
{fontUploading && (
|
{fontUploading && (
|
||||||
<p className="mt-1 text-xs text-yellow-400">Uploading font…</p>
|
<p className="mt-1 text-xs text-yellow-400">Uploading font...</p>
|
||||||
)}
|
)}
|
||||||
{fontError && <p className="mt-1 text-xs text-red-400">{fontError}</p>}
|
{fontError && <p className="mt-1 text-xs text-red-400">{fontError}</p>}
|
||||||
</div>
|
</div>
|
||||||
@ -815,7 +1125,7 @@ export function LayoutStudio({
|
|||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
{logoUploading && (
|
{logoUploading && (
|
||||||
<p className="mt-1 text-xs text-yellow-400">Uploading logo…</p>
|
<p className="mt-1 text-xs text-yellow-400">Uploading logo...</p>
|
||||||
)}
|
)}
|
||||||
{logoError && <p className="mt-1 text-xs text-red-400">{logoError}</p>}
|
{logoError && <p className="mt-1 text-xs text-red-400">{logoError}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
201
components/MediaDropzone.tsx
Normal file
201
components/MediaDropzone.tsx
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif"]);
|
||||||
|
const AUDIO_EXTS = new Set([".mp3", ".wav", ".flac", ".m4a", ".aac", ".ogg"]);
|
||||||
|
|
||||||
|
export function splitMediaFiles(files: FileList | File[]): {
|
||||||
|
images: File[];
|
||||||
|
audios: File[];
|
||||||
|
ignored: string[];
|
||||||
|
} {
|
||||||
|
const images: File[] = [];
|
||||||
|
const audios: File[] = [];
|
||||||
|
const ignored: string[] = [];
|
||||||
|
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
const name = file.name.toLowerCase();
|
||||||
|
const dot = name.lastIndexOf(".");
|
||||||
|
const ext = dot >= 0 ? name.slice(dot) : "";
|
||||||
|
const mime = file.type.toLowerCase();
|
||||||
|
|
||||||
|
if (IMAGE_EXTS.has(ext) || mime.startsWith("image/")) {
|
||||||
|
images.push(file);
|
||||||
|
} else if (AUDIO_EXTS.has(ext) || mime.startsWith("audio/")) {
|
||||||
|
audios.push(file);
|
||||||
|
} else {
|
||||||
|
ignored.push(file.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { images, audios, ignored };
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
disabled?: boolean;
|
||||||
|
acceptAudio: string;
|
||||||
|
onFiles: (payload: { images: File[]; audios: File[] }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const IMAGE_ACCEPT = "image/jpeg,image/png,image/webp,image/gif,.jpg,.jpeg,.png,.webp,.gif";
|
||||||
|
|
||||||
|
function FileHitArea({
|
||||||
|
disabled,
|
||||||
|
accept,
|
||||||
|
multiple,
|
||||||
|
onFiles,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
disabled?: boolean;
|
||||||
|
accept: string;
|
||||||
|
multiple?: boolean;
|
||||||
|
onFiles: (list: FileList | null) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className ?? ""}`}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple={multiple}
|
||||||
|
accept={accept}
|
||||||
|
disabled={disabled}
|
||||||
|
/* Direct tap on the native input — required on iOS; hidden/sr-only + label often no-ops. */
|
||||||
|
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
|
||||||
|
style={{ fontSize: 16 }}
|
||||||
|
onChange={(e) => {
|
||||||
|
onFiles(e.target.files);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="pointer-events-none">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile: two full-size native file inputs (cover vs audio). Mixed accept + hidden
|
||||||
|
* inputs commonly fail to open the picker in iOS Safari / in-app browsers.
|
||||||
|
* Desktop: one drop zone with overlay input + drag-and-drop.
|
||||||
|
*/
|
||||||
|
export function MediaDropzone({ disabled, acceptAudio, onFiles }: Props) {
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
|
const handleFiles = useCallback(
|
||||||
|
(list: FileList | File[] | null) => {
|
||||||
|
if (!list || disabled) return;
|
||||||
|
const { images, audios, ignored } = splitMediaFiles(list);
|
||||||
|
if (images.length || audios.length) {
|
||||||
|
onFiles({ images, audios });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ignored.length) {
|
||||||
|
onFiles({ images: [], audios: [] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[disabled, onFiles],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dropClass = disabled
|
||||||
|
? "cursor-not-allowed border-gray-800 bg-surface-light/20 opacity-60"
|
||||||
|
: dragging
|
||||||
|
? "border-accent bg-accent/10"
|
||||||
|
: "border-gray-600 bg-surface-light/30 hover:border-accent/60 hover:bg-accent/[0.06]";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid gap-3 sm:hidden">
|
||||||
|
<FileHitArea
|
||||||
|
disabled={disabled}
|
||||||
|
accept={IMAGE_ACCEPT}
|
||||||
|
onFiles={(list) => handleFiles(list)}
|
||||||
|
>
|
||||||
|
<div className={`rounded-xl border-2 border-dashed px-5 py-8 text-center ${dropClass}`}>
|
||||||
|
<p className="text-base font-medium text-gray-200">Tap to add cover image</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">JPG, PNG, WebP, or GIF</p>
|
||||||
|
</div>
|
||||||
|
</FileHitArea>
|
||||||
|
<FileHitArea
|
||||||
|
disabled={disabled}
|
||||||
|
accept={acceptAudio}
|
||||||
|
multiple
|
||||||
|
onFiles={(list) => handleFiles(list)}
|
||||||
|
>
|
||||||
|
<div className={`rounded-xl border-2 border-dashed px-5 py-8 text-center ${dropClass}`}>
|
||||||
|
<p className="text-base font-medium text-gray-200">Tap to add audio</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Free: MP3. Pro: WAV / FLAC / AAC / OGG
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</FileHitArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div
|
||||||
|
onDragEnter={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!disabled) setDragging(true);
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!disabled) setDragging(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragging(false);
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragging(false);
|
||||||
|
handleFiles(e.dataTransfer.files);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileHitArea
|
||||||
|
disabled={disabled}
|
||||||
|
accept={`${IMAGE_ACCEPT},${acceptAudio}`}
|
||||||
|
multiple
|
||||||
|
onFiles={(list) => handleFiles(list)}
|
||||||
|
>
|
||||||
|
<div className={`rounded-xl border-2 border-dashed px-6 py-10 text-center ${dropClass}`}>
|
||||||
|
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full border border-gray-700 bg-surface text-accent">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="h-7 w-7"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 16V4m0 0 4 4m-4-4-4 4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4 14v2.5A3.5 3.5 0 0 0 7.5 20h9a3.5 3.5 0 0 0 3.5-3.5V14"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="mx-auto max-w-md text-base font-medium leading-relaxed text-gray-200">
|
||||||
|
Drag & drop audio and a cover image, or tap to browse
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-xs text-gray-500">
|
||||||
|
One cover and one or more tracks. Free: MP3 ≤50 MB. Pro: WAV/FLAC ≤500 MB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</FileHitArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -38,6 +38,7 @@ import {
|
|||||||
import type { SharePlatform } from "@/lib/share-verify";
|
import type { SharePlatform } from "@/lib/share-verify";
|
||||||
import { VERIFY_FAIL_MESSAGE } from "@/lib/share-verify";
|
import { VERIFY_FAIL_MESSAGE } from "@/lib/share-verify";
|
||||||
import type { JobDeliveryMode } from "@/lib/types";
|
import type { JobDeliveryMode } from "@/lib/types";
|
||||||
|
import { createClientId } from "@/lib/client-id";
|
||||||
|
|
||||||
const UPLOAD_CONCURRENCY = 4;
|
const UPLOAD_CONCURRENCY = 4;
|
||||||
const SHARE_UNLOCK_TOKEN_KEY = "s2vid_share_download_unlock_token_v1";
|
const SHARE_UNLOCK_TOKEN_KEY = "s2vid_share_download_unlock_token_v1";
|
||||||
@ -142,7 +143,7 @@ function defaultMetadata(title = ""): ItemMetadata {
|
|||||||
|
|
||||||
export function UploadForm() {
|
export function UploadForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const uploadSessionRef = useRef<string>(crypto.randomUUID());
|
const uploadSessionRef = useRef<string>(createClientId());
|
||||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||||
const [imagePath, setImagePath] = useState<string | null>(null);
|
const [imagePath, setImagePath] = useState<string | null>(null);
|
||||||
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
|
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
|
||||||
@ -265,7 +266,7 @@ export function UploadForm() {
|
|||||||
|
|
||||||
async function uploadFilesWithConcurrency(files: File[]) {
|
async function uploadFilesWithConcurrency(files: File[]) {
|
||||||
const pending = files.map((file) => {
|
const pending = files.map((file) => {
|
||||||
const id = crypto.randomUUID();
|
const id = createClientId();
|
||||||
const autoTitle = filenameWithoutExtension(file.name);
|
const autoTitle = filenameWithoutExtension(file.name);
|
||||||
|
|
||||||
setAudioItems((prev) => [
|
setAudioItems((prev) => [
|
||||||
@ -349,6 +350,10 @@ export function UploadForm() {
|
|||||||
|
|
||||||
async function ingestMediaFiles(images: File[], audios: File[]) {
|
async function ingestMediaFiles(images: File[], audios: File[]) {
|
||||||
setError(null);
|
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) {
|
if (images.length > 1) {
|
||||||
setError("Only one cover image is used per batch - using the first image.");
|
setError("Only one cover image is used per batch - using the first image.");
|
||||||
}
|
}
|
||||||
@ -403,7 +408,7 @@ export function UploadForm() {
|
|||||||
});
|
});
|
||||||
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
|
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
|
||||||
|
|
||||||
const id = crypto.randomUUID();
|
const id = createClientId();
|
||||||
setAudioItems([
|
setAudioItems([
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@ -628,6 +633,44 @@ export function UploadForm() {
|
|||||||
) &&
|
) &&
|
||||||
!submitting;
|
!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) {
|
function buildJobBody(delivery: JobDeliveryMode, unlockTokenOverride?: string | null) {
|
||||||
if (!imagePath) return null;
|
if (!imagePath) return null;
|
||||||
const forcedWatermark = !isPro && !quota?.selfHosted;
|
const forcedWatermark = !isPro && !quota?.selfHosted;
|
||||||
@ -918,7 +961,7 @@ export function UploadForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleRenderPress} className="space-y-6">
|
||||||
<RenderChoiceModal
|
<RenderChoiceModal
|
||||||
open={choiceOpen && !isPro}
|
open={choiceOpen && !isPro}
|
||||||
shareUnlocked={shareUnlocked}
|
shareUnlocked={shareUnlocked}
|
||||||
@ -1067,6 +1110,11 @@ export function UploadForm() {
|
|||||||
onClearBackground={clearBackgroundImage}
|
onClearBackground={clearBackgroundImage}
|
||||||
hasCustomBackground={Boolean(backgroundImagePath)}
|
hasCustomBackground={Boolean(backgroundImagePath)}
|
||||||
logoPreviewUrl={logoPreviewUrl}
|
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 && (
|
{audioItems.length > 0 && (
|
||||||
@ -1299,8 +1347,10 @@ export function UploadForm() {
|
|||||||
{!hasSelectedMedia && (
|
{!hasSelectedMedia && (
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!canSubmit}
|
aria-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"
|
className={`w-full rounded-lg bg-accent px-6 py-3.5 text-base font-semibold text-white transition-colors hover:bg-accent-hover ${
|
||||||
|
!canSubmit ? "cursor-pointer opacity-50" : ""
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{submitting
|
{submitting
|
||||||
? "Creating videos..."
|
? "Creating videos..."
|
||||||
@ -1325,8 +1375,10 @@ export function UploadForm() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!canSubmit}
|
aria-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"
|
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 sm:w-auto ${
|
||||||
|
!canSubmit ? "cursor-pointer opacity-50" : ""
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{submitting
|
{submitting
|
||||||
? "Creating videos..."
|
? "Creating videos..."
|
||||||
|
|||||||
7
lib/client-id.ts
Normal file
7
lib/client-id.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/** UUID for client-side ids (upload sessions, list keys). Safe in older WebViews. */
|
||||||
|
export function createClientId(): string {
|
||||||
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user