This commit is contained in:
2026-04-03 13:46:30 +04:00
commit 9b3e9481d0
129 changed files with 25099 additions and 0 deletions

210
src/Captions.tsx Normal file
View File

@@ -0,0 +1,210 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
AbsoluteFill,
staticFile,
useCurrentFrame,
useVideoConfig,
Sequence,
useDelayRender,
} from "remotion";
import { parseSrt } from "@remotion/captions";
import { FONT_FAMILY } from "./fonts";
import type { Caption } from "@remotion/captions";
const HIGHLIGHT_COLOR = "#FFD93D";
const CTA_START_MS = 14400;
const MAX_WORDS_PER_CHUNK = 5;
/**
* Split long SRT entries into smaller chunks of ~MAX_WORDS_PER_CHUNK words.
* Time is distributed proportionally by word count.
*/
function splitLongCaptions(captions: Caption[]): Caption[] {
const result: Caption[] = [];
for (const cap of captions) {
const words = cap.text.split(/\s+/).filter((w) => w.length > 0);
if (words.length <= MAX_WORDS_PER_CHUNK) {
result.push(cap);
continue;
}
const totalDurationMs = cap.endMs - cap.startMs;
const msPerWord = totalDurationMs / words.length;
for (let i = 0; i < words.length; i += MAX_WORDS_PER_CHUNK) {
const chunkWords = words.slice(i, i + MAX_WORDS_PER_CHUNK);
const chunkStartMs = cap.startMs + i * msPerWord;
const chunkEndMs = cap.startMs + (i + chunkWords.length) * msPerWord;
result.push({
text: chunkWords.join(" "),
startMs: Math.round(chunkStartMs),
endMs: Math.round(chunkEndMs),
timestampMs: null,
confidence: null,
});
}
}
return result;
}
const CaptionEntry: React.FC<{
text: string;
durationMs: number;
}> = ({ text, durationMs }) => {
const frame = useCurrentFrame();
const { fps, height, width } = useVideoConfig();
const isVertical = height / width > 1.5;
const safeTop = isVertical ? Math.round(height * 0.14) : 30;
const safeHorizontal = isVertical ? Math.round(width * 0.06) : 20;
const currentMs = (frame / fps) * 1000;
const words = text.split(/(\s+)/);
const realWords = words.filter((w) => w.trim().length > 0);
const wordCount = realWords.length;
const msPerWord = wordCount > 0 ? durationMs / wordCount : durationMs;
let wordIndex = 0;
return (
<AbsoluteFill
style={{
justifyContent: "flex-start",
alignItems: "center",
paddingTop: safeTop,
paddingLeft: safeHorizontal,
paddingRight: safeHorizontal,
}}
>
<div
style={{
backgroundColor: "rgba(0, 0, 0, 0.6)",
borderRadius: 16,
padding: "14px 28px",
maxWidth: width - safeHorizontal * 2 - 40,
}}
>
<div
style={{
fontSize: 54,
fontWeight: 800,
fontFamily: FONT_FAMILY,
textAlign: "center",
whiteSpace: "pre-wrap",
lineHeight: 1.35,
}}
>
{words.map((token, i) => {
if (token.trim().length === 0) {
return (
<span key={`ws-${i}`} style={{ color: "white" }}>
{token}
</span>
);
}
const thisWordIndex = wordIndex;
wordIndex++;
const wordStartMs = thisWordIndex * msPerWord;
const wordEndMs = (thisWordIndex + 1) * msPerWord;
const isActive = currentMs >= wordStartMs && currentMs < wordEndMs;
return (
<span
key={`w-${i}`}
style={{
color: isActive ? HIGHLIGHT_COLOR : "white",
}}
>
{token}
</span>
);
})}
</div>
</div>
</AbsoluteFill>
);
};
export const Captions: React.FC<{ srtFile?: string }> = ({
srtFile = "voiceover/voiceover.srt",
}) => {
const [captions, setCaptions] = useState<Caption[] | null>(null);
const { delayRender, continueRender, cancelRender } = useDelayRender();
const [handle] = useState(() => delayRender());
const { fps } = useVideoConfig();
const fetchCaptions = useCallback(async () => {
try {
const response = await fetch(staticFile(srtFile));
const text = await response.text();
const { captions: parsed } = parseSrt({ input: text });
const cleaned = parsed
.map((c) => ({
...c,
text: c.text.replace(/\[.*?\]\s*/g, "").trim(),
}))
.filter((c) => c.text.length > 0);
setCaptions(cleaned);
continueRender(handle);
} catch (e) {
cancelRender(e);
}
}, [continueRender, cancelRender, handle]);
useEffect(() => {
fetchCaptions();
}, [fetchCaptions]);
// Split long entries into ≤5-word chunks
const chunks = useMemo(() => {
if (!captions) return [];
return splitLongCaptions(captions);
}, [captions]);
if (!captions) return null;
return (
<AbsoluteFill>
{chunks.map((caption, index) => {
if (caption.startMs >= CTA_START_MS) return null;
const startFrame = Math.round((caption.startMs / 1000) * fps);
const nextCaption = chunks[index + 1] ?? null;
let endMs: number;
if (nextCaption) {
endMs = nextCaption.startMs;
} else {
endMs = caption.endMs;
}
if (endMs > CTA_START_MS) {
endMs = CTA_START_MS;
}
const endFrame = Math.round((endMs / 1000) * fps);
const durationInFrames = endFrame - startFrame;
if (durationInFrames <= 0) return null;
const durationMs = endMs - caption.startMs;
return (
<Sequence
key={index}
from={startFrame}
durationInFrames={durationInFrames}
>
<CaptionEntry text={caption.text} durationMs={durationMs} />
</Sequence>
);
})}
</AbsoluteFill>
);
};

376
src/HeartyAd.tsx Normal file
View File

@@ -0,0 +1,376 @@
import React from "react";
import {
AbsoluteFill,
Img,
staticFile,
useCurrentFrame,
useVideoConfig,
Sequence,
interpolate,
Easing,
} from "remotion";
import { Audio } from "@remotion/media";
import { Video } from "@remotion/media";
import { TransitionSeries, linearTiming } from "@remotion/transitions";
import { slide } from "@remotion/transitions/slide";
import { fade } from "@remotion/transitions/fade";
import { Captions } from "./Captions";
import { FONT_FAMILY } from "./fonts";
const TRANSITION_FRAMES = 6;
const CTA_DURATION = 210;
// ─── Localized CTA strings ─────────────────────────────────
const CTA_STRINGS: Record<string, { tagline: string; button: string }> = {
en: { tagline: "Play together. Every day.", button: "Try Today!" },
fr: { tagline: "Jouez ensemble. Chaque jour.", button: "Essayez aujourd'hui !" },
es: { tagline: "Jueguen juntos. Cada día.", button: "¡Pruébalo hoy!" },
it: { tagline: "Giocate insieme. Ogni giorno.", button: "Provalo oggi!" },
pt: { tagline: "Brinquem juntos. Todo dia.", button: "Experimente hoje!" },
de: { tagline: "Spielt zusammen. Jeden Tag.", button: "Jetzt ausprobieren!" },
};
type ClipDef = { file: string; title: string; durationFrames: number };
export type HeartyAdProps = {
clips: ClipDef[];
lang?: string; // "en" | "fr" | "es" | "it" | "pt" | "de"
};
// ─── Clip sets ──────────────────────────────────────────────
export const CLIPS_V1: ClipDef[] = [
{ file: "clips/01_felt_tip_pens.mp4", title: "Falling Felt-Tip Pens", durationFrames: 75 },
{ file: "clips/02_treasure_map.mp4", title: "Treasure Map", durationFrames: 78 },
{ file: "clips/03_sponge_sailboat.mp4", title: "Sponge Sailboat", durationFrames: 72 },
{ file: "clips/04_pom_pom_popper.mp4", title: "Clown Pom Pom Popper", durationFrames: 60 },
{ file: "clips/05_worry_rocks.mp4", title: "Worry Rocks", durationFrames: 72 },
{ file: "clips/06_water_battles.mp4", title: "Water Battles", durationFrames: 60 },
{ file: "clips/07_fruits_box.mp4", title: "Fruits in the Box", durationFrames: 60 },
];
export const CLIPS_V2: ClipDef[] = [
{ file: "clips-v2/01_three_legged_race.mp4", title: "Three-Legged Race", durationFrames: 75 },
{ file: "clips-v2/02_ping_pong_cup.mp4", title: "Ping Pong Cup Challenge", durationFrames: 78 },
{ file: "clips-v2/03_air_hockey.mp4", title: "Dining Table Air Hockey", durationFrames: 72 },
{ file: "clips-v2/04_gift_unwrap.mp4", title: "Gift Unwrap Challenge", durationFrames: 60 },
{ file: "clips-v2/05_helicopter_rope.mp4", title: "Helicopter Rope Game", durationFrames: 72 },
{ file: "clips-v2/06_catch_tail.mp4", title: "Catch My Tail", durationFrames: 60 },
{ file: "clips-v2/07_balloon_cup.mp4", title: "Balloon World Cup", durationFrames: 60 },
];
export const CLIPS_V3: ClipDef[] = [
{ file: "clips-v3/01_rainbow_spinner.mp4", title: "Rainbow Paper Spinner", durationFrames: 75 },
{ file: "clips-v3/02_oil_milk_art.mp4", title: "Oil & Milk Droplet Art", durationFrames: 78 },
{ file: "clips-v3/03_shake_color.mp4", title: "Shake & Color Surprise", durationFrames: 72 },
{ file: "clips-v3/04_dot_art.mp4", title: "Dot Art Fun", durationFrames: 60 },
{ file: "clips-v3/05_dancing_paper.mp4", title: "Dancing Paper People", durationFrames: 72 },
{ file: "clips-v3/06_floral_straw.mp4", title: "Floral Paper Straw Art", durationFrames: 60 },
{ file: "clips-v3/07_foil_bubble.mp4", title: "Foil Flower Bubble Magic", durationFrames: 60 },
];
export const CLIPS_V4: ClipDef[] = [
{ file: "clips-v4/01_water_gun_paint.mp4", title: "Water Gun T-Shirt Painting", durationFrames: 75 },
{ file: "clips-v4/02_balloon_race.mp4", title: "Head-to-Head Balloon Race", durationFrames: 78 },
{ file: "clips-v4/03_shower_paint.mp4", title: "Shower Curtain Painting", durationFrames: 72 },
{ file: "clips-v4/04_zip_line.mp4", title: "Zip Line Your Balloon!", durationFrames: 60 },
{ file: "clips-v4/05_dough_monsters.mp4", title: "Modeling Dough Monsters", durationFrames: 72 },
{ file: "clips-v4/06_potato_hedgehog.mp4", title: "Potato Hedgehog Craft", durationFrames: 60 },
{ file: "clips-v4/07_ping_pong.mp4", title: "Ping Pong Cup Challenge", durationFrames: 60 },
];
// ─── Title overlay ──────────────────────────────────────────
/** Shows activity title at the top, within 3:4 safe zone */
const ActivityTitle: React.FC<{ title: string }> = ({ title }) => {
const frame = useCurrentFrame();
const { fps, height, width } = useVideoConfig();
// In 9:16 (1920), 3:4 safe zone starts at 240px.
// In 3:4 (1440), no inset needed — use a small padding.
const safeTop = height > width * (4 / 3) + 1
? Math.round((height - width * (4 / 3)) / 2) + 20
: 30;
// Slide in + fade
const opacity = interpolate(frame, [0, 0.2 * fps], [0, 1], {
extrapolateRight: "clamp",
});
const translateY = interpolate(frame, [0, 0.25 * fps], [-20, 0], {
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
return (
<AbsoluteFill
style={{
justifyContent: "flex-start",
alignItems: "center",
paddingTop: safeTop,
pointerEvents: "none",
}}
>
<div
style={{
opacity,
transform: `translateY(${translateY}px)`,
backgroundColor: "rgba(0, 0, 0, 0.55)",
borderRadius: 14,
padding: "10px 28px",
}}
>
<span
style={{
fontSize: 38,
fontWeight: 700,
fontFamily: FONT_FAMILY,
color: "white",
textShadow: "0 2px 8px rgba(0,0,0,0.5)",
}}
>
{title}
</span>
</div>
</AbsoluteFill>
);
};
// ─── Clip + Video ───────────────────────────────────────────
const ClipScene: React.FC<{ file: string }> = ({ file }) => {
return (
<AbsoluteFill>
<Video
src={staticFile(file)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
muted
/>
</AbsoluteFill>
);
};
// ─── CTA Card ───────────────────────────────────────────────
const CTACard: React.FC<{ lang: string }> = ({ lang }) => {
const strings = CTA_STRINGS[lang] || CTA_STRINGS.en;
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const logoScale = interpolate(frame, [0, 0.4 * fps], [0.5, 1], {
extrapolateRight: "clamp",
easing: Easing.out(Easing.back(1.5)),
});
const textOpacity = interpolate(frame, [0.3 * fps, 0.7 * fps], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const ctaY = interpolate(frame, [0.5 * fps, 0.9 * fps], [40, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const ctaOpacity = interpolate(frame, [0.5 * fps, 0.9 * fps], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{
background:
"linear-gradient(180deg, #FF6B6B 0%, #FF8E53 50%, #FFD93D 100%)",
justifyContent: "center",
alignItems: "center",
}}
>
<Img
src={staticFile("app_icon.png")}
style={{
width: 220,
height: 220,
borderRadius: 48,
transform: `scale(${logoScale})`,
boxShadow: "0 12px 40px rgba(0,0,0,0.25)",
}}
/>
<div
style={{
transform: `scale(${logoScale})`,
fontSize: 110,
fontWeight: 900,
fontFamily: FONT_FAMILY,
color: "white",
textShadow: "0 4px 20px rgba(0,0,0,0.2)",
letterSpacing: -2,
marginTop: 30,
}}
>
Hearty
</div>
<div
style={{
opacity: textOpacity,
fontSize: 42,
fontWeight: 600,
fontFamily: FONT_FAMILY,
color: "white",
marginTop: 20,
textShadow: "0 2px 10px rgba(0,0,0,0.15)",
}}
>
{strings.tagline}
</div>
<div
style={{
opacity: ctaOpacity,
transform: `translateY(${ctaY}px)`,
marginTop: 60,
backgroundColor: "white",
borderRadius: 60,
padding: "24px 80px",
boxShadow: "0 8px 30px rgba(0,0,0,0.15)",
}}
>
<span
style={{
fontSize: 40,
fontWeight: 800,
fontFamily: FONT_FAMILY,
color: "#FF6B6B",
}}
>
{strings.button}
</span>
</div>
</AbsoluteFill>
);
};
// ─── Title timing helper ────────────────────────────────────
/**
* Compute the start frame for each clip inside a TransitionSeries.
* Each transition eats TRANSITION_FRAMES from the timeline.
*/
function getClipStartFrames(clips: ClipDef[]): number[] {
const starts: number[] = [];
let cursor = 0;
for (let i = 0; i < clips.length; i++) {
starts.push(cursor);
cursor += clips[i].durationFrames;
if (i < clips.length - 1) {
cursor -= TRANSITION_FRAMES; // transition overlap
}
}
return starts;
}
// ─── Main composition ───────────────────────────────────────
export const HeartyAd: React.FC<HeartyAdProps> = ({ clips, lang = "en" }) => {
const { fps } = useVideoConfig();
// Audio/SRT paths based on language
const voicePath =
lang === "en"
? "voiceover/voiceover.mp3"
: `voiceover/${lang}/voiceover.mp3`;
const srtPath =
lang === "en"
? "voiceover/voiceover.srt"
: `voiceover/${lang}/voiceover.srt`;
const clipStarts = getClipStartFrames(clips);
const directions: Array<
"from-left" | "from-right" | "from-top" | "from-bottom"
> = [
"from-right",
"from-left",
"from-bottom",
"from-right",
"from-left",
"from-top",
];
return (
<AbsoluteFill style={{ backgroundColor: "black" }}>
{/* Clip montage with transitions */}
<TransitionSeries>
{clips.map((clip, i) => {
const elements = [];
elements.push(
<TransitionSeries.Sequence
key={`clip-${i}`}
durationInFrames={clip.durationFrames}
>
<ClipScene file={clip.file} />
</TransitionSeries.Sequence>
);
if (i < clips.length - 1) {
elements.push(
<TransitionSeries.Transition
key={`trans-${i}`}
presentation={slide({
direction: directions[i % directions.length],
})}
timing={linearTiming({ durationInFrames: TRANSITION_FRAMES })}
/>
);
}
return elements;
})}
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: 10 })}
/>
<TransitionSeries.Sequence durationInFrames={CTA_DURATION}>
<CTACard lang={lang} />
</TransitionSeries.Sequence>
</TransitionSeries>
{/* Voiceover */}
<Audio src={staticFile(voicePath)} volume={1} />
{/* Background music */}
<Audio
src={staticFile("bg-music.mp3")}
volume={(f) =>
interpolate(
f,
[0, 0.5 * fps, 18 * fps, 21 * fps],
[0, 0.12, 0.12, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
)
}
/>
{/* Captions */}
<Captions srtFile={srtPath} />
{/* Gradient overlay */}
<AbsoluteFill
style={{
background:
"linear-gradient(180deg, rgba(0,0,0,0.3) 0%, transparent 15%, transparent 75%, rgba(0,0,0,0.4) 100%)",
pointerEvents: "none",
}}
/>
</AbsoluteFill>
);
};

49
src/Root.tsx Normal file
View File

@@ -0,0 +1,49 @@
import { Composition } from "remotion";
import {
HeartyAd,
CLIPS_V1,
CLIPS_V2,
CLIPS_V3,
CLIPS_V4,
} from "./HeartyAd";
const FPS = 30;
const DURATION = 21 * FPS;
const CLIP_VARIANTS = [
{ id: "v1", clips: CLIPS_V1 },
{ id: "v2", clips: CLIPS_V2 },
{ id: "v3", clips: CLIPS_V3 },
{ id: "v4", clips: CLIPS_V4 },
];
const LANGUAGES = ["en", "fr", "es", "it", "pt", "de"];
const ASPECT_RATIOS = [
{ suffix: "9x16", width: 1080, height: 1920 },
{ suffix: "4x5", width: 1080, height: 1350 },
];
export const RemotionRoot: React.FC = () => {
return (
<>
{/* Every variant × every language × every aspect ratio */}
{CLIP_VARIANTS.map(({ id, clips }) =>
LANGUAGES.map((lang) =>
ASPECT_RATIOS.map(({ suffix, width, height }) => (
<Composition
key={`${id}-${lang}-${suffix}`}
id={`HeartyAd-${id}-${lang}-${suffix}`}
component={HeartyAd}
durationInFrames={DURATION}
fps={FPS}
width={width}
height={height}
defaultProps={{ clips, lang }}
/>
))
)
)}
</>
);
};

8
src/fonts.ts Normal file
View File

@@ -0,0 +1,8 @@
import { loadFont } from "@remotion/google-fonts/Nunito";
const { fontFamily } = loadFont("normal", {
weights: ["600", "700", "800", "900"],
subsets: ["latin"],
});
export const FONT_FAMILY = fontFamily;

4
src/index.ts Normal file
View File

@@ -0,0 +1,4 @@
import { registerRoot } from "remotion";
import { RemotionRoot } from "./Root";
registerRoot(RemotionRoot);