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:
@@ -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 (2025–2026)
|
||||||
|
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:** P0–P3 are free, Iran-safe, in our existing Remotion engine, and raise
|
||||||
|
quality immediately. The AI tier (P5–P7) 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,
|
||||||
|
};
|
||||||
@@ -7,6 +7,9 @@ import { SlideshowBlock } from "./blocks/Slideshow";
|
|||||||
import { OutroCTABlock } from "./blocks/OutroCTA";
|
import { OutroCTABlock } from "./blocks/OutroCTA";
|
||||||
import { LogoRevealBlock } from "./blocks/LogoReveal";
|
import { LogoRevealBlock } from "./blocks/LogoReveal";
|
||||||
import { StatCounterBlock } from "./blocks/StatCounter";
|
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";
|
import { ProductShowcaseBlock } from "./blocks/ProductShowcase";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +27,9 @@ export const SCENE_BLOCKS: Record<string, SceneBlock> = {
|
|||||||
[LogoRevealBlock.id]: LogoRevealBlock,
|
[LogoRevealBlock.id]: LogoRevealBlock,
|
||||||
[StatCounterBlock.id]: StatCounterBlock,
|
[StatCounterBlock.id]: StatCounterBlock,
|
||||||
[ProductShowcaseBlock.id]: ProductShowcaseBlock,
|
[ProductShowcaseBlock.id]: ProductShowcaseBlock,
|
||||||
|
[BarChartBlock.id]: BarChartBlock,
|
||||||
|
[StompBlock.id]: StompBlock,
|
||||||
|
[DeviceMockupBlock.id]: DeviceMockupBlock,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BLOCK_LIST = Object.values(SCENE_BLOCKS);
|
export const BLOCK_LIST = Object.values(SCENE_BLOCKS);
|
||||||
|
|||||||
Reference in New Issue
Block a user