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:
soroush.asadi
2026-06-23 15:05:15 +03:30
parent 8582e956c9
commit 383331e8f1
3 changed files with 136 additions and 0 deletions
@@ -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,
};
+4
View File
@@ -5,6 +5,8 @@ import { ImageCaptionBlock } from "./blocks/ImageCaption";
import { KineticQuoteBlock } from "./blocks/KineticQuote"; import { KineticQuoteBlock } from "./blocks/KineticQuote";
import { SlideshowBlock } from "./blocks/Slideshow"; import { SlideshowBlock } from "./blocks/Slideshow";
import { OutroCTABlock } from "./blocks/OutroCTA"; 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 * 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, [KineticQuoteBlock.id]: KineticQuoteBlock,
[SlideshowBlock.id]: SlideshowBlock, [SlideshowBlock.id]: SlideshowBlock,
[OutroCTABlock.id]: OutroCTABlock, [OutroCTABlock.id]: OutroCTABlock,
[LogoRevealBlock.id]: LogoRevealBlock,
[StatCounterBlock.id]: StatCounterBlock,
}; };
export const BLOCK_LIST = Object.values(SCENE_BLOCKS); export const BLOCK_LIST = Object.values(SCENE_BLOCKS);