mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-12 01:36:54 +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;
|
||||
logoUrl?: string;
|
||||
storeName?: string;
|
||||
endingFrames?: number; // frames reserved for ending slide (default 90 = 3s at 30fps)
|
||||
endingFrames?: number;
|
||||
};
|
||||
|
||||
// 6 modern, simple entrance transitions
|
||||
type Transition = "fade" | "slideLeft" | "slideRight" | "zoomIn" | "zoomOut" | "slideUp";
|
||||
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;
|
||||
}
|
||||
|
||||
// Sub-component: one image displayed directly, no entrance transition
|
||||
const ImageSlide: React.FC<{ src: string }> = ({ src }) => {
|
||||
return (
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", overflow: "hidden", ...wrapStyle }}>
|
||||
<Img src={src} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", overflow: "hidden", backgroundColor: "black" }}>
|
||||
<Img src={src} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -86,7 +34,6 @@ export const ImageComposition = ({
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
// 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;
|
||||
@@ -101,16 +48,17 @@ export const ImageComposition = ({
|
||||
durationInFrames: 30,
|
||||
});
|
||||
|
||||
// Current active word (wordTimings only contains spoken words — no hashtags/emojis)
|
||||
const activeWordIdx = wordTimings
|
||||
? wordTimings.findIndex(w => frame >= w.startFrame && frame < w.endFrame)
|
||||
: -1;
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "black" }}>
|
||||
{/* Images with entrance transitions */}
|
||||
{/* Images — displayed immediately, no entrance transition */}
|
||||
{images.map((imgUrl, index) => (
|
||||
<Sequence key={index} from={index * durationPerImage} durationInFrames={durationPerImage}>
|
||||
<ImageSlide src={imgUrl} transition={pickTransition(index)} />
|
||||
<ImageSlide src={imgUrl} />
|
||||
</Sequence>
|
||||
))}
|
||||
|
||||
@@ -120,73 +68,70 @@ export const ImageComposition = ({
|
||||
{/* Background music */}
|
||||
{musicUrl && <Html5Audio src={musicUrl} volume={musicVolume} />}
|
||||
|
||||
{/* TikTok-style 3-words-at-a-time overlay (content section only) */}
|
||||
{frame < endingStart && (
|
||||
wordTimings && wordTimings.length > 0 && activeWordIdx >= 0 ? (() => {
|
||||
// Group index: which chunk of 3 is currently active
|
||||
const groupIdx = Math.floor(activeWordIdx / 3);
|
||||
const groupStart = groupIdx * 3;
|
||||
const groupWords = wordTimings.slice(groupStart, groupStart + 3);
|
||||
return (
|
||||
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 160 }}>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "88%",
|
||||
textAlign: "center",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "center",
|
||||
gap: "0 18px",
|
||||
alignItems: "baseline",
|
||||
filter: "drop-shadow(0 2px 12px rgba(0,0,0,0.7))",
|
||||
}}
|
||||
>
|
||||
{groupWords.map((w, gi) => {
|
||||
const isActive = groupStart + gi === activeWordIdx;
|
||||
return (
|
||||
<span
|
||||
key={groupStart + gi}
|
||||
style={{
|
||||
color: isActive ? "#FFE600" : "white",
|
||||
fontFamily: "'Arial Black', 'Impact', sans-serif",
|
||||
fontSize: 76,
|
||||
fontWeight: 900,
|
||||
lineHeight: 1.2,
|
||||
WebkitTextStroke: isActive ? "3px rgba(0,0,0,0.8)" : "2px rgba(0,0,0,0.7)",
|
||||
textShadow: "0 4px 20px rgba(0,0,0,0.9)",
|
||||
display: "inline-block",
|
||||
textTransform: "uppercase",
|
||||
transform: isActive ? "scale(1.08)" : "scale(1)",
|
||||
}}
|
||||
>
|
||||
{w.word}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
})() : null
|
||||
)}
|
||||
{/* Word overlay — TikTok style, 3 spoken words at a time, synced to TTS */}
|
||||
{frame < endingStart && wordTimings && wordTimings.length > 0 && activeWordIdx >= 0 && (() => {
|
||||
const groupIdx = Math.floor(activeWordIdx / 3);
|
||||
const groupStart = groupIdx * 3;
|
||||
const groupWords = wordTimings.slice(groupStart, groupStart + 3);
|
||||
return (
|
||||
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 160 }}>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "88%",
|
||||
textAlign: "center",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "center",
|
||||
gap: "0 18px",
|
||||
alignItems: "baseline",
|
||||
filter: "drop-shadow(0 2px 12px rgba(0,0,0,0.7))",
|
||||
}}
|
||||
>
|
||||
{groupWords.map((w, gi) => {
|
||||
const isActive = groupStart + gi === activeWordIdx;
|
||||
return (
|
||||
<span
|
||||
key={groupStart + gi}
|
||||
style={{
|
||||
color: isActive ? "#FFE600" : "white",
|
||||
fontFamily: "'Arial Black', 'Impact', sans-serif",
|
||||
fontSize: 76,
|
||||
fontWeight: 900,
|
||||
lineHeight: 1.2,
|
||||
WebkitTextStroke: isActive ? "3px rgba(0,0,0,0.8)" : "2px rgba(0,0,0,0.7)",
|
||||
textShadow: "0 4px 20px rgba(0,0,0,0.9)",
|
||||
display: "inline-block",
|
||||
textTransform: "uppercase",
|
||||
transform: isActive ? "scale(1.08)" : "scale(1)",
|
||||
}}
|
||||
>
|
||||
{w.word}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Logo watermark — bottom right, always visible during content */}
|
||||
{/* Logo watermark — bottom right during content */}
|
||||
{logoUrl && frame < endingStart && (
|
||||
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "flex-end", padding: 48 }}>
|
||||
<Img
|
||||
src={logoUrl}
|
||||
style={{
|
||||
width: 130,
|
||||
height: 130,
|
||||
width: 150,
|
||||
height: 150,
|
||||
objectFit: "contain",
|
||||
opacity: 0.82,
|
||||
borderRadius: 16,
|
||||
opacity: 0.85,
|
||||
borderRadius: 20,
|
||||
filter: "drop-shadow(0 2px 8px rgba(0,0,0,0.5))",
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
|
||||
{/* Ending slide — centered logo + store name */}
|
||||
{/* Ending slide — large centered logo + store name */}
|
||||
{hasEnding && (
|
||||
<Sequence from={endingStart} durationInFrames={effectiveEndingFrames}>
|
||||
<AbsoluteFill
|
||||
@@ -195,20 +140,20 @@ export const ImageComposition = ({
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
gap: 40,
|
||||
gap: 48,
|
||||
}}
|
||||
>
|
||||
{logoUrl && (
|
||||
<Img
|
||||
src={logoUrl}
|
||||
style={{
|
||||
width: 300,
|
||||
height: 300,
|
||||
width: 520,
|
||||
height: 520,
|
||||
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))",
|
||||
borderRadius: 32,
|
||||
filter: "drop-shadow(0 4px 32px rgba(255,255,255,0.2))",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -216,7 +161,7 @@ export const ImageComposition = ({
|
||||
<div
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 80,
|
||||
fontSize: 90,
|
||||
fontFamily: "'Arial Black', Impact, sans-serif",
|
||||
fontWeight: 900,
|
||||
textAlign: "center",
|
||||
|
||||
+12
-15
@@ -54,8 +54,8 @@ function stripForTTS(text: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates word timings (in frames at 30fps) based on audio duration.
|
||||
* Each word appears only when spoken, then disappears (no accumulation).
|
||||
* Calculates word timings (in frames) for SPOKEN words only (no hashtags, no emojis).
|
||||
* Distributes audio duration evenly across words → tight sync with TTS voice.
|
||||
*/
|
||||
function computeWordTimings(
|
||||
displayText: string,
|
||||
@@ -63,22 +63,19 @@ function computeWordTimings(
|
||||
fps: number,
|
||||
startFrame: 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 ttsWords = ttsText.split(/\s+/).filter(Boolean);
|
||||
const spokenWords = ttsText.split(/\s+/).filter(Boolean);
|
||||
if (spokenWords.length === 0) return [];
|
||||
|
||||
const totalTTSFrames = audioDurationSeconds * fps;
|
||||
const framesPerTTSWord = ttsWords.length > 0 ? totalTTSFrames / ttsWords.length : fps;
|
||||
const totalFrames = audioDurationSeconds * fps;
|
||||
const framesPerWord = totalFrames / spokenWords.length;
|
||||
|
||||
let currentFrame = startFrame;
|
||||
|
||||
return displayWords.map((word) => {
|
||||
const wordStart = currentFrame;
|
||||
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 };
|
||||
});
|
||||
return spokenWords.map((word, i) => ({
|
||||
word,
|
||||
startFrame: startFrame + Math.round(i * framesPerWord),
|
||||
endFrame: startFrame + Math.round((i + 1) * framesPerWord),
|
||||
}));
|
||||
}
|
||||
|
||||
// Duration constants
|
||||
|
||||
Reference in new issue
Block a user