feat(remotion): +2 scene blocks — LogoReveal (logo motion) + StatCounter
Grows the scene-block library toward full template-type coverage: - LogoReveal: premium logo-motion — spring scale-in + glint sweep over the logo (image upload or a branded play-mark placeholder) + brand text + tagline, on the shared 2.5D Three backdrop. Fields: logoUrl, brandText, tagline. - StatCounter: animated count-up to a target (English-digit value → Persian display) + suffix + label. Fields: value, suffix, label. Registry now has 8 blocks. Both verified via FlexStory props-override stills. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, Img, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { FONT } from "../../lib/fonts";
|
||||
import { hexToRgba, mixHex } from "../../lib/anim";
|
||||
import { ThreeBackdrop, Grain, Vignette, useSceneTransition } from "../chrome";
|
||||
import type { BlockProps, SceneBlock } from "../types";
|
||||
|
||||
const resolveSrc = (u: string) => (/^https?:\/\//.test(u) ? u : staticFile(u));
|
||||
|
||||
const LogoReveal: React.FC<BlockProps> = ({ data, colors, L, durationInFrames }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const { opacity } = useSceneTransition(durationInFrames, L);
|
||||
const logoSp = spring({ frame: frame - 4, fps, config: { damping: 12, stiffness: 120 } });
|
||||
const logoScale = interpolate(logoSp, [0, 1], [0.4, 1]);
|
||||
const logoRot = interpolate(logoSp, [0, 1], [-12, 0]);
|
||||
const sweep = interpolate(frame, [12, 36], [-1.3, 1.3], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
const textOp = interpolate(frame, [18, 34], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
const tagOp = interpolate(frame, [26, 42], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
const logoSize = L.pick(L.vmin(280), L.vmin(260), L.vmin(300));
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ opacity, fontFamily: FONT, backgroundColor: colors.backgroundColor }}>
|
||||
<ThreeBackdrop colors={colors} />
|
||||
<AbsoluteFill style={{ display: "flex", alignItems: "center", justifyContent: "center", flexDirection: "column" }}>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: logoSize,
|
||||
height: logoSize,
|
||||
transform: `scale(${logoScale}) rotate(${logoRot}deg)`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
borderRadius: L.vmin(48),
|
||||
boxShadow: `0 ${L.vmin(30)}px ${L.vmin(60)}px ${hexToRgba(colors.accentColor, 0.3)}`,
|
||||
}}
|
||||
>
|
||||
{data.logoUrl ? (
|
||||
<Img src={resolveSrc(data.logoUrl)} style={{ width: "100%", height: "100%", objectFit: "contain" }} />
|
||||
) : (
|
||||
<div style={{ width: "100%", height: "100%", borderRadius: L.vmin(48), background: `linear-gradient(135deg, ${colors.accentColor}, ${mixHex(colors.accentColor, colors.secondaryColor, 0.7)})`, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<div style={{ width: 0, height: 0, borderTop: `${L.vmin(56)}px solid transparent`, borderBottom: `${L.vmin(56)}px solid transparent`, borderLeft: `${L.vmin(92)}px solid #ffffff`, marginLeft: L.vmin(20) }} />
|
||||
</div>
|
||||
)}
|
||||
{/* glint sweep across the logo */}
|
||||
<div style={{ position: "absolute", top: 0, bottom: 0, width: "45%", left: 0, transform: `translateX(${sweep * logoSize}px) skewX(-20deg)`, background: "linear-gradient(90deg, transparent, rgba(255,255,255,0.45), transparent)" }} />
|
||||
</div>
|
||||
<div style={{ direction: "rtl", opacity: textOp, marginTop: L.vmin(40), fontWeight: 900, fontSize: L.pick(L.vmin(88), L.vmin(78), L.vmin(72)), color: colors.textColor, letterSpacing: -1, textAlign: "center" }}>
|
||||
{data.brandText}
|
||||
</div>
|
||||
<div style={{ direction: "rtl", opacity: tagOp, marginTop: L.vmin(12), fontWeight: 400, fontSize: L.pick(L.vmin(34), L.vmin(32), L.vmin(30)), color: hexToRgba(colors.textColor, 0.6), textAlign: "center" }}>
|
||||
{data.tagline}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
<Vignette />
|
||||
<Grain />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const LogoRevealBlock: SceneBlock = {
|
||||
id: "LogoReveal",
|
||||
label: "نمایش لوگو",
|
||||
component: LogoReveal,
|
||||
fields: [
|
||||
{ key: "logoUrl", label: "لوگو (تصویر)", type: "image", default: "" },
|
||||
{ key: "brandText", label: "نام برند", type: "text", default: "برند شما" },
|
||||
{ key: "tagline", label: "شعار", type: "text", default: "شعار شما اینجا قرار میگیرد" },
|
||||
],
|
||||
defaultDurationSec: 4,
|
||||
minDurationSec: 2,
|
||||
maxDurationSec: 7,
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { FONT } from "../../lib/fonts";
|
||||
import { hexToRgba } from "../../lib/anim";
|
||||
import { ThreeBackdrop, Grain, Vignette, ProgressDots, Kicker, useSceneTransition } from "../chrome";
|
||||
import type { BlockProps, SceneBlock } from "../types";
|
||||
|
||||
const faNum = (s: string) => s.replace(/[0-9]/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[+d]);
|
||||
|
||||
const StatCounter: React.FC<BlockProps> = ({ data, colors, L, index, total, durationInFrames }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const { opacity, slide } = useSceneTransition(durationInFrames, L);
|
||||
const target = parseFloat(data.value) || 0;
|
||||
const decimals = (data.value.split(".")[1] || "").length;
|
||||
const prog = spring({
|
||||
frame: frame - 6,
|
||||
fps,
|
||||
config: { damping: 22, stiffness: 70 },
|
||||
durationInFrames: Math.max(20, Math.min(durationInFrames - 14, 64)),
|
||||
});
|
||||
const current = (target * prog).toFixed(decimals);
|
||||
const labelOp = interpolate(frame, [10, 26], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ opacity, fontFamily: FONT, backgroundColor: colors.backgroundColor }}>
|
||||
<ThreeBackdrop colors={colors} />
|
||||
<AbsoluteFill style={{ display: "flex", alignItems: "center", justifyContent: "center", flexDirection: "column", padding: L.vmin(60) }}>
|
||||
<Kicker index={index} total={total} colors={colors} L={L} slide={slide} />
|
||||
<div style={{ direction: "rtl", display: "flex", alignItems: "baseline", gap: L.vmin(6), fontWeight: 900, fontSize: L.pick(L.vmin(200), L.vmin(180), L.vmin(160)), color: colors.accentColor, lineHeight: 1, letterSpacing: -3 }}>
|
||||
<span style={{ fontVariantNumeric: "tabular-nums" }}>{faNum(current)}</span>
|
||||
<span style={{ fontSize: "0.5em", color: hexToRgba(colors.textColor, 0.7) }}>{data.suffix}</span>
|
||||
</div>
|
||||
<div style={{ direction: "rtl", opacity: labelOp, marginTop: L.vmin(18), fontWeight: 600, fontSize: L.pick(L.vmin(42), L.vmin(40), L.vmin(36)), color: hexToRgba(colors.textColor, 0.75), textAlign: "center", maxWidth: L.vmin(1000) }}>
|
||||
{data.label}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
<ProgressDots index={index} total={total} colors={colors} L={L} />
|
||||
<Vignette />
|
||||
<Grain />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const StatCounterBlock: SceneBlock = {
|
||||
id: "StatCounter",
|
||||
label: "شمارنده / آمار",
|
||||
component: StatCounter,
|
||||
fields: [
|
||||
{ key: "value", label: "عدد", type: "text", default: "98" },
|
||||
{ key: "suffix", label: "پسوند (٪ + …)", type: "text", default: "٪" },
|
||||
{ key: "label", label: "توضیح", type: "text", default: "رضایت کاربران" },
|
||||
],
|
||||
defaultDurationSec: 4,
|
||||
minDurationSec: 2,
|
||||
maxDurationSec: 7,
|
||||
};
|
||||
@@ -5,6 +5,8 @@ import { ImageCaptionBlock } from "./blocks/ImageCaption";
|
||||
import { KineticQuoteBlock } from "./blocks/KineticQuote";
|
||||
import { SlideshowBlock } from "./blocks/Slideshow";
|
||||
import { OutroCTABlock } from "./blocks/OutroCTA";
|
||||
import { LogoRevealBlock } from "./blocks/LogoReveal";
|
||||
import { StatCounterBlock } from "./blocks/StatCounter";
|
||||
|
||||
/**
|
||||
* The scene-block registry. A FlexStory template is an ordered list of these
|
||||
@@ -18,6 +20,8 @@ export const SCENE_BLOCKS: Record<string, SceneBlock> = {
|
||||
[KineticQuoteBlock.id]: KineticQuoteBlock,
|
||||
[SlideshowBlock.id]: SlideshowBlock,
|
||||
[OutroCTABlock.id]: OutroCTABlock,
|
||||
[LogoRevealBlock.id]: LogoRevealBlock,
|
||||
[StatCounterBlock.id]: StatCounterBlock,
|
||||
};
|
||||
|
||||
export const BLOCK_LIST = Object.values(SCENE_BLOCKS);
|
||||
|
||||
Reference in New Issue
Block a user