feat(remotion): +3 scene blocks (BarChart, Stomp, DeviceMockup) + catalog/toolchain docs

Unlocks the biggest catalog gaps by composition:
- BarChart: animated infographic bars (value + label, normalized, staggered grow).
- Stomp: punchy beat-synced typography — words slam in with overshoot + shake +
  accent impact bar (titles / fashion / openers).
- DeviceMockup: phone/browser frame holding the user's screenshot + title/caption
  (app / website promo); placeholder when no image.
Registry now has 12 blocks. All verified via FlexStory props-override stills.

docs: CATALOG_PLAN.md (the full template taxonomy + production map + build waves;
the Persian/Islamic occasions = the moat) and PREMIUM_TOOLCHAIN.md (the stop-motion/
paper-cut/premium tool plan; editable-backdrop architecture; Iran/OFAC reality).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-24 23:16:44 +03:30
parent 866edbff8c
commit 8f34c3175f
6 changed files with 387 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
# FlatRender — Template Catalog Plan
The categories to cover, grouped, each mapped to **how it's produced** (engine ·
style · assets) and **status**. Built on the FlexStory scene engine + block library
([[project_scene_engine]]), the 3D single-comp templates, and the premium toolchain
([[reference_premium_toolchain]]). Status: ✅ have · 🟡 partial (have a block/3D base) · ⬜ new.
## 1. Marketing / Promo — *engine: scene-engine + 3D · style: 2.5D / 3D cinematic*
| Category | Approach | Status |
|---|---|---|
| Logo motion | 3D metallic + rays (single-comp) | ✅ LogoMotion3D |
| App / website promo | device mockup + 2.5D | 🟡 AppShowcase3D, ProductShowcase |
| Instagram promo | 2.5D + CTA | 🟡 InstaPromo |
| YouTube promo / channel intro | 2.5D + subscribe CTA | 🟡 YouTubeIntro |
| Sale / offer | 3D gift/box + badge | 🟡 Promo3D, SalePromo |
| Real estate | image showcase + map/stats | ⬜ new (ImageCaption + StatCounter) |
| Fashion | kinetic + image reveal | ⬜ new (Stomp + ImageCaption) |
## 2. Persian / Islamic occasions — ⭐ **THE MOAT** — *self-authored art; Western tools can't do these authentically*
| Category | Notes | Status |
|---|---|---|
| Nowruz (نوروز) | haft-sin, sabzeh, goldfish | ✅ Nowruz3D, NowruzGreeting |
| Yalda (شب یلدا) | pomegranate, watermelon, Hafez | ⬜ new |
| Ramadan (رمضان) | crescent, fanoos/lantern | ⬜ new |
| Eid (عید فطر/قربان) | celebratory, ornamental | ⬜ new |
| Muharram (محرم) | somber, respectful, calligraphy | ⬜ new |
| Condolences (تسلیت) | minimal, dignified | ⬜ new |
| Wedding (عروسی) | elegant, gold, floral | ⬜ new |
| Birthday (تولد) | confetti, cake | ✅ Birthday3D, HappyBirthday |
> This group is the **differentiation** — Persian-first, culturally authentic, low competition. Highest strategic ROI. Self-authored seasonal art (the discipline we already use for Nowruz), no licensing/Iran blockers.
## 3. Presentation / Content — *engine: FlexStory scene-engine (already built)*
| Category | Approach | Status |
|---|---|---|
| Presentations / openers | TitleCard + scene list | 🟡 Opener, TitleCard |
| Slideshows | Slideshow block | ✅ |
| Infographics | StatCounter + chart blocks | 🟡 (StatCounter; need Chart/Bar/Pie blocks) |
| Explainers | CharacterScene + scene list | ✅ CharacterStory |
| Toolkits | a *set* of reusable blocks/elements | ✅ the scene engine **is** this |
## 4. Titles / Typography — *style: kinetic type, beat-synced*
| Category | Approach | Status |
|---|---|---|
| Titles / lower-thirds | kinetic text block | 🟡 KineticQuote |
| Stomp | fast beat-synced typography to music | ⬜ new (Stomp block) |
## 5. Characters & Animation — *vendored CC0 art + 2.5D + craft*
| Category | Approach | Status |
|---|---|---|
| Characters & animation | CC0 Open-Peeps + 2.5D SceneStage | ✅ CharacterScene |
| Stop motion | craft kit: frame-step + jitter + grain | ⬜ new (toolchain P1) · or Wan 2.2 LoRA |
| Paper-cut | layered planes + shadows + paper PBR | ⬜ new (toolchain) |
---
## What this tells us
- **~60% is assembly on the existing scene engine** — promo, slideshow, openers, explainers, infographics, titles are *block compositions*, not new code. Build the missing blocks (Chart, Stomp, LowerThird) and most of §1/§3/§4 falls out.
- **The Persian/Islamic pack (§2) is the moat** — self-authored, no competitor, no licensing/Iran issue. Invest here for differentiation.
- **Stop-motion/paper-cut/premium (§5)** = the toolchain free-craft tier first, AI tier later.
## Build waves
- **Wave 1 — Assemble from existing:** ship promo / slideshow / opener / explainer / infographic templates by composing current blocks + a few new blocks (Chart, Stomp, LowerThird, device mockup). Fast, leverages what's built.
- **Wave 2 — The Persian moat:** Yalda · Ramadan · Eid · Muharram · Wedding · Condolences seasonal templates (self-authored art, reuse Nowruz3D learnings). The differentiator.
- **Wave 3 — Craft/premium tier:** the free craft kit (frame-step, paper-cut planes, FinishPass, Lottie) → stop-motion + paper-cut + premium templates. Then self-host AI (Wan/FLUX).
**Each template still goes through the flow:** brief → ★ quality-gate (cheap still, your approval) ★ → build → per-scene previews → seed → deploy.
@@ -0,0 +1,72 @@
# FlatRender — Premium Template Toolchain (stop-motion · paper-cut · premium)
From the `premium-video-toolchain` research sweep (6 finders). Goal: raise template
quality beyond hand-coded art, for a **Persian-first paid SaaS** that renders
**editable** templates to MP4 server-side, on **Remotion + After Effects** engines.
## The diagnosis
The quality cap isn't the engines — it's the AI **hand-authoring the art in code** at
render time. Clean vector motion is what code does well; stop-motion / paper-cut live
on **texture, handmade imperfection, real shadows, layered materials** — artist/asset
driven. Fix = **change where the artistry comes from**, keep the editable layer in code.
## The load-bearing constraint: Iran access is the dominant filter (not quality)
Every frontier AI tool is **OFAC-blocked at signup AND payment** — US (Runway, Sora,
Veo, Luma, Pika, Kaiber) *and* Chinese (Kling, Seedance). The aggregators (fal.ai,
Replicate) are US/Stripe-billed → same wall. There is **no direct payable-from-Iran
path to any hosted model.** Two viable routes:
- **Self-host open weights** → zero OFAC exposure (nothing paid to a US co. at render). The ONLY thing that can sit in the **live, on-demand server render**.
- **Acquire-once via a non-Iran intermediary** → batch-generate clips → **vendor the MP4** under the existing `assets.json` licence firewall (the *output* licence is perpetual; the *service* access is not). Exactly the asset/audio-library pattern.
## The architecture (this is the whole answer)
An AI clip is **never the template** — it's **moving wallpaper**. Three layers:
```
L1 BACKDROP baked AI clip / textured render / Lottie (no editable text)
L2 EDITABLE text · logo · colours · images → Remotion inputProps / AE bind.jsx
L3 RENDER composite → MP4 (Remotion OffthreadVideo or Three VideoTexture; AE footage layer)
```
Rules: bake backdrops at the comp's fps/aspect; keep them **dark/low-contrast/abstract** so editable type stays legible; **colour lives in the overlay, not the AI footage**. This is how premium + editability coexist — and it maps onto our two engines directly.
## Per style — the recommended approach
| Style | Approach | Tools |
|---|---|---|
| **Stop-motion** | Frame-step ("on twos" / posterizeTime / reduced fps) + per-frame jitter + paper/grain overlay. Pika's first/last-frame keying is "stop-motion-native". | **Remotion code (free)** · Blender (grease pencil / sim) · Pika (acquire-once) · Wan 2.2 LoRA |
| **Paper-cut** | Layered Z-planes + **real cast shadows** + depth in @remotion/three; CC0 paper **normal/displacement maps**; SVG feTurbulence for organic edges. | **@remotion/three (free)** · ambientCG / Poly Haven (CC0) · Recraft (paper-cut image gen) |
| **Premium motion** | A shared **finish pass** (film grain + DOF + bloom + **LUT colour grade**) + **HDRI lighting** + artist **Lottie** + custom illustration. | **@react-three/postprocessing (free)** · Poly Haven HDRI · **@remotion/lottie** · FLUX+LoRA |
## The tool stack (priority order)
**Free + Iran-safe (do first — biggest ROI, zero licensing/OFAC risk, in our engine):**
1. **`@remotion/lottie`** + **`@remotion/paths`** — artist-made Lottie animations + path morphing. (lottie still NOT installed — flagged repeatedly.)
2. **Craft primitives** — a reusable kit: frame-stepping, jitter, paper-cut planes+shadows, grain.
3. **`@react-three/postprocessing` finish pass** — grain/DOF/bloom/vignette + LUT. One shared component lifts *everything*.
4. **CC0 PBR textures + HDRI** — ambientCG, Poly Haven (paper, cardboard, fabric, light leaks, lighting).
**Self-host (Iran-safe, GPU needed — the live-render upgrade):**
5. **FLUX + LoRA** (image) / **SDXL** — generate style-locked per-scene illustration; LoRA locks a paper-cut/clay/brand style. Permissive licences, runs offline.
6. **Wan 2.2** (Alibaba, **Apache-2.0**, video, ~RTX 4090) — **the only video model that can be in the live pipeline from Iran**; I2V from your own style still; LoRA-trainable.
7. **Blender** (free) — grease pencil 2D, paper-cut rigs, stop-motion sims; render → backdrop.
**Acquire-once via non-Iran intermediary (premium hero clips only, vendor the MP4):**
8. **Kling** (best stylized I2V from a still) · **Seedance 2.0** (best multi-ref consistency — up to 9 refs) · **Pika** (keyframe stop-motion). Paid plans grant commercial use; vendor outputs + ledger them. (Kling retains a backdoor licence to your generated content — fine for backdrops.)
**Reference-only / blocked:** Adobe Firefly, OpenAI/Sora (OFAC + payment); Envato/Storyblocks AE packs (redistribution/competing-service clauses + payment).
## Consistency lever (critical for multi-scene)
**Image-to-video + reference images**, not text-to-video — animate *your* style-locked still so the model never reinvents the look. **LoRA** training locks a character/style across scenes. Seed every scene from the same reference.
## Trends to ride (20252026)
Craft revival (stop-motion/paper-cut **as luxury**) · the **baked-backdrop + editable-overlay** pattern · **I2V + reference** for consistency · **self-hosted open weights** (sovereignty + Iran) · **LoRA + seed** style-lock · a **centralized finish pass**.
## Phased plan
- **P0** — install `@remotion/lottie` + `@remotion/paths`. (free, immediate)
- **P1** — build the **craft-primitives kit** (frame-step, jitter, paper-cut planes, grain). (free)
- **P2** — a shared **FinishPass** (postprocessing + LUT). (free)
- **P3** — vendor **CC0 PBR textures + HDRI** (ambientCG / Poly Haven). (free, VPN fetch)
- **P4** — curate + vendor **Lottie / asset packs** behind the licence gate.
- **P5** — stand up **FLUX + LoRA** self-host for style-locked illustration. (GPU)
- **P6** — the **backdrop architecture** end-to-end (AI/asset clip → editable AE/Remotion overlay → render cache).
- **P7** — self-host **Wan 2.2** (video) + **Blender** (stop-motion/paper-cut sims). (GPU)
**Bottom line:** P0P3 are free, Iran-safe, in our existing Remotion engine, and raise
quality immediately. The AI tier (P5P7) is a **self-hosted-weights** play because of
OFAC — Wan 2.2 + FLUX are the sovereignty path; hosted models are acquire-once-and-vendor.
@@ -0,0 +1,81 @@
import React from "react";
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { FONT } from "../../lib/fonts";
import { hexToRgba, mixHex } 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 BarChart: React.FC<BlockProps> = ({ data, colors, L, index, total, durationInFrames }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const { opacity, slide } = useSceneTransition(durationInFrames, L);
const bars = [1, 2, 3, 4, 5]
.map((i) => ({ label: data[`bar${i}Label`], value: parseFloat(data[`bar${i}Value`] || "") }))
.filter((b) => b.label && b.label.trim() && !Number.isNaN(b.value));
const max = Math.max(1, ...bars.map((b) => b.value));
const chartH = L.pick(L.vmin(440), L.vmin(420), L.vmin(420));
const barW = L.pick(L.vmin(110), L.vmin(100), L.vmin(92));
const gap = L.vmin(36);
return (
<AbsoluteFill style={{ opacity, fontFamily: FONT, backgroundColor: colors.backgroundColor }}>
<ThreeBackdrop colors={colors} />
<AbsoluteFill style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", padding: L.vmin(60) }}>
<div style={{ transform: `translateX(${slide}px)` }}>
<Kicker index={index} total={total} colors={colors} L={L} slide={0} />
</div>
<div style={{ direction: "rtl", textAlign: "center", transform: `translateX(${slide}px)`, fontWeight: 800, fontSize: L.pick(L.vmin(66), L.vmin(60), L.vmin(56)), color: colors.textColor, lineHeight: 1.2, letterSpacing: -0.5, marginBottom: L.vmin(40) }}>
{data.title}
</div>
<div style={{ display: "flex", alignItems: "flex-end", gap, height: chartH }}>
{bars.map((b, i) => {
const sp = spring({ frame: frame - 12 - i * 6, fps, config: { damping: 18, stiffness: 80 } });
const h = interpolate(sp, [0, 1], [0, (b.value / max) * chartH * 0.86]);
const valOp = interpolate(sp, [0.6, 1], [0, 1], { extrapolateLeft: "clamp" });
const fill = i % 2 ? colors.secondaryColor : colors.accentColor;
return (
<div key={i} style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "flex-end", height: chartH }}>
<div style={{ direction: "rtl", opacity: valOp, fontWeight: 800, fontSize: L.vmin(34), color: fill, marginBottom: L.vmin(8) }}>
{faNum(String(b.value))}
</div>
<div style={{ width: barW, height: h, borderRadius: `${L.vmin(16)}px ${L.vmin(16)}px ${L.vmin(4)}px ${L.vmin(4)}px`, background: `linear-gradient(180deg, ${fill}, ${mixHex(fill, colors.backgroundColor, 0.35)})`, boxShadow: `0 ${L.vmin(10)}px ${L.vmin(30)}px ${hexToRgba(fill, 0.3)}` }} />
<div style={{ direction: "rtl", marginTop: L.vmin(14), fontWeight: 600, fontSize: L.vmin(26), color: hexToRgba(colors.textColor, 0.72), maxWidth: barW + gap, textAlign: "center" }}>
{b.label}
</div>
</div>
);
})}
</div>
</AbsoluteFill>
<ProgressDots index={index} total={total} colors={colors} L={L} />
<Vignette />
<Grain />
</AbsoluteFill>
);
};
export const BarChartBlock: SceneBlock = {
id: "BarChart",
label: "نمودار میله‌ای (اینفوگرافیک)",
component: BarChart,
fields: [
{ key: "title", label: "عنوان", type: "text", default: "رشد ما در یک نگاه" },
{ key: "bar1Label", label: "میله ۱ — برچسب", type: "text", default: "۱۴۰۲" },
{ key: "bar1Value", label: "میله ۱ — مقدار", type: "text", default: "45" },
{ key: "bar2Label", label: "میله ۲ — برچسب", type: "text", default: "۱۴۰۳" },
{ key: "bar2Value", label: "میله ۲ — مقدار", type: "text", default: "72" },
{ key: "bar3Label", label: "میله ۳ — برچسب", type: "text", default: "۱۴۰۴" },
{ key: "bar3Value", label: "میله ۳ — مقدار", type: "text", default: "98" },
{ key: "bar4Label", label: "میله ۴ — برچسب", type: "text", default: "" },
{ key: "bar4Value", label: "میله ۴ — مقدار", type: "text", default: "" },
{ key: "bar5Label", label: "میله ۵ — برچسب", type: "text", default: "" },
{ key: "bar5Value", label: "میله ۵ — مقدار", type: "text", default: "" },
],
defaultDurationSec: 5,
minDurationSec: 3,
maxDurationSec: 9,
};
@@ -0,0 +1,89 @@
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, ProgressDots, Kicker, useSceneTransition } from "../chrome";
import type { BlockProps, SceneBlock } from "../types";
const resolveSrc = (u: string) => (/^https?:\/\//.test(u) ? u : staticFile(u));
const DeviceMockup: React.FC<BlockProps> = ({ data, colors, L, index, total, durationInFrames }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const { opacity, slide } = useSceneTransition(durationInFrames, L);
const sp = spring({ frame: frame - 2, fps, config: { damping: 18, stiffness: 90 } });
const deviceScale = interpolate(sp, [0, 1], [0.86, 1]);
const float = Math.sin(frame / 26) * L.vmin(8);
const textSp = spring({ frame: frame - 10, fps, config: { damping: 18, stiffness: 110 } });
const textX = interpolate(textSp, [0, 1], [L.vmin(40), 0]);
const isPhone = (data.device || "phone").toLowerCase() !== "browser";
const src = data.imageUrl ? resolveSrc(data.imageUrl) : null;
const bezel = "#1b1f2e";
const Placeholder = (
<div style={{ width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", background: `linear-gradient(135deg, ${mixHex(colors.accentColor, "#ffffff", 0.5)}, ${mixHex(colors.secondaryColor, "#ffffff", 0.5)})`, color: hexToRgba(colors.textColor, 0.4), fontSize: L.vmin(60) }}>
{isPhone ? "📱" : "🖥"}
</div>
);
const phoneW = L.pick(L.vmin(330), L.vmin(300), L.vmin(360));
const Phone = (
<div style={{ transform: `translateY(${float}px) scale(${deviceScale}) rotate(-2deg)`, width: phoneW, height: phoneW * 2.05, borderRadius: L.vmin(46), background: bezel, padding: L.vmin(10), boxShadow: `0 ${L.vmin(44)}px ${L.vmin(90)}px ${hexToRgba("#0b1020", 0.5)}` }}>
<div style={{ position: "relative", width: "100%", height: "100%", borderRadius: L.vmin(38), overflow: "hidden", background: "#000" }}>
{src ? <Img src={src} style={{ width: "100%", height: "100%", objectFit: "cover" }} /> : Placeholder}
<div style={{ position: "absolute", top: L.vmin(10), left: "50%", transform: "translateX(-50%)", width: L.vmin(80), height: L.vmin(20), borderRadius: 999, background: bezel }} />
</div>
</div>
);
const browserW = L.pick(L.vmin(820), L.vmin(720), L.vmin(760));
const Browser = (
<div style={{ transform: `translateY(${float}px) scale(${deviceScale}) rotate(-1deg)`, width: browserW, borderRadius: L.vmin(18), overflow: "hidden", background: "#fff", boxShadow: `0 ${L.vmin(44)}px ${L.vmin(90)}px ${hexToRgba("#0b1020", 0.4)}` }}>
<div style={{ height: L.vmin(48), background: "#e9ebf2", display: "flex", alignItems: "center", gap: L.vmin(9), padding: `0 ${L.vmin(18)}px` }}>
{["#ff5f57", "#febc2e", "#28c840"].map((c) => (
<span key={c} style={{ width: L.vmin(15), height: L.vmin(15), borderRadius: 999, background: c }} />
))}
<div style={{ marginInlineStart: L.vmin(14), flex: 1, height: L.vmin(26), borderRadius: 999, background: "#fff" }} />
</div>
<div style={{ width: "100%", aspectRatio: "16 / 10", position: "relative", background: "#000" }}>
{src ? <Img src={src} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" }} /> : Placeholder}
</div>
</div>
);
const Text = (
<div style={{ direction: "rtl", textAlign: L.isWide ? "right" : "center", transform: `translateX(${textX}px)`, maxWidth: L.isWide ? L.vmin(560) : L.vmin(900) }}>
<Kicker index={index} total={total} colors={colors} L={L} slide={slide} />
<div style={{ fontWeight: 800, fontSize: L.pick(L.vmin(64), L.vmin(58), L.vmin(56)), color: colors.textColor, lineHeight: 1.2, letterSpacing: -0.5 }}>{data.title}</div>
<div style={{ marginTop: L.vmin(16), fontWeight: 400, fontSize: L.pick(L.vmin(31), L.vmin(30), L.vmin(29)), color: hexToRgba(colors.textColor, 0.64), lineHeight: 1.7 }}>{data.caption}</div>
</div>
);
return (
<AbsoluteFill style={{ opacity, fontFamily: FONT, backgroundColor: colors.backgroundColor }}>
<ThreeBackdrop colors={colors} />
<AbsoluteFill style={{ display: "flex", flexDirection: L.isWide ? "row-reverse" : "column", alignItems: "center", justifyContent: "center", gap: L.pick(L.vmin(64), L.vmin(40), L.vmin(44)), padding: L.vmin(64) }}>
{isPhone ? Phone : Browser}
{Text}
</AbsoluteFill>
<ProgressDots index={index} total={total} colors={colors} L={L} />
<Vignette />
<Grain />
</AbsoluteFill>
);
};
export const DeviceMockupBlock: SceneBlock = {
id: "DeviceMockup",
label: "نمایش روی دستگاه (اپ/وب)",
component: DeviceMockup,
fields: [
{ key: "imageUrl", label: "تصویر صفحه (اسکرین‌شات)", type: "image", default: "" },
{ key: "device", label: "دستگاه (phone/browser)", type: "text", default: "phone" },
{ key: "title", label: "عنوان", type: "text", default: "اپلیکیشن شما" },
{ key: "caption", label: "توضیح", type: "text", default: "تجربه‌ای روان، سریع و زیبا روی هر دستگاه", multiline: true },
],
defaultDurationSec: 5,
minDurationSec: 3,
maxDurationSec: 9,
};
@@ -0,0 +1,73 @@
import React from "react";
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { FONT } from "../../lib/fonts";
import { mixHex, rand } from "../../lib/anim";
import { Grain, Vignette, useSceneTransition } from "../chrome";
import type { BlockProps, SceneBlock } from "../types";
// Stomp = punchy beat-synced typography: each word slams in (scale overshoot +
// shake), filling the frame, alternating accent/bg flashes. High-energy opener.
const Stomp: React.FC<BlockProps> = ({ data, colors, L, durationInFrames }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const { opacity } = useSceneTransition(durationInFrames, L);
const words = [data.line1, data.line2, data.line3, data.line4].filter((w) => w && w.trim());
const n = Math.max(1, words.length);
const slot = durationInFrames / n;
const idx = Math.min(n - 1, Math.floor(frame / slot));
const local = frame - idx * slot;
// stomp impact at each slot start
const sp = spring({ frame: local, fps, config: { damping: 9, stiffness: 220 } });
const scale = interpolate(sp, [0, 1], [1.5, 1]);
const shake = local < 8 ? (rand(idx * 7 + local) - 0.5) * L.vmin(14) * (1 - local / 8) : 0;
const flash = interpolate(local, [0, 5], [1, 0], { extrapolateRight: "clamp" });
const tilt = idx % 2 ? -3 : 3;
const bg = mixHex(colors.backgroundColor, "#000000", 0.25);
return (
<AbsoluteFill style={{ opacity, fontFamily: FONT, backgroundColor: bg, overflow: "hidden" }}>
{/* impact flash */}
<AbsoluteFill style={{ backgroundColor: colors.accentColor, opacity: flash * 0.5 }} />
{/* impact bars */}
<AbsoluteFill style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ position: "absolute", width: "120%", height: L.vmin(8), background: colors.accentColor, transform: `scaleX(${interpolate(sp, [0, 1], [0, 1])})` }} />
</AbsoluteFill>
<AbsoluteFill style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: L.vmin(50) }}>
<div
style={{
direction: "rtl",
textAlign: "center",
transform: `translateX(${shake}px) scale(${scale}) rotate(${tilt}deg)`,
fontWeight: 900,
fontSize: L.pick(L.vmin(150), L.vmin(130), L.vmin(120)),
color: idx % 2 ? colors.accentColor : colors.textColor,
lineHeight: 1,
letterSpacing: -2,
textShadow: `0 ${L.vmin(8)}px 0 ${mixHex(colors.backgroundColor, "#000000", 0.5)}`,
}}
>
{words[idx]}
</div>
</AbsoluteFill>
<Vignette />
<Grain />
</AbsoluteFill>
);
};
export const StompBlock: SceneBlock = {
id: "Stomp",
label: "استامپ (تایپوگرافی ضربه‌ای)",
component: Stomp,
fields: [
{ key: "line1", label: "کلمه ۱", type: "text", default: "جسور" },
{ key: "line2", label: "کلمه ۲", type: "text", default: "سریع" },
{ key: "line3", label: "کلمه ۳", type: "text", default: "حرفه‌ای" },
{ key: "line4", label: "کلمه ۴", type: "text", default: "" },
],
defaultDurationSec: 4,
minDurationSec: 2,
maxDurationSec: 7,
};
+6
View File
@@ -7,6 +7,9 @@ import { SlideshowBlock } from "./blocks/Slideshow";
import { OutroCTABlock } from "./blocks/OutroCTA";
import { LogoRevealBlock } from "./blocks/LogoReveal";
import { StatCounterBlock } from "./blocks/StatCounter";
import { BarChartBlock } from "./blocks/BarChart";
import { StompBlock } from "./blocks/Stomp";
import { DeviceMockupBlock } from "./blocks/DeviceMockup";
import { ProductShowcaseBlock } from "./blocks/ProductShowcase";
/**
@@ -24,6 +27,9 @@ export const SCENE_BLOCKS: Record<string, SceneBlock> = {
[LogoRevealBlock.id]: LogoRevealBlock,
[StatCounterBlock.id]: StatCounterBlock,
[ProductShowcaseBlock.id]: ProductShowcaseBlock,
[BarChartBlock.id]: BarChartBlock,
[StompBlock.id]: StompBlock,
[DeviceMockupBlock.id]: DeviceMockupBlock,
};
export const BLOCK_LIST = Object.values(SCENE_BLOCKS);