mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 23:18:12 +02:00
feat: Implement Remotion video generation feature, allowing users to combine images with text overlay and text-to-speech.
This commit is contained in:
1 parent
4f69a2edda
commit
24a1d39e88
4 files changed
+479
-253
No files matched your search
@@ -1,8 +1,8 @@
|
||||
import { AbsoluteFill, Img, Sequence, useVideoConfig, useCurrentFrame, Audio } from "remotion";
|
||||
import { AbsoluteFill, Img, Sequence, useVideoConfig, useCurrentFrame, Html5Audio, spring, interpolate } from "remotion";
|
||||
|
||||
export type WordTiming = {
|
||||
word: string; // display word (may include emoji/hashtag)
|
||||
startFrame: number; // frame this word becomes active
|
||||
word: string;
|
||||
startFrame: number;
|
||||
endFrame: number;
|
||||
};
|
||||
|
||||
@@ -11,48 +11,100 @@ export type ImageCompositionProps = {
|
||||
overlayText?: string;
|
||||
audioUrl?: string;
|
||||
wordTimings?: WordTiming[];
|
||||
musicUrl?: string;
|
||||
musicVolume?: 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;
|
||||
}
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", overflow: "hidden", ...wrapStyle }}>
|
||||
<Img src={src} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const ImageComposition = ({
|
||||
images, overlayText, audioUrl, wordTimings
|
||||
images, overlayText, audioUrl, wordTimings, musicUrl, musicVolume = 0.3
|
||||
}: ImageCompositionProps) => {
|
||||
const { fps } = useVideoConfig();
|
||||
const frame = useCurrentFrame();
|
||||
const durationPerImage = 3 * fps;
|
||||
|
||||
// Determine which word is currently "active" for TikTok highlight
|
||||
const activeWordIdx = wordTimings
|
||||
? wordTimings.findIndex(w => frame >= w.startFrame && frame < w.endFrame)
|
||||
: -1;
|
||||
|
||||
// Words displayed up to the current active one (progressive reveal)
|
||||
const visibleWords = wordTimings
|
||||
? wordTimings.filter(w => w.startFrame <= frame)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "black" }}>
|
||||
{/* Images sequence */}
|
||||
{/* Images with entrance transitions */}
|
||||
{images.map((imgUrl, index) => (
|
||||
<Sequence key={index} from={index * durationPerImage} durationInFrames={durationPerImage}>
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
|
||||
<Img src={imgUrl} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
|
||||
</AbsoluteFill>
|
||||
<ImageSlide src={imgUrl} transition={pickTransition(index)} />
|
||||
</Sequence>
|
||||
))}
|
||||
|
||||
{/* TTS Audio */}
|
||||
{audioUrl && <Audio src={audioUrl} startFrom={0} />}
|
||||
{audioUrl && <Html5Audio src={audioUrl} />}
|
||||
|
||||
{/* 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,
|
||||
}}
|
||||
>
|
||||
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 120 }}>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "88%",
|
||||
@@ -92,10 +144,7 @@ export const ImageComposition = ({
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
) : overlayText ? (
|
||||
/* Fallback: static text if no word timings */
|
||||
<AbsoluteFill
|
||||
style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 120 }}
|
||||
>
|
||||
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 120 }}>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
|
||||
Reference in new issue
Block a user