test
This commit is contained in:
210
src/Captions.tsx
Normal file
210
src/Captions.tsx
Normal 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
376
src/HeartyAd.tsx
Normal 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
49
src/Root.tsx
Normal 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
8
src/fonts.ts
Normal 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
4
src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { RemotionRoot } from "./Root";
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
Reference in New Issue
Block a user