mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 15:08:13 +02:00
feat: implement Remotion image composition with dynamic text, audio, and branding, and a server-side rendering API.
This commit is contained in:
1 parent
1957445225
commit
0c525665d8
2 files changed
+77
-135
No files matched your search
@@ -15,66 +15,14 @@ export type ImageCompositionProps = {
|
|||||||
musicVolume?: number;
|
musicVolume?: number;
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
storeName?: string;
|
storeName?: string;
|
||||||
endingFrames?: number; // frames reserved for ending slide (default 90 = 3s at 30fps)
|
endingFrames?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 6 modern, simple entrance transitions
|
// Sub-component: one image displayed directly, no entrance transition
|
||||||
type Transition = "fade" | "slideLeft" | "slideRight" | "zoomIn" | "zoomOut" | "slideUp";
|
const ImageSlide: React.FC<{ src: string }> = ({ src }) => {
|
||||||
const TRANSITIONS: Transition[] = ["zoomIn", "slideLeft", "fade", "slideRight", "zoomOut", "slideUp"];
|
|
||||||
|
|
||||||
// Deterministic "random" — varied order that never repeats two in a row
|
|
||||||
const TRANSITION_ORDER = [0, 1, 2, 3, 4, 5, 1, 3, 0, 4, 2, 5];
|
|
||||||
function pickTransition(index: number): Transition {
|
|
||||||
return TRANSITIONS[TRANSITION_ORDER[index % TRANSITION_ORDER.length]];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sub-component: one image with its entrance spring animation
|
|
||||||
const ImageSlide: React.FC<{ src: string; transition: Transition }> = ({ src, transition }) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const progress = spring({ frame, fps, config: { damping: 22, stiffness: 160 }, durationInFrames: 22 });
|
|
||||||
|
|
||||||
let wrapStyle: React.CSSProperties = {};
|
|
||||||
switch (transition) {
|
|
||||||
case "fade":
|
|
||||||
wrapStyle = { opacity: progress };
|
|
||||||
break;
|
|
||||||
case "slideLeft":
|
|
||||||
wrapStyle = {
|
|
||||||
opacity: interpolate(progress, [0, 0.4], [0, 1], { extrapolateRight: "clamp" }),
|
|
||||||
transform: `translateX(${interpolate(progress, [0, 1], [110, 0])}%)`,
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "slideRight":
|
|
||||||
wrapStyle = {
|
|
||||||
opacity: interpolate(progress, [0, 0.4], [0, 1], { extrapolateRight: "clamp" }),
|
|
||||||
transform: `translateX(${interpolate(progress, [0, 1], [-110, 0])}%)`,
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "slideUp":
|
|
||||||
wrapStyle = {
|
|
||||||
opacity: interpolate(progress, [0, 0.4], [0, 1], { extrapolateRight: "clamp" }),
|
|
||||||
transform: `translateY(${interpolate(progress, [0, 1], [80, 0])}%)`,
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "zoomIn":
|
|
||||||
wrapStyle = {
|
|
||||||
opacity: interpolate(progress, [0, 0.5], [0, 1], { extrapolateRight: "clamp" }),
|
|
||||||
transform: `scale(${interpolate(progress, [0, 1], [0.75, 1])})`,
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "zoomOut":
|
|
||||||
wrapStyle = {
|
|
||||||
opacity: interpolate(progress, [0, 0.5], [0, 1], { extrapolateRight: "clamp" }),
|
|
||||||
transform: `scale(${interpolate(progress, [0, 1], [1.25, 1])})`,
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", overflow: "hidden", ...wrapStyle }}>
|
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", overflow: "hidden", backgroundColor: "black" }}>
|
||||||
<Img src={src} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
|
<Img src={src} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||||
</AbsoluteFill>
|
</AbsoluteFill>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -86,7 +34,6 @@ export const ImageComposition = ({
|
|||||||
const { fps, durationInFrames } = useVideoConfig();
|
const { fps, durationInFrames } = useVideoConfig();
|
||||||
const frame = useCurrentFrame();
|
const frame = useCurrentFrame();
|
||||||
|
|
||||||
// Reserve last endingFrames for the ending slide; spread images across the rest
|
|
||||||
const hasEnding = !!(logoUrl || storeName);
|
const hasEnding = !!(logoUrl || storeName);
|
||||||
const effectiveEndingFrames = hasEnding ? endingFrames : 0;
|
const effectiveEndingFrames = hasEnding ? endingFrames : 0;
|
||||||
const contentFrames = durationInFrames - effectiveEndingFrames;
|
const contentFrames = durationInFrames - effectiveEndingFrames;
|
||||||
@@ -101,16 +48,17 @@ export const ImageComposition = ({
|
|||||||
durationInFrames: 30,
|
durationInFrames: 30,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Current active word (wordTimings only contains spoken words — no hashtags/emojis)
|
||||||
const activeWordIdx = wordTimings
|
const activeWordIdx = wordTimings
|
||||||
? wordTimings.findIndex(w => frame >= w.startFrame && frame < w.endFrame)
|
? wordTimings.findIndex(w => frame >= w.startFrame && frame < w.endFrame)
|
||||||
: -1;
|
: -1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AbsoluteFill style={{ backgroundColor: "black" }}>
|
<AbsoluteFill style={{ backgroundColor: "black" }}>
|
||||||
{/* Images with entrance transitions */}
|
{/* Images — displayed immediately, no entrance transition */}
|
||||||
{images.map((imgUrl, index) => (
|
{images.map((imgUrl, index) => (
|
||||||
<Sequence key={index} from={index * durationPerImage} durationInFrames={durationPerImage}>
|
<Sequence key={index} from={index * durationPerImage} durationInFrames={durationPerImage}>
|
||||||
<ImageSlide src={imgUrl} transition={pickTransition(index)} />
|
<ImageSlide src={imgUrl} />
|
||||||
</Sequence>
|
</Sequence>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -120,73 +68,70 @@ export const ImageComposition = ({
|
|||||||
{/* Background music */}
|
{/* Background music */}
|
||||||
{musicUrl && <Html5Audio src={musicUrl} volume={musicVolume} />}
|
{musicUrl && <Html5Audio src={musicUrl} volume={musicVolume} />}
|
||||||
|
|
||||||
{/* TikTok-style 3-words-at-a-time overlay (content section only) */}
|
{/* Word overlay — TikTok style, 3 spoken words at a time, synced to TTS */}
|
||||||
{frame < endingStart && (
|
{frame < endingStart && wordTimings && wordTimings.length > 0 && activeWordIdx >= 0 && (() => {
|
||||||
wordTimings && wordTimings.length > 0 && activeWordIdx >= 0 ? (() => {
|
const groupIdx = Math.floor(activeWordIdx / 3);
|
||||||
// Group index: which chunk of 3 is currently active
|
const groupStart = groupIdx * 3;
|
||||||
const groupIdx = Math.floor(activeWordIdx / 3);
|
const groupWords = wordTimings.slice(groupStart, groupStart + 3);
|
||||||
const groupStart = groupIdx * 3;
|
return (
|
||||||
const groupWords = wordTimings.slice(groupStart, groupStart + 3);
|
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 160 }}>
|
||||||
return (
|
<div
|
||||||
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 160 }}>
|
style={{
|
||||||
<div
|
maxWidth: "88%",
|
||||||
style={{
|
textAlign: "center",
|
||||||
maxWidth: "88%",
|
display: "flex",
|
||||||
textAlign: "center",
|
flexWrap: "wrap",
|
||||||
display: "flex",
|
justifyContent: "center",
|
||||||
flexWrap: "wrap",
|
gap: "0 18px",
|
||||||
justifyContent: "center",
|
alignItems: "baseline",
|
||||||
gap: "0 18px",
|
filter: "drop-shadow(0 2px 12px rgba(0,0,0,0.7))",
|
||||||
alignItems: "baseline",
|
}}
|
||||||
filter: "drop-shadow(0 2px 12px rgba(0,0,0,0.7))",
|
>
|
||||||
}}
|
{groupWords.map((w, gi) => {
|
||||||
>
|
const isActive = groupStart + gi === activeWordIdx;
|
||||||
{groupWords.map((w, gi) => {
|
return (
|
||||||
const isActive = groupStart + gi === activeWordIdx;
|
<span
|
||||||
return (
|
key={groupStart + gi}
|
||||||
<span
|
style={{
|
||||||
key={groupStart + gi}
|
color: isActive ? "#FFE600" : "white",
|
||||||
style={{
|
fontFamily: "'Arial Black', 'Impact', sans-serif",
|
||||||
color: isActive ? "#FFE600" : "white",
|
fontSize: 76,
|
||||||
fontFamily: "'Arial Black', 'Impact', sans-serif",
|
fontWeight: 900,
|
||||||
fontSize: 76,
|
lineHeight: 1.2,
|
||||||
fontWeight: 900,
|
WebkitTextStroke: isActive ? "3px rgba(0,0,0,0.8)" : "2px rgba(0,0,0,0.7)",
|
||||||
lineHeight: 1.2,
|
textShadow: "0 4px 20px rgba(0,0,0,0.9)",
|
||||||
WebkitTextStroke: isActive ? "3px rgba(0,0,0,0.8)" : "2px rgba(0,0,0,0.7)",
|
display: "inline-block",
|
||||||
textShadow: "0 4px 20px rgba(0,0,0,0.9)",
|
textTransform: "uppercase",
|
||||||
display: "inline-block",
|
transform: isActive ? "scale(1.08)" : "scale(1)",
|
||||||
textTransform: "uppercase",
|
}}
|
||||||
transform: isActive ? "scale(1.08)" : "scale(1)",
|
>
|
||||||
}}
|
{w.word}
|
||||||
>
|
</span>
|
||||||
{w.word}
|
);
|
||||||
</span>
|
})}
|
||||||
);
|
</div>
|
||||||
})}
|
</AbsoluteFill>
|
||||||
</div>
|
);
|
||||||
</AbsoluteFill>
|
})()}
|
||||||
);
|
|
||||||
})() : null
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Logo watermark — bottom right, always visible during content */}
|
{/* Logo watermark — bottom right during content */}
|
||||||
{logoUrl && frame < endingStart && (
|
{logoUrl && frame < endingStart && (
|
||||||
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "flex-end", padding: 48 }}>
|
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "flex-end", padding: 48 }}>
|
||||||
<Img
|
<Img
|
||||||
src={logoUrl}
|
src={logoUrl}
|
||||||
style={{
|
style={{
|
||||||
width: 130,
|
width: 150,
|
||||||
height: 130,
|
height: 150,
|
||||||
objectFit: "contain",
|
objectFit: "contain",
|
||||||
opacity: 0.82,
|
opacity: 0.85,
|
||||||
borderRadius: 16,
|
borderRadius: 20,
|
||||||
filter: "drop-shadow(0 2px 8px rgba(0,0,0,0.5))",
|
filter: "drop-shadow(0 2px 8px rgba(0,0,0,0.5))",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</AbsoluteFill>
|
</AbsoluteFill>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Ending slide — centered logo + store name */}
|
{/* Ending slide — large centered logo + store name */}
|
||||||
{hasEnding && (
|
{hasEnding && (
|
||||||
<Sequence from={endingStart} durationInFrames={effectiveEndingFrames}>
|
<Sequence from={endingStart} durationInFrames={effectiveEndingFrames}>
|
||||||
<AbsoluteFill
|
<AbsoluteFill
|
||||||
@@ -195,20 +140,20 @@ export const ImageComposition = ({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: 40,
|
gap: 48,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{logoUrl && (
|
{logoUrl && (
|
||||||
<Img
|
<Img
|
||||||
src={logoUrl}
|
src={logoUrl}
|
||||||
style={{
|
style={{
|
||||||
width: 300,
|
width: 520,
|
||||||
height: 300,
|
height: 520,
|
||||||
objectFit: "contain",
|
objectFit: "contain",
|
||||||
opacity: endingProgress,
|
opacity: endingProgress,
|
||||||
transform: `scale(${interpolate(endingProgress, [0, 1], [0.65, 1])})`,
|
transform: `scale(${interpolate(endingProgress, [0, 1], [0.65, 1])})`,
|
||||||
borderRadius: 24,
|
borderRadius: 32,
|
||||||
filter: "drop-shadow(0 4px 24px rgba(255,255,255,0.15))",
|
filter: "drop-shadow(0 4px 32px rgba(255,255,255,0.2))",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -216,7 +161,7 @@ export const ImageComposition = ({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
color: "white",
|
color: "white",
|
||||||
fontSize: 80,
|
fontSize: 90,
|
||||||
fontFamily: "'Arial Black', Impact, sans-serif",
|
fontFamily: "'Arial Black', Impact, sans-serif",
|
||||||
fontWeight: 900,
|
fontWeight: 900,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
|
|||||||
+12
-15
@@ -54,8 +54,8 @@ function stripForTTS(text: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates word timings (in frames at 30fps) based on audio duration.
|
* Calculates word timings (in frames) for SPOKEN words only (no hashtags, no emojis).
|
||||||
* Each word appears only when spoken, then disappears (no accumulation).
|
* Distributes audio duration evenly across words → tight sync with TTS voice.
|
||||||
*/
|
*/
|
||||||
function computeWordTimings(
|
function computeWordTimings(
|
||||||
displayText: string,
|
displayText: string,
|
||||||
@@ -63,22 +63,19 @@ function computeWordTimings(
|
|||||||
fps: number,
|
fps: number,
|
||||||
startFrame: number
|
startFrame: number
|
||||||
): Array<{ word: string; startFrame: number; endFrame: number }> {
|
): Array<{ word: string; startFrame: number; endFrame: number }> {
|
||||||
const displayWords = displayText.split(/\s+/).filter(Boolean);
|
// Only keep words that the TTS will actually speak
|
||||||
const ttsText = stripForTTS(displayText);
|
const ttsText = stripForTTS(displayText);
|
||||||
const ttsWords = ttsText.split(/\s+/).filter(Boolean);
|
const spokenWords = ttsText.split(/\s+/).filter(Boolean);
|
||||||
|
if (spokenWords.length === 0) return [];
|
||||||
|
|
||||||
const totalTTSFrames = audioDurationSeconds * fps;
|
const totalFrames = audioDurationSeconds * fps;
|
||||||
const framesPerTTSWord = ttsWords.length > 0 ? totalTTSFrames / ttsWords.length : fps;
|
const framesPerWord = totalFrames / spokenWords.length;
|
||||||
|
|
||||||
let currentFrame = startFrame;
|
return spokenWords.map((word, i) => ({
|
||||||
|
word,
|
||||||
return displayWords.map((word) => {
|
startFrame: startFrame + Math.round(i * framesPerWord),
|
||||||
const wordStart = currentFrame;
|
endFrame: startFrame + Math.round((i + 1) * framesPerWord),
|
||||||
const isSpoken = !/^#/.test(word) && !(/[\uD800-\uDFFF\u2600-\u27BF]/.test(word));
|
}));
|
||||||
const frameDuration = isSpoken ? framesPerTTSWord : framesPerTTSWord * 0.4;
|
|
||||||
currentFrame += Math.round(frameDuration);
|
|
||||||
return { word, startFrame: wordStart, endFrame: currentFrame };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Duration constants
|
// Duration constants
|
||||||
|
|||||||
Reference in new issue
Block a user