mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-21 22:18:12 +02:00
feat: add logo watermark, ending slide, 25-30s duration, and word-sync text
- Video duration forced to 25-30s (content 22-27s + 3s ending) - Logo watermark (bottom-right) visible throughout content section - Ending slide: logo centered + store name with spring animation (3s) - Text overlay now shows only the active spoken word (true TikTok sync) - Server fetches logo from Cloudinary config and store name from user's pages - Player preview also shows logo/storeName via /api/cloudinary/config + /api/pages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
57c18e24df
commit
9145bc5941
4 files changed
+250
-100
No files matched your search
@@ -61,6 +61,13 @@ export default function MobileRemotionVideoPage() {
|
||||
|
||||
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ['/api/media'] });
|
||||
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
|
||||
const { data: cloudinaryConfig } = useQuery<{ cloudName?: string; logoPublicId?: string } | null>({ queryKey: ['/api/cloudinary/config'] });
|
||||
const { data: socialPages = [] } = useQuery<{ pageName: string }[]>({ queryKey: ['/api/pages'] });
|
||||
|
||||
const previewLogoUrl = cloudinaryConfig?.cloudName && cloudinaryConfig?.logoPublicId
|
||||
? `https://res.cloudinary.com/${cloudinaryConfig.cloudName}/image/upload/${cloudinaryConfig.logoPublicId}`
|
||||
: undefined;
|
||||
const previewStoreName = (socialPages[0] as any)?.pageName as string | undefined;
|
||||
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
|
||||
|
||||
const generateTextMutation = useMutation({
|
||||
@@ -90,11 +97,13 @@ export default function MobileRemotionVideoPage() {
|
||||
|
||||
const totalSelected = images.length + selectedLibraryImages.length;
|
||||
const previewWordTimings = useMemo(() => overlayText ? estimateWordTimings(overlayText) : undefined, [overlayText]);
|
||||
const ENDING_FRAMES = (previewLogoUrl || previewStoreName) ? 90 : 0;
|
||||
const totalFrames = useMemo(() => {
|
||||
if (previewWordTimings?.length)
|
||||
return Math.max(combinedUrls.length * 3 * 30, previewWordTimings[previewWordTimings.length - 1].endFrame + 30);
|
||||
return combinedUrls.length > 0 ? combinedUrls.length * 3 * 30 : 300;
|
||||
}, [previewWordTimings, combinedUrls]);
|
||||
const ttsEnd = previewWordTimings?.length ? previewWordTimings[previewWordTimings.length - 1].endFrame : 0;
|
||||
const naturalContent = Math.max(ttsEnd / 30, combinedUrls.length * 3);
|
||||
const contentSeconds = Math.min(Math.max(naturalContent, 22), 27);
|
||||
return Math.round(contentSeconds * 30) + ENDING_FRAMES;
|
||||
}, [previewWordTimings, combinedUrls, ENDING_FRAMES]);
|
||||
|
||||
const handleTtsPreview = async () => {
|
||||
const ttsText = stripForTTS(overlayText);
|
||||
@@ -297,7 +306,14 @@ export default function MobileRemotionVideoPage() {
|
||||
{combinedUrls.length > 0 ? (
|
||||
<Player
|
||||
component={ImageComposition}
|
||||
inputProps={{ images: combinedUrls, overlayText: overlayText || undefined, wordTimings: previewWordTimings }}
|
||||
inputProps={{
|
||||
images: combinedUrls,
|
||||
overlayText: overlayText || undefined,
|
||||
wordTimings: previewWordTimings,
|
||||
logoUrl: previewLogoUrl,
|
||||
storeName: previewStoreName,
|
||||
endingFrames: ENDING_FRAMES,
|
||||
}}
|
||||
durationInFrames={totalFrames}
|
||||
compositionWidth={1080}
|
||||
compositionHeight={1920}
|
||||
|
||||
@@ -72,6 +72,17 @@ export default function RemotionVideoPage() {
|
||||
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({
|
||||
queryKey: ['/api/audio-tracks'],
|
||||
});
|
||||
const { data: cloudinaryConfig } = useQuery<{ cloudName?: string; logoPublicId?: string } | null>({
|
||||
queryKey: ['/api/cloudinary/config'],
|
||||
});
|
||||
const { data: socialPages = [] } = useQuery<{ pageName: string }[]>({
|
||||
queryKey: ['/api/pages'],
|
||||
});
|
||||
|
||||
const previewLogoUrl = cloudinaryConfig?.cloudName && cloudinaryConfig?.logoPublicId
|
||||
? `https://res.cloudinary.com/${cloudinaryConfig.cloudName}/image/upload/${cloudinaryConfig.logoPublicId}`
|
||||
: undefined;
|
||||
const previewStoreName = (socialPages[0] as any)?.pageName as string | undefined;
|
||||
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
|
||||
|
||||
const generateTextMutation = useMutation({
|
||||
@@ -109,12 +120,15 @@ export default function RemotionVideoPage() {
|
||||
|
||||
const previewWordTimings = useMemo(() => overlayText ? estimateWordTimings(overlayText) : undefined, [overlayText]);
|
||||
|
||||
const ENDING_FRAMES = (previewLogoUrl || previewStoreName) ? 90 : 0;
|
||||
const totalFrames = useMemo(() => {
|
||||
if (previewWordTimings?.length) {
|
||||
return Math.max(combinedUrls.length * 3 * 30, previewWordTimings[previewWordTimings.length - 1].endFrame + 30);
|
||||
}
|
||||
return combinedUrls.length > 0 ? combinedUrls.length * 3 * 30 : 300;
|
||||
}, [previewWordTimings, combinedUrls]);
|
||||
const ttsEnd = previewWordTimings?.length
|
||||
? previewWordTimings[previewWordTimings.length - 1].endFrame
|
||||
: 0;
|
||||
const naturalContent = Math.max(ttsEnd / 30, combinedUrls.length * 3);
|
||||
const contentSeconds = Math.min(Math.max(naturalContent, 22), 27);
|
||||
return Math.round(contentSeconds * 30) + ENDING_FRAMES;
|
||||
}, [previewWordTimings, combinedUrls, ENDING_FRAMES]);
|
||||
|
||||
const handleTtsPreview = async () => {
|
||||
if (!overlayText) return;
|
||||
@@ -368,7 +382,14 @@ export default function RemotionVideoPage() {
|
||||
{combinedUrls.length > 0 ? (
|
||||
<Player
|
||||
component={ImageComposition}
|
||||
inputProps={{ images: combinedUrls, overlayText: overlayText || undefined, wordTimings: previewWordTimings }}
|
||||
inputProps={{
|
||||
images: combinedUrls,
|
||||
overlayText: overlayText || undefined,
|
||||
wordTimings: previewWordTimings,
|
||||
logoUrl: previewLogoUrl,
|
||||
storeName: previewStoreName,
|
||||
endingFrames: ENDING_FRAMES,
|
||||
}}
|
||||
durationInFrames={totalFrames}
|
||||
compositionWidth={1080}
|
||||
compositionHeight={1920}
|
||||
|
||||
@@ -13,6 +13,9 @@ export type ImageCompositionProps = {
|
||||
wordTimings?: WordTiming[];
|
||||
musicUrl?: string;
|
||||
musicVolume?: number;
|
||||
logoUrl?: string;
|
||||
storeName?: string;
|
||||
endingFrames?: number; // frames reserved for ending slide (default 90 = 3s at 30fps)
|
||||
};
|
||||
|
||||
// 6 modern, simple entrance transitions
|
||||
@@ -77,11 +80,26 @@ const ImageSlide: React.FC<{ src: string; transition: Transition }> = ({ src, tr
|
||||
};
|
||||
|
||||
export const ImageComposition = ({
|
||||
images, overlayText, audioUrl, wordTimings, musicUrl, musicVolume = 0.3
|
||||
images, overlayText, audioUrl, wordTimings, musicUrl, musicVolume = 0.3,
|
||||
logoUrl, storeName, endingFrames = 90,
|
||||
}: ImageCompositionProps) => {
|
||||
const { fps } = useVideoConfig();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
const frame = useCurrentFrame();
|
||||
const durationPerImage = 3 * fps;
|
||||
|
||||
// Reserve last endingFrames for the ending slide; spread images across the rest
|
||||
const hasEnding = !!(logoUrl || storeName);
|
||||
const effectiveEndingFrames = hasEnding ? endingFrames : 0;
|
||||
const contentFrames = durationInFrames - effectiveEndingFrames;
|
||||
const durationPerImage = Math.floor(contentFrames / Math.max(images.length, 1));
|
||||
const endingStart = contentFrames;
|
||||
|
||||
// Ending slide spring animation
|
||||
const endingProgress = spring({
|
||||
frame: frame - endingStart,
|
||||
fps,
|
||||
config: { damping: 20, stiffness: 80 },
|
||||
durationInFrames: 30,
|
||||
});
|
||||
|
||||
const activeWordIdx = wordTimings
|
||||
? wordTimings.findIndex(w => frame >= w.startFrame && frame < w.endFrame)
|
||||
@@ -102,68 +120,132 @@ export const ImageComposition = ({
|
||||
{/* Background music */}
|
||||
{musicUrl && <Html5Audio src={musicUrl} volume={musicVolume} />}
|
||||
|
||||
{/* TikTok-style word-by-word text overlay */}
|
||||
{wordTimings && wordTimings.length > 0 ? (
|
||||
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 120 }}>
|
||||
<div
|
||||
{/* TikTok-style word-by-word text overlay (content section only) */}
|
||||
{frame < endingStart && (
|
||||
wordTimings && wordTimings.length > 0 ? (
|
||||
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 160 }}>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "88%",
|
||||
textAlign: "center",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "center",
|
||||
gap: "0 14px",
|
||||
alignItems: "baseline",
|
||||
filter: "drop-shadow(0 2px 12px rgba(0,0,0,0.7))",
|
||||
}}
|
||||
>
|
||||
{wordTimings.map((w, i) => {
|
||||
const isActive = i === activeWordIdx;
|
||||
if (!isActive) return null;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
color: "#FFE600",
|
||||
fontFamily: "'Arial Black', 'Impact', sans-serif",
|
||||
fontSize: 80,
|
||||
fontWeight: 900,
|
||||
lineHeight: 1.2,
|
||||
WebkitTextStroke: "3px rgba(0,0,0,0.8)",
|
||||
textShadow: "0 4px 20px rgba(0,0,0,0.9)",
|
||||
display: "inline-block",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{w.word}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
) : overlayText ? (
|
||||
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 160 }}>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
borderRadius: 16,
|
||||
padding: "24px 32px",
|
||||
color: "white",
|
||||
fontSize: 60,
|
||||
fontWeight: "bold",
|
||||
fontFamily: "sans-serif",
|
||||
textAlign: "center",
|
||||
lineHeight: 1.4,
|
||||
maxWidth: "90%",
|
||||
WebkitTextStroke: "1px rgba(0,0,0,0.4)",
|
||||
}}
|
||||
>
|
||||
{overlayText}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
) : null
|
||||
)}
|
||||
|
||||
{/* Logo watermark — bottom right, always visible during content */}
|
||||
{logoUrl && frame < endingStart && (
|
||||
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "flex-end", padding: 48 }}>
|
||||
<Img
|
||||
src={logoUrl}
|
||||
style={{
|
||||
maxWidth: "88%",
|
||||
textAlign: "center",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "center",
|
||||
gap: "0 14px",
|
||||
alignItems: "baseline",
|
||||
filter: "drop-shadow(0 2px 12px rgba(0,0,0,0.7))",
|
||||
}}
|
||||
>
|
||||
{wordTimings.map((w, i) => {
|
||||
const isActive = i === activeWordIdx;
|
||||
const isVisible = w.startFrame <= frame;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
color: isActive ? "#FFE600" : "white",
|
||||
fontFamily: "'Arial Black', 'Impact', sans-serif",
|
||||
fontSize: 72,
|
||||
fontWeight: 900,
|
||||
lineHeight: 1.15,
|
||||
opacity: isVisible ? 1 : 0,
|
||||
WebkitTextStroke: "3px rgba(0,0,0,0.7)",
|
||||
textShadow: "0 4px 18px rgba(0,0,0,0.85)",
|
||||
transform: isActive ? "scale(1.08)" : "scale(1)",
|
||||
display: "inline-block",
|
||||
transition: "all 0.1s",
|
||||
}}
|
||||
>
|
||||
{w.word}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
) : overlayText ? (
|
||||
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 120 }}>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
width: 130,
|
||||
height: 130,
|
||||
objectFit: "contain",
|
||||
opacity: 0.82,
|
||||
borderRadius: 16,
|
||||
padding: "24px 32px",
|
||||
color: "white",
|
||||
fontSize: 60,
|
||||
fontWeight: "bold",
|
||||
fontFamily: "sans-serif",
|
||||
textAlign: "center",
|
||||
lineHeight: 1.4,
|
||||
maxWidth: "90%",
|
||||
WebkitTextStroke: "1px rgba(0,0,0,0.4)",
|
||||
filter: "drop-shadow(0 2px 8px rgba(0,0,0,0.5))",
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
|
||||
{/* Ending slide — centered logo + store name */}
|
||||
{hasEnding && (
|
||||
<Sequence from={endingStart} durationInFrames={effectiveEndingFrames}>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: "black",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
gap: 40,
|
||||
}}
|
||||
>
|
||||
{overlayText}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
) : null}
|
||||
{logoUrl && (
|
||||
<Img
|
||||
src={logoUrl}
|
||||
style={{
|
||||
width: 300,
|
||||
height: 300,
|
||||
objectFit: "contain",
|
||||
opacity: endingProgress,
|
||||
transform: `scale(${interpolate(endingProgress, [0, 1], [0.65, 1])})`,
|
||||
borderRadius: 24,
|
||||
filter: "drop-shadow(0 4px 24px rgba(255,255,255,0.15))",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{storeName && (
|
||||
<div
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 80,
|
||||
fontFamily: "'Arial Black', Impact, sans-serif",
|
||||
fontWeight: 900,
|
||||
textAlign: "center",
|
||||
opacity: endingProgress,
|
||||
letterSpacing: 3,
|
||||
textShadow: "0 4px 24px rgba(255,255,255,0.2)",
|
||||
transform: `translateY(${interpolate(endingProgress, [0, 1], [30, 0])}px)`,
|
||||
}}
|
||||
>
|
||||
{storeName}
|
||||
</div>
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
</Sequence>
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user