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:
MichaelandClaude Sonnet 4.6 committed 2026-03-24 19:13:07 +01:00
1 parent 57c18e24df
commit 9145bc5941
4 files changed
+250 -100

No files matched your search

+21 -5
View File
@@ -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}
+27 -6
View File
@@ -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}
+143 -61
View File
@@ -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>
);
};