Compare commits
46 Commits
896ce3dfa9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 62ea110605 | |||
| 3748b1c8d8 | |||
| dc1fe11604 | |||
| dc5ff09b67 | |||
| 40fdcf280f | |||
| 6814e64593 | |||
| a36e96d933 | |||
| 21b6a30f08 | |||
| 7725c13771 | |||
| 38229185a7 | |||
| 7ed2ccc414 | |||
| 8c4bc2c626 | |||
| b1a51cb01b | |||
| 8f34c3175f | |||
| 866edbff8c | |||
| 055d8365fe | |||
| e4fd936953 | |||
| 825f25be55 | |||
| 4bac5154ed | |||
| de8849bd94 | |||
| 7394c5ce78 | |||
| a48633741e | |||
| 3eab1056c8 | |||
| c0d04fa855 | |||
| c1747167f3 | |||
| 383331e8f1 | |||
| 8582e956c9 | |||
| 8ddca5647b | |||
| f8ea9af3b6 | |||
| 2104dd3c84 | |||
| d830c56ea0 | |||
| fd364209e7 | |||
| cb6512fee3 | |||
| a3152ee84f | |||
| 863b9503b3 | |||
| 60759f35b4 | |||
| 1795bc855b | |||
| f83d657844 | |||
| cb11c177a7 | |||
| af3c73c560 | |||
| 4f04f6bf75 | |||
| b9b91397b0 | |||
| 6d79ddb8d1 | |||
| 23d1fd8fb1 | |||
| 376cdf6a1c | |||
| ec51e87d2d |
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: flat-artist
|
||||
description: >-
|
||||
The master entry point and professional motion-design persona for creating FlatRender video
|
||||
templates with Remotion + Three.js. INVOKE THIS FIRST whenever you start, design, build, review,
|
||||
or seed a FlatRender template — or whenever "Flat Artist", "flatrender template", "بساز/طراحی قالب",
|
||||
a new logo reveal / promo / greeting / intro, or any template work is mentioned. It activates the
|
||||
whole template-creation skill suite (design, animation, characters, aspect ratios, composition,
|
||||
color/SVG, fonts, hooks, transitions, effects, music, SFX, assets, SEO) as ONE professional
|
||||
pipeline and tells you which sub-skill to apply at each step, to a Renderforest / After-Effects
|
||||
quality bar. Persian-first.
|
||||
---
|
||||
|
||||
# Flat Artist — the FlatRender template studio
|
||||
|
||||
You are **Flat Artist**: a professional motion-design artist building FlatRender templates to a
|
||||
Renderforest / After-Effects bar. When this skill is active, you ALWAYS work the full pipeline below
|
||||
and apply the sub-skills — never ship "basic". Stack: `services/remotion/` (Remotion 4 +
|
||||
`@remotion/three`, R3F v9, `gl="angle"`). Persian (fa) is canonical; English mirrors it 1:1.
|
||||
|
||||
## How to use this skill
|
||||
This skill BUNDLES the whole suite: every sub-skill is a folder beside this file, at
|
||||
`<skill-name>/SKILL.md` (relative to this `flat-artist/SKILL.md`). You don't invoke them as
|
||||
separate skills — you **read the bundled file** for the phase you're on and apply it.
|
||||
1. Read this file's pipeline + rules.
|
||||
2. At each phase, open the bundled sub-skill at **`<skill-name>/SKILL.md`** (e.g. `motion-design-principles/SKILL.md`) and apply it.
|
||||
3. The deep R&D reference is **`references/design-motion-rnd.md`** (bundled here — trends, craft, asset pipeline, masterpiece + platform playbook); consult it for art direction and the masterpiece bar.
|
||||
|
||||
## Non-negotiable rules (apply on EVERY template)
|
||||
- **Pure frames:** animate ONLY off `useCurrentFrame()`. Never `useFrame` (R3F), `Math.random()`, `Date.now()`, `setState`/`useEffect`-driven motion. Determinism via `rand(i)` (`lib/anim.ts`).
|
||||
- **3D:** `@remotion/three` only; reuse `lib/three-kit.tsx` (StudioEnv/Lights/Floor/Effects, Confetti3D); drive camera/objects from `frame`.
|
||||
- **Three real aspects:** 16:9 / 1:1 / 9:16 must **re-flow, never letterbox**. Design tall-first; branch layout with `useLayout()` + `pick(wide,square,tall)` (`lib/aspect.ts`). Verify by rendering a still in ALL THREE.
|
||||
- **Persian-first:** `FONT` (Vazirmatn), `direction:"rtl"`; split kinetic text by **word, not char**; preserve ZWNJ; Persian numerals where apt.
|
||||
- **Recolorable:** every themeable color comes from `colorSchema` props (accent/secondary/background/text in `lib/branding.ts`) — no hardcoded hex on editable elements.
|
||||
- **Editable = a prop + a seeded element:** text/logo/image keys MUST equal the Zod schema field; seed via `scripts/seed_remotion_templates.py`.
|
||||
- **Vendored assets (Iran iron rule):** every asset committed into `services/remotion/public/` + `staticFile()`; NEVER a CDN URL at render. License firewall (CC0/commercial-ok, tracked in `assets.json`).
|
||||
- **Finish it:** a masterpiece = the 8 layers below, not one big move. Render → LOOK → refine.
|
||||
|
||||
## The pipeline (work in order; read the bundled skill named at each step)
|
||||
|
||||
| # | Phase | Apply skill |
|
||||
|---|---|---|
|
||||
| 0 | **Scope & storyboard** — pick the template TYPE/pattern; confirm a storyboard with the user before building anything character- or scene-heavy | `remotion-template-catalog/SKILL.md` |
|
||||
| 1 | **Art direction** — choose ONE coherent style + palette from current trends | `remotion-design-styles/SKILL.md` + R&D report |
|
||||
| 2 | **Hook** — design the scroll-stopping first 1–3s (it's the cover frame) | `video-hooks/SKILL.md` |
|
||||
| 3 | **Characters** (if any) — build/rig from SVG or 3D primitives | `remotion-character-design/SKILL.md` |
|
||||
| 4 | **Build the composition** — lib helpers + `three-kit` for 3D | (this skill) |
|
||||
| 5 | **Motion** — easing/timing/stagger/secondary motion (the foundation) | `motion-design-principles/SKILL.md` |
|
||||
| 6 | **Kinetic type** — animate the hero/caption text (Persian word-split) | `kinetic-typography/SKILL.md` |
|
||||
| 7 | **Transitions** — scene-to-scene choreography, seamless | `scene-transitions/SKILL.md` |
|
||||
| 8 | **Effects** — grain, bokeh, light-leaks, sparkles, glow, vignette (deterministic) | `particles-and-effects/SKILL.md` |
|
||||
| 9 | **Aspect re-flow** — make it truly fit 16:9/1:1/9:16 | `remotion-aspect-ratios/SKILL.md` |
|
||||
| 10 | **Composition & elements** — hierarchy, logo/image/copy, reveal pacing | `remotion-template-composition/SKILL.md` |
|
||||
| 11 | **Color / live recolor** — wire color props + SVG color preview | `remotion-svg-colors/SKILL.md` |
|
||||
| 12 | **Fonts** — pick Persian-first type by role | `persian-fonts/SKILL.md` |
|
||||
| 13 | **Assets / footage** — source, license, prepare, composite | `asset-sourcing/SKILL.md` |
|
||||
| 14 | **Music + SFX** — beat-sync reveals, place SFX, duck | `remotion-music-picker/SKILL.md` + `remotion-sound-effects/SKILL.md` |
|
||||
| 15 | **QA — masterpiece gate** — the 8 layers + pre-ship checklist (below) | (this skill) |
|
||||
| 16 | **SEO & taxonomy** — category, tags, keywords, slug, copy, related | `flatrender-template-seo/SKILL.md` |
|
||||
| 17 | **Ship** — render 3 thumbnails + preview, seed, deploy (copy assets into the running container + restart so Next re-scans `public/`) | (this skill) |
|
||||
|
||||
## The masterpiece bar (8 production-value layers — finish ALL)
|
||||
1. Sound design + beat-sync (hero on a downbeat; whoosh/thump/sparkle/riser; ducking; silence before the hero hit).
|
||||
2. Micro-detail (no linear easing; overshoot & settle; 2–5f staggered entrances; secondary motion; anticipation).
|
||||
3. Design system (one type scale, one spacing rhythm, constrained palette, consistent radii/strokes/elevation).
|
||||
4. Depth & lighting (parallax layers, one consistent light direction, atmospheric bg blur, rim light on hero).
|
||||
5. Color grade (one unifying grade over the whole comp; user hex still passes through it).
|
||||
6. Pacing/rhythm (vary cut length, build to a climax, trim ruthlessly).
|
||||
7. A clear hero moment (one peak with the biggest motion + strongest hit).
|
||||
8. Finishing texture (subtle grain, gentle vignette, 1–2px chromatic aberration at impacts, tiny continuous camera drift, motion blur on fast elements).
|
||||
|
||||
## Pre-ship checklist (if you can't tick it, it's not done)
|
||||
- [ ] No linear easing; staggered entrances; ≥1 overshoot-and-settle; nothing pops on/off without a transition.
|
||||
- [ ] Verified in 16:9 / 1:1 / 9:16 (re-flowed, not letterboxed); long Persian text doesn't crop; short text doesn't look empty.
|
||||
- [ ] Hook lands in the first 1–2s; first frame works as the cover.
|
||||
- [ ] One unmistakable hero moment; consistent light direction + a unifying grade.
|
||||
- [ ] Subtle grain/vignette; frame feels alive; (audio: beat-synced + ducked if present).
|
||||
- [ ] All editable fields (text/logo/image/colors) swap without breaking layout; colors from props.
|
||||
- [ ] Clean render at target res (no flicker/z-fighting/font fallback); assets vendored + licensed.
|
||||
- [ ] Category/tags/keywords/slug + fa+en copy set (`flatrender-template-seo/SKILL.md`).
|
||||
|
||||
## Project map
|
||||
- Compositions: `services/remotion/src/compositions/`, registered in `src/templates.tsx` (`TemplateDef`, all 3 aspects).
|
||||
- Helpers: `lib/anim.ts`, `lib/aspect.ts` (`useLayout`/`pick`), `lib/branding.ts` (`colorSchema`/`BRAND`), `lib/fonts.ts` (`FONT`), `lib/three-kit.tsx`.
|
||||
- Seed: `scripts/seed_remotion_templates.py` (containers/projects/scenes/colors + `MEDIA` for image fields).
|
||||
- Render checks: `npx remotion still src/index.ts "<Comp>-16x9|1x1|9x16" out/x.png --frame=NN`.
|
||||
- Bundled here: each sub-skill at `<name>/SKILL.md`; the deep R&D at `references/design-motion-rnd.md`.
|
||||
|
||||
Bundled sub-skills (read each at its `SKILL.md`): `remotion-template-catalog/SKILL.md`, `remotion-design-styles/SKILL.md`, `video-hooks/SKILL.md`, `remotion-character-design/SKILL.md`, `motion-design-principles/SKILL.md`, `kinetic-typography/SKILL.md`, `scene-transitions/SKILL.md`, `particles-and-effects/SKILL.md`, `remotion-aspect-ratios/SKILL.md`, `remotion-template-composition/SKILL.md`, `remotion-svg-colors/SKILL.md`, `persian-fonts/SKILL.md`, `asset-sourcing/SKILL.md`, `remotion-music-picker/SKILL.md`, `remotion-sound-effects/SKILL.md`, `flatrender-template-seo/SKILL.md`.
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: asset-sourcing
|
||||
description: How to source, license, AI-generate, prepare, and organize royalty-free assets (footage, images, textures, HDRIs, GLTF/GLB, icons, illustrations) for FlatRender Remotion templates — Iran-aware (geo-blocks), vendored-only, license-firewalled. Use when a template needs real media, when downloading/committing assets into public/, when grading/masking/looping footage or compositing it via Video/OffthreadVideo/Img/staticFile + Ken-Burns, or when generating bespoke assets with local AI models.
|
||||
---
|
||||
|
||||
# Asset sourcing for templates
|
||||
|
||||
Project: `services/remotion/`. Helpers: `src/lib/anim.ts` (`hexToRgba`, `mixHex`, `rand`), `src/lib/aspect.ts` (`useLayout` → `isWide/isSquare/isTall`, `vmin`, `unit`), `src/lib/branding.ts` (`colorSchema`, `BRAND`), `src/lib/fonts.ts` (`FONT` = Vazirmatn, RTL), `src/lib/three-kit.tsx` (`StudioEnv/Lights/Floor/Effects`, `Confetti3D`). Render is **headless Chrome in Docker** — every value derives from `useCurrentFrame()` (never `Math.random`/`Date.now`/`useFrame`; use `rand(i)`).
|
||||
|
||||
## The Iron Rule — vendor everything
|
||||
The Iran environment punishes runtime dependencies. **Download once (VPN if needed), commit into `public/`, reference with `staticFile()`.** Never put `https://…` in a shipped template — a geo-block or flaky tunnel kills the render mid-frame. Mirror npm/NuGet/Docker via Nexus (`mirror.soroushasadi.com`); asset *binaries* are sourced by hand. **Record the license at acquisition time, not later.** `public/` today holds only `fonts/` — you build the rest.
|
||||
|
||||
## License taxonomy (know cold — this is the firewall)
|
||||
| Class | Examples | Ship? |
|
||||
|---|---|---|
|
||||
| CC0 / Public Domain / Pixabay / Pexels / Unsplash | Poly Haven, ambientCG, Kenney, Mixkit | ✅ free, no credit — **default target** |
|
||||
| CC-BY | many Sketchfab, Bensound | ⚠️ ship only with a tracked on-screen/end-card credit |
|
||||
| CC-BY-SA | some Wikimedia | ❌ share-alike can infect our proprietary template |
|
||||
| CC-BY-NC | "free for personal" tiers | ❌ we are a **paid** product = commercial |
|
||||
| Editorial / rights-managed | news/celebrity stock | ❌ |
|
||||
| Paid stock | Envato, Adobe, Shutterstock | ✅ per license — **keep the receipt/PDF** |
|
||||
|
||||
No license row = unknown license = **do not ship**.
|
||||
|
||||
## Sourcing map (CC0 / no-attribution first) + Iran access
|
||||
| Type | Best CC0 sources | Commit to | Iran access |
|
||||
|---|---|---|---|
|
||||
| Footage (H.264 MP4, right-sized) | Pexels Video, Pixabay Video, Mixkit, Coverr, Videvo (filter CC0) | `public/footage/{nature,business,abstract}/` | Pixabay/Mixkit/Coverr OK; Pexels VPN-ish |
|
||||
| Images | Pexels, Pixabay, Unsplash, StockSnap, Burst | `public/images/` | Pixabay OK; Pexels/Unsplash VPN-ish |
|
||||
| Textures / overlays | Poly Haven, ambientCG; grain/light-leak/dust CC0 clips | `public/textures/`, `public/overlays/` | OK |
|
||||
| HDRIs (1k–2k for render speed) | Poly Haven, ambientCG | `public/hdri/` | OK |
|
||||
| 3D (**prefer GLB** over glTF+textures) | Poly Haven Models, Kenney, Khronos glTF samples, Sketchfab (check each) | `public/models/` | Poly Haven OK; Sketchfab VPN-ish |
|
||||
| Icons (bundle via Nexus npm, **never CDN**) | Lucide, Tabler, Heroicons, Phosphor | npm dep | npm via Nexus OK |
|
||||
| Illustrations (recolorable **SVG**) | unDraw, Open Peeps, Humaaans | `public/illustrations/` | OK |
|
||||
|
||||
For Persian/Iran imagery search English terms ("Tehran", "Iranian food") + self-shot/local stock. **Sanction-blocked at account/payment: Adobe Stock, Envato** — use a foreign account/partner or skip. **Mitigation: do one batched "asset run" over a stable tunnel, commit binaries, render never touches the open internet again.** Draco-compress GLBs (`gltf-pipeline -i in.glb -o out.glb -d`), keep low-poly for headless render speed.
|
||||
|
||||
## AI-generated assets — when it's right
|
||||
- **Use when:** the asset doesn't exist as stock (specific Persian cultural scene, branded mascot), you need consistency across a template set (reference-image control), or it beats a 5-site license hunt.
|
||||
- **Don't when:** clean CC0 already exists, you need photographic authenticity, or a free tier's **commercial license is unclear** (watermarks / non-commercial = legal landmine for a paid product).
|
||||
- **Iran-pragmatic:** self-host open models — **HunyuanVideo 1.5** (~RTX 4090, no geo-block/payment/watermark) for video; **FLUX/SDXL** locally for image/texture/illustration. Hosted SaaS (Runway, Kling) only when local quality falls short and a VPN+foreign-account path exists. **Always record prompt + tool + plan-tier + date** in the asset's `.license.txt` sidecar.
|
||||
|
||||
## Preparing footage in Remotion (composite, grade, mask, loop)
|
||||
**Primitives:** `<OffthreadVideo>` = default for **all** video in a render (FFmpeg extraction, deterministic, no seek drift). `<Video>` = preview only. `<Img>` over raw `<img>` (waits for load → no half-loaded frames). `staticFile()` for every vendored asset.
|
||||
|
||||
```tsx
|
||||
import { OffthreadVideo, Img, staticFile, useCurrentFrame, interpolate, Easing } from "remotion";
|
||||
import { useLayout } from "./lib/aspect";
|
||||
|
||||
const frame = useCurrentFrame();
|
||||
const L = useLayout();
|
||||
|
||||
// Ken-Burns: overscan ≥1 so no edges reveal; cover + center crops cleanly in all 3 aspects.
|
||||
const scale = interpolate(frame, [0, 150], [1.08, 1.2], { extrapolateRight: "clamp" });
|
||||
const ty = interpolate(frame, [0, 150], [0, L.vmin(-30)], { easing: Easing.out(Easing.cubic), extrapolateRight: "clamp" });
|
||||
|
||||
<OffthreadVideo
|
||||
src={staticFile("footage/nature/forest-loop.mp4")}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover",
|
||||
transform: `scale(${scale}) translateY(${ty}px)`,
|
||||
filter: "contrast(1.08) saturate(1.15) brightness(0.96)" }} // grade
|
||||
/>
|
||||
```
|
||||
|
||||
| Job | Pattern |
|
||||
|---|---|
|
||||
| **Color grade** | per-layer CSS `filter` (`contrast/saturate/brightness/hue-rotate`); build a shared `lib/grades.ts` (`warm`, `teal-orange`, `mono`, `filmic`) so palette can drive `hue-rotate`/`saturate`. Heavy grade → pre-grade in DaVinci Resolve (free), then commit. |
|
||||
| **Masking / keying** | no native keyer — pre-key in Resolve/AE, export **alpha** (ProRes 4444 or WebM/VP9 alpha), then `<OffthreadVideo>`. Shape masks via CSS `maskImage`/`clipPath` + `hexToRgba` gradients, or SVG `<mask>`. |
|
||||
| **Seamless loop** | source loop-designed clips (Coverr/Mixkit) or mirror-pingpong; `<OffthreadVideo loop>` once first/last frames match; crossfade-to-self with overlapping `<Sequence>` for imperfect footage. |
|
||||
| **Overlays (cheap "authentic" layer)** | stack grayscale-on-black/white clips: **screen** for light-leaks/bokeh/dust, **overlay/soft-light** for grain, **multiply** for vignettes/paper. Keep palette-independent. **Animated grain must move** — offset `background-position` per frame or jitter SVG `feTurbulence` `seed`. |
|
||||
| **Per-aspect crop** | `objectFit:"cover"` + center-safe framing; branch focal point on `L.isWide/isSquare/isTall` (or the proposed `L.pick(wide,square,tall)`) so the subject never crops out. |
|
||||
|
||||
HDRIs/GLBs: feed `staticFile("hdri/…")` into `three-kit`'s `StudioEnv`; load models with `useGLTF(staticFile("models/…glb"))`, idle-bob with `Math.sin(frame/fps)` (driven by `useCurrentFrame`, **not** `useFrame`).
|
||||
|
||||
## Library structure + attribution firewall
|
||||
Create under `public/`: `footage/{nature,business,abstract}/`, `overlays/`, `images/`, `textures/`, `hdri/`, `models/`, `icons/`, `illustrations/`, plus **`assets.json`** + **`ASSETS.md`**. Lowercase-kebab names, no spaces. Every asset gets one `assets.json` row **at download time**:
|
||||
|
||||
```json
|
||||
{ "file": "footage/nature/forest-loop.mp4", "source": "Pexels",
|
||||
"url": "https://www.pexels.com/video/...", "author": "Name",
|
||||
"license": "Pexels", "attribution_required": false, "commercial_ok": true,
|
||||
"acquired": "2026-06-21", "notes": "1080p H.264, loops clean" }
|
||||
```
|
||||
Sidecar `.license.txt` next to AI assets (prompt + tool + date) and paid receipts. A **CI validation script** asserts every file in the media folders has a matching row with `commercial_ok:true`, else fails the build — this is the firewall. `ASSETS.md` is the generated human/legal-readable table. `attribution_required:true` must surface a credit on a shippable surface (end-card/footer). If the repo bloats, move large media to MinIO (already in stack) with a `predeploy` sync into `public/` — but present at render time.
|
||||
|
||||
## Checklist (before committing an asset / shipping a template)
|
||||
- [ ] Vendored in `public/…` and referenced via `staticFile()` — **no external URL** anywhere in the template.
|
||||
- [ ] `assets.json` row added with `commercial_ok:true`; `.license.txt`/receipt for AI/paid; CC-BY credits surfaced.
|
||||
- [ ] Right-sized (don't ship 4K into a 1080p comp); video is H.264 MP4 played via `<OffthreadVideo>`; images via `<Img>`.
|
||||
- [ ] GLB (not glTF+loose textures), Draco-compressed, low-poly; HDRI 1k–2k.
|
||||
- [ ] Footage graded through `lib/grades.ts`; overlay grain/light-leak **moves** per frame; loops are seamless.
|
||||
- [ ] Ken-Burns overscans (start scale ≥ 1.05) and `objectFit:cover` crops cleanly in 16:9 / 1:1 / 9:16 with subject in frame.
|
||||
- [ ] Re-render twice → identical (deterministic; nothing pulled from network/random/date).
|
||||
|
||||
Related: `../remotion-design-styles/SKILL.md`, `../remotion-template-composition/SKILL.md`, `../remotion-aspect-ratios/SKILL.md`, `../remotion-character-design/SKILL.md`, `../remotion-svg-colors/SKILL.md`, `../remotion-music-picker/SKILL.md`, `../remotion-sound-effects/SKILL.md`, `../persian-fonts/SKILL.md`, `../flatrender-template-seo/SKILL.md`.
|
||||
@@ -0,0 +1,171 @@
|
||||
---
|
||||
name: flatrender-template-seo
|
||||
description: >-
|
||||
Classifies and merchandises a FlatRender video template when an admin or developer creates or
|
||||
publishes it. Decides the single primary category, the faceted tags, the SEO title/description/
|
||||
keywords/slug, the Persian-first presentation copy, and the related-templates set — every choice
|
||||
driven by the template's actual design, editable content, and usability. Use whenever creating,
|
||||
editing, publishing, or seeding a template (project_container) and you must fill category_ids,
|
||||
tag_ids, keywords, slug, description, news_text, or write its catalog/detail copy. Persian (fa) is
|
||||
the source of truth; English (en) mirrors it 1:1.
|
||||
---
|
||||
|
||||
# FlatRender Template SEO & Taxonomy
|
||||
|
||||
Decide a template's category, tags, SEO, presentation copy, and related set — grounded in what the
|
||||
template *actually is*, not what you wish it ranked for. **fa is canonical; en mirrors it.**
|
||||
|
||||
## When to use
|
||||
- An admin creates/edits a template in `TemplatesAdmin.tsx`, or you set fields on `content.project_containers`.
|
||||
- You run/extend `scripts/seed_remotion_templates.py` (which currently leaves SEO + taxonomy EMPTY — see below).
|
||||
- You publish (`is_published = true`) and need the template to be findable and well-presented.
|
||||
|
||||
Cross-reference: `../remotion-template-catalog/SKILL.md` (template TYPE → pattern), `../remotion-template-composition/SKILL.md`
|
||||
(what's editable, used to write "what you can customize"), `../persian-fonts/SKILL.md` (RTL copy & Persian numerals).
|
||||
|
||||
## The real data model (use these exact names — do not invent fields)
|
||||
|
||||
Template = `content.project_containers`. Admin sets via `TemplatesAdmin.tsx` → `POST/PUT /v1/templates`
|
||||
(`Create/UpdateContainerRequest`). Settable, SEO/taxonomy-relevant fields:
|
||||
|
||||
| Purpose | Admin label | form key | DTO | DB column |
|
||||
|---|---|---|---|---|
|
||||
| Name (→ H1) | نام | `name` | `Name` | `name` |
|
||||
| Slug | اسلاگ | `slug` | `Slug` | `slug` (CITEXT UNIQUE) |
|
||||
| Description / presentation copy | توضیحات | `description` | `Description` | `description` |
|
||||
| Keywords (opaque free text) | کلمات کلیدی (سئو) | `keywords` | `Keywords` | `keywords` (TEXT) |
|
||||
| Announcement (NOT seo) | متن خبر | `news_text` | `NewsText` | `news_text` |
|
||||
| Categories (M2M) | دستهبندیها | `category_ids[]` | `CategoryIds` | `container_categories` |
|
||||
| Tags (M2M) | برچسبها | `tag_ids[]` | `TagIds` | `container_tags` |
|
||||
| Primary mode | حالت اصلی | `primary_mode` | `PrimaryMode` | `primary_mode` enum |
|
||||
|
||||
**Hard constraints from the platform (work within these):**
|
||||
- **No `meta_title` / `meta_description` on a template.** The only SEO string on a container is `keywords`.
|
||||
Per-page `meta_title`/`meta_description`/`meta_keywords`/`bot_follow` exist ONLY on `content.categories`
|
||||
(and on `blogs`). Workaround: put the page's discoverable meta intent into `keywords`; pack the human
|
||||
title/description into `name` + `description`; lift the rest of the meta from the **assigned category's**
|
||||
`meta_*` fields (so choose the category deliberately — its SEO is inherited).
|
||||
- **The public detail page emits title-only metadata** (`templates/[id]/page.tsx` → `{ title: "${name} — FlatRender" }`),
|
||||
and the public mapper (`admin-api.ts` `containerToAdminProject`) DROPS `keywords`, `coverImageUrl`→OG, and
|
||||
hardcodes `categoryName: undefined`. So today your `keywords`/category never reach the public `<head>`.
|
||||
Still fill them: they power the backend list filters and are the fix-point when the frontend SEO gap is closed.
|
||||
When you can, also fix the mapper to surface `keywords` + cover OG; otherwise flag it.
|
||||
- **No related-templates table/column/endpoint exists.** "Related" is computed by re-querying
|
||||
`GET /v1/templates?categoryId={guid}` or `?tagSlug={slug}` and excluding the current template.
|
||||
There is no precomputed set — pick the category/tags such that this query returns good neighbors.
|
||||
- **Tags are not shown anywhere in the templates UI** today, and the list page ignores the real DB
|
||||
categories: the sidebar is ~12 hardcoded buckets (11 real categories + `"all"`), and because the
|
||||
public mapper hardcodes `categoryName: undefined`, every template defaults to `"social"`. Tags/categories
|
||||
still matter for the backend filters and future UI — set them correctly regardless.
|
||||
- `demo_script_tag` exists in DB + DTO but is NOT in the admin form — you cannot set it there.
|
||||
- Categories are a tree (`parent_id`); `GET /v1/categories` only eager-loads one child level. Keep the
|
||||
primary set flat (8–12). Tags have `applies_to_mode`; categories do not.
|
||||
|
||||
## Step-by-step decision process
|
||||
|
||||
Run this for every template, in order. Each step feeds the next.
|
||||
|
||||
**0. Read the design + content + usability.** What does it output (format)? What's editable (text/colors/
|
||||
logo/images/music — from `../remotion-template-composition/SKILL.md`)? Who hires it and when? 2D or 3D (real Remotion
|
||||
build: SVG vs `@remotion/three`)? Which aspects exist (16:9 / 1:1 / 9:16 child projects)?
|
||||
|
||||
**1. Category — pick exactly ONE primary.** Category = the *output format*, never the occasion/industry.
|
||||
Test: which single shelf would a user expect this on? If the answer is a format (logo reveal, story, slideshow)
|
||||
it's the category; if it's when/why/who (Nowruz, real estate, teens) that's a TAG. Use the flat set:
|
||||
اینترو و لوگو / Intro & Logo · استوری و ریلز / Story & Reels · پست شبکه اجتماعی / Social Post ·
|
||||
تبلیغ و پروموشن / Promo & Ad · اسلایدشو / Slideshow · معرفی محصول / Product Showcase ·
|
||||
دعوتنامه و مناسبت / Invitation & Event · تیتراژ و زیرنویس / Titles & Lower-thirds ·
|
||||
ارائه و اینفوگرافیک / Presentation & Infographic · یوتیوب و اینترو کانال / YouTube & Channel.
|
||||
No "Other"; no category with <5 templates. → set `category_ids` (the primary first; its `sort` = index 0).
|
||||
|
||||
**2. Tags — 6–12, from a controlled fa↔en vocabulary**, each TRUE of this design, across facets:
|
||||
use-case · occasion · industry · style/aesthetic · aspect ratio · color/mood · 2D-3D · audience.
|
||||
Empty facets are fine — don't invent tags. Aspect ratio is a TAG (one template, three outputs), never a
|
||||
separate catalog entry. Tag 3D only if it really renders 3D. Reuse existing `content.tags` (match by
|
||||
`slug`/`latin_name`); create a new tag only as a deliberate dictionary edit. → set `tag_ids`.
|
||||
|
||||
**3. SEO title / description / keywords / slug.**
|
||||
- **Title** (lives in `name`): `{Template Name} | {Category} {use/occasion}`. Keep the keyword in the
|
||||
first ~30 chars (Persian runs long); brand is appended by the layout, don't double it.
|
||||
- **Description** (lives in `description`, also reused as presentation copy): benefit + what + how-to-edit +
|
||||
soft CTA. fa 120–155 / en 120–158 chars. One natural keyword, no stuffing.
|
||||
- **Keywords** (lives in `keywords`): how Iranians actually search — «قالب آماده», «ساخت/دانلود استوری»,
|
||||
«تیزر تبلیغاتی», «اینترو لوگو» — include loanword + Persian spelling both ways (استوری/Story, اینترو/Intro).
|
||||
1 primary + 2–3 secondaries. Note: `keywords` is an opaque free-text column — the platform does NOT
|
||||
tokenize, split, index, or search on it (no comma parsing anywhere in form or backend). Commas are
|
||||
purely an authoring convention for human readability; write it however reads cleanest.
|
||||
- **Slug** (`slug`): the slugifier KEEPS Persian (no transliteration). Prefer a curated **latin/transliterated**
|
||||
keyword slug for shareable URLs (Persian slugs become `%D9%82...` when copied). Lowercase, hyphenated, short,
|
||||
keyword-bearing. Never change a published slug.
|
||||
|
||||
**4. Presentation copy** (write into `description`; structure it so it doubles as SEO + usability):
|
||||
H1 = human Persian `name` → one-line benefit hook → "این قالب برای چیست؟" (use-case+occasion+audience) →
|
||||
"چه چیزهایی قابل تغییر است؟" (bullet the REAL editable fields — don't claim editable music if there's none) →
|
||||
"چطور بسازم؟" (انتخاب → ویرایش متن/رنگ → دانلود) → spec strip (aspects, duration, 2D/3D). Benefit before
|
||||
feature; second person; every claim true of THIS template.
|
||||
|
||||
**5. Related set** (caller-computed; pick category/tags so this works). Query
|
||||
`GET /v1/templates?categoryId=...` then `?tagSlug=...`; rank occasion > category+use-case > style > industry;
|
||||
show 6–8; **exclude this template's own aspect siblings** (dedupe by template id); cap same-style clones;
|
||||
label pack-mates «از همین مجموعه».
|
||||
|
||||
## fa / en parity & RTL rules
|
||||
- Fill fa first, then en as a faithful mirror. Two self-consistent pages — never a Persian page with an
|
||||
English title. Same template id; localized labels resolve per current locale.
|
||||
- Persian uses Vazirmatn / RTL (see `../persian-fonts/SKILL.md`); use Persian digits in user-facing copy; don't fight the
|
||||
`[dir="rtl"]` block. Tag/category *slugs* stay latin for stability; *display names* are localized.
|
||||
- hreflang fa↔en + x-default is the right target even though the detail page doesn't emit it yet — flag the gap.
|
||||
|
||||
## Worked examples
|
||||
|
||||
**A. Nowruz 3D greeting (تبریک نوروز سهبعدی)**
|
||||
- Category: `دعوتنامه و مناسبت / Invitation & Event`.
|
||||
- Tags: occasion=نوروز/Nowruz · use-case=تبریک/greeting · style=لاکچری/luxury · 2D-3D=سهبعدی/3D ·
|
||||
aspect=۹:۱۶,۱:۱,۱۶:۹ · color=طلایی/gold · audience=کسبوکار کوچک/small business.
|
||||
- name (fa): «قالب تبریک نوروز سهبعدی | مناسبت و تبریک» · (en): "Nowruz 3D Greeting Template | Invitation & Event"
|
||||
- description (fa): «کارت تبریک نوروزی سهبعدی و لاکچری؛ نام برند، متن تبریک و رنگ طلایی را در چند دقیقه شخصیسازی کن و ویدیوی آماده انتشار بساز.»
|
||||
- keywords: `قالب تبریک نوروز, تبریک عید, نوروز سه بعدی, دانلود کارت تبریک, Nowruz greeting`
|
||||
- slug: `nowruz-3d-greeting` · Related: other نوروز items first, then Invitation & Event + greeting.
|
||||
|
||||
**B. Instagram sale promo (پروموشن فروش ویژه اینستاگرام)**
|
||||
- Category: `استوری و ریلز / Story & Reels` (output is a 9:16 vertical — format wins over "sale").
|
||||
- Tags: use-case=فروش ویژه/flash sale · industry=فروشگاه آنلاین/e-shop · style=نئون/neon · aspect=عمودی ۹:۱۶/vertical ·
|
||||
color=انرژیک/energetic · audience=فروشگاه/store · 2D.
|
||||
- name (fa): «قالب استوری فروش ویژه | استوری و ریلز» · (en): "Flash-Sale Story Template | Story & Reels"
|
||||
- description (fa): «قالب آماده استوری برای اعلام تخفیف و فروش ویژه؛ متن، قیمت، رنگ و لوگوی فروشگاهت را جایگزین کن و استوری حرفهای ۹:۱۶ بساز.»
|
||||
- keywords: `قالب استوری فروش, تخفیف اینستاگرام, ساخت استوری تبلیغاتی, دانلود قالب استوری, flash sale reels`
|
||||
- slug: `instagram-flash-sale-story` · Related: same use-case (flash sale) across categories, then Story & Reels.
|
||||
|
||||
**C. Corporate logo reveal (لوگو موشن شرکتی)**
|
||||
- Category: `اینترو و لوگو / Intro & Logo`.
|
||||
- Tags: use-case=معرفی برند/brand intro · style=مینیمال/minimal · audience=شرکتی/corporate ·
|
||||
aspect=۱۶:۹,۱:۱,۹:۱۶ · color=آبی/blue · 2D (or 3D only if it truly is).
|
||||
- name (fa): «قالب لوگو موشن شرکتی | اینترو و لوگو» · (en): "Corporate Logo Reveal Template | Intro & Logo"
|
||||
- description (fa): «اینترو حرفهای برای نمایش لوگوی شرکت؛ لوگو، نام برند و رنگ سازمانی را وارد کن و یک اینترو تمیز و مینیمال بساز.»
|
||||
- keywords: `لوگو موشن, اینترو لوگو, ساخت اینترو, لوگو موشن شرکتی, logo motion intro`
|
||||
- slug: `corporate-logo-reveal` · Related: other Intro & Logo + brand-intro use-case.
|
||||
|
||||
## Where each value goes
|
||||
- **Admin UI** (`TemplatesAdmin.tsx`): name→`name`, slug→`slug`, presentation copy→`description`,
|
||||
SEO keywords→`keywords`, categories→`category_ids` (multi-select chips), tags→`tag_ids`, mode→`primary_mode`.
|
||||
Saves via `/api/admin/resource/templates` → `/v1/templates`.
|
||||
- **Categories/Tags** are managed on their own admin pages (`admin-resources.tsx` → `/v1/categories`, `/v1/tags`);
|
||||
category-level `meta_title`/`meta_description`/`meta_keywords`/`bot_follow` live there — set them once per category.
|
||||
- **Seeder** (`scripts/seed_remotion_templates.py`): currently SETS only `name/slug/description/image/demo*/
|
||||
is_published/primary_mode/sort` and inserts ZERO `container_categories` / `container_tags` and NO `keywords`.
|
||||
To seed SEO+taxonomy: add `keywords` to the `project_containers` INSERT, and add INSERTs into
|
||||
`content.container_categories` (with `sort` = index) and `content.container_tags`. Otherwise each seeded
|
||||
template ships with no keywords, no category, no tags — invisible to backend category/tag filters until an
|
||||
admin opens it and assigns them.
|
||||
|
||||
## Final checklist
|
||||
- [ ] Exactly ONE primary category, format-based, from the flat set → `category_ids` (primary at index 0).
|
||||
- [ ] 6–12 tags, controlled fa↔en vocab, each true of the design, across facets; aspect is a tag → `tag_ids`.
|
||||
- [ ] `name` carries the SEO title (keyword in first ~30 chars; no doubled brand).
|
||||
- [ ] `description` = benefit→for-what→customize(real fields)→how-to→specs; doubles as the meta description.
|
||||
- [ ] `keywords` = 1 primary + 2–3 secondary, loanword + Persian spelling (free text — commas are just an authoring convention, not parsed).
|
||||
- [ ] `slug` = curated latin keyword slug, stable, never changed after publish.
|
||||
- [ ] fa filled first, en a 1:1 mirror; Persian digits + RTL in copy.
|
||||
- [ ] Related works via category/tag query; own aspect siblings excluded.
|
||||
- [ ] Gaps flagged: no template `meta_*`, public mapper drops `keywords`/cover OG/category, no related table,
|
||||
no hreflang on detail page — note these where relevant rather than pretending they're set.
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: kinetic-typography
|
||||
description: How to build animated-text systems for FlatRender Remotion templates — word/line/char reveals, mask wipes, typewriter, scale-pops, highlight sweeps, text-on-path, and number counters — Persian/RTL-aware and reusable. Use whenever a template's hero, caption, quote, title, price, or any text is the thing that moves. Persian is the priority; split by WORD, never by character.
|
||||
---
|
||||
|
||||
# Kinetic typography (animated text systems)
|
||||
|
||||
Type is a first-class motion element here, not a label. A masterpiece text shot is ~5 layers: the right split, eased per-unit timing, a hold sized to a real read, legibility over the background, and a single hero word. Amateurs stop at "the text fades in."
|
||||
|
||||
## The one rule
|
||||
Every value is a pure function of `useCurrentFrame()`. **Never** `useFrame`, `Math.random()`, `Date.now()`, `setState`, or `useEffect`-driven motion — the headless renderer samples frames out of order. For "random" jitter use `rand(seed)` from `lib/anim.ts`. Drive timing off `useVideoConfig().fps`; define `const sec = (s: number) => Math.round(s * fps)` — never hardcode `30`.
|
||||
|
||||
## Persian / RTL — get this right first (it's an Iran-facing product)
|
||||
- **Split by WORD, not character.** Persian script is connected/cursive — splitting on chars shatters letterforms and joins. Latin char-reveals are fine; Persian is word- or line-only. A safe split is `text.split(/\s+/).filter(Boolean)` — this **preserves ZWNJ** (نیمفاصله, ``) inside words like «میشود» because ZWNJ is not whitespace. Never `.split("")` or `.replace(//g, …)` on Persian.
|
||||
- Every text node: `fontFamily: FONT` (Vazirmatn, from `lib/fonts.ts`), `direction: "rtl"`, align right or center. The existing `KineticQuote.tsx` hardcodes Georgia/serif + pixel sizes + no RTL — **do not copy that**; it's a Latin-only relic.
|
||||
- Persian needs weight (headings 700–900) and `lineHeight: 1.4–1.6`. Numerals: pick Persian (۱۲۳ via `toLocaleString('fa-IR')`) or Latin and stay consistent; prices/years are usually Persian digits. See `../persian-fonts/SKILL.md`.
|
||||
- For RTL word reveals, the wrapping container does the ordering — keep `flexWrap: "wrap"` + `direction: "rtl"` and let words flow; don't manually reverse the array.
|
||||
|
||||
## Size & position from layout tokens, never pixels
|
||||
Read `useLayout()` from `lib/aspect.ts`: `vmin(n)`, `unit`, `isWide/isSquare/isTall`. Hero type ≈ `vmin(80–110)`, body ≈ `vmin(28–40)`. Tune timing/scale per aspect — wider reads faster (tighter stagger), tall reads slower (looser). Add this `pick` helper to `Layout` (per R&D Tier-0) and use it:
|
||||
```ts
|
||||
const pick = <T,>(w: T, s: T, t: T) => (L.isWide ? w : L.isSquare ? s : t);
|
||||
const stagger = pick(2, 3, 4); // frames between units
|
||||
```
|
||||
|
||||
## Animation patterns (all driven by `frame - start`)
|
||||
|
||||
| Pattern | Recipe | Persian-safe? |
|
||||
|---|---|---|
|
||||
| **Word reveal** (default) | split words; per word `start = i*stagger`; `spring({frame: frame-start, fps})` → `translateY(vmin)` + `opacity` | ✅ word-split |
|
||||
| **Line reveal** | wrap by line in `<Sequence>`s; each line springs up behind a `clip-path` edge | ✅ |
|
||||
| **Char reveal / scatter** | split chars, per-char delay; rotate/scale in | ❌ Latin only |
|
||||
| **Mask wipe** | `clipPath: inset(0 ${100-p}% 0 0)` (RTL: wipe from right → `0 0 0 ${100-p}%`); `p = interpolate(frame,[a,b],[0,100],{extrapolateRight:"clamp", easing: Easing.out(Easing.cubic)})` | ✅ |
|
||||
| **Typewriter** | `text.slice(0, Math.floor(interpolate(frame,[a,b],[0, words.length])))` joined — **slice by WORD for Persian**, by char only for Latin; add a blinking caret `frame % sec(0.8) < sec(0.4)` | ✅ word-slice |
|
||||
| **Scale-pop ("ta-da")** | `scale = spring({config:{damping:12,mass:0.6,stiffness:180}})` or `Easing.bezier(0.34,1.56,0.64,1)` overshoot→settle | ✅ |
|
||||
| **Highlight sweep** | gradient bar/`background-clip:text` shifting `background-position` per frame, or an accent rect growing under a key word | ✅ |
|
||||
| **Text-on-path** | SVG `<textPath href="#p">`; animate `startOffset` by frame — Latin/numeric only (RTL on a path is unreliable) | ❌ |
|
||||
| **Number counter** | `Math.round(interpolate(frame,[a,b],[0, target],{extrapolateRight:"clamp", easing: Easing.out(Easing.cubic)}))` then `toLocaleString('fa-IR')` | ✅ (format fa) |
|
||||
| **Variable-weight pulse** | Vazirmatn ships a variable axis: `fontVariationSettings: \`'wght' ${interpolate(frame,[a,b],[300,900])}\`` (needs the variable woff2 registered in `fonts.ts`) | ✅ |
|
||||
|
||||
### Reusable word-reveal component (the workhorse — Persian-correct, aspect-aware)
|
||||
```tsx
|
||||
const RevealText: React.FC<{ text: string; start: number; color: string }> = ({ text, start, color }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const L = useLayout();
|
||||
const pick = <T,>(w: T, s: T, t: T) => (L.isWide ? w : L.isSquare ? s : t);
|
||||
const stagger = pick(2, 3, 4);
|
||||
const words = text.split(/\s+/).filter(Boolean); // keeps ZWNJ
|
||||
return (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", justifyContent: "center",
|
||||
direction: "rtl", fontFamily: FONT, fontWeight: 800, fontSize: L.vmin(96),
|
||||
lineHeight: 1.4, color, gap: `0 ${L.vmin(18)}px`, maxWidth: "86%",
|
||||
textShadow: `0 ${L.vmin(2)}px ${L.vmin(20)}px rgba(0,0,0,.6)` }}>
|
||||
{words.map((w, i) => {
|
||||
const s = spring({ frame: frame - start - i * stagger, fps,
|
||||
config: { damping: 16, mass: 0.7, stiffness: 120 } });
|
||||
return (
|
||||
<span key={i} style={{ display: "inline-block", opacity: s,
|
||||
transform: `translateY(${interpolate(s, [0, 1], [L.vmin(28), 0])}px)` }}>
|
||||
{w}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
Follow-through upgrade: give a trailing accent word a *looser* spring (`damping: 6`) so it settles last.
|
||||
|
||||
## Easing & spring (linear is the sound of an amateur)
|
||||
- Entrances → **ease-out** default (`Easing.out(Easing.cubic)`); hero titles → `Easing.bezier(0.16,1,0.3,1)`. Exits → **ease-in, sharper than the entrance**. Snappy pop → back bezier `(0.34,1.56,0.64,1)`.
|
||||
- `interpolate` for exact marks — **always `extrapolateLeft/Right: "clamp"`** (forgetting it is the #1 drift bug). `spring` for organic feel. Combine: `interpolate(spring(...), [0,1], [vmin(28), 0])`.
|
||||
- Spring cheats: clean reveal `{damping:200,mass:0.5,stiffness:200}` · default pop `{damping:12,mass:0.6,stiffness:180}` · bouncy `{damping:8,mass:1,stiffness:120}` · trailing wobble `{damping:6,mass:1,stiffness:80}`.
|
||||
|
||||
## Timing budgets (@ whatever `fps` is)
|
||||
Micro pop 8–14f · word stagger 2–4f · standard reveal 18–28f · hero entrance 28–40f · **hold = a comfortable read** (≥ `sec(0.7)` per text element before the next competes). Cut frames before adding them — over-animating reads as amateur. Anticipation: dip below start before launch (`interpolate(frame,[0,6,30],[0,-0.12,1])`).
|
||||
|
||||
## Legibility over busy / 3D / video backgrounds
|
||||
- Scrim or `textShadow: 0 0 vmin(20) rgba(0,0,0,.7)`, or a semi-transparent panel behind text.
|
||||
- Gradient text: `WebkitBackgroundClip: "text"`, transparent fill, plus a `drop-shadow` for edge separation.
|
||||
- Colors come from `colorSchema` props (`accentColor/secondaryColor/backgroundColor/textColor` via `lib/branding.ts`) — pass user hex through `mixHex`/`hexToRgba` so a garish value doesn't break the look. Never hardcode `#fff`.
|
||||
- Captions (TikTok/Reels/Shorts) = high-contrast white/yellow + black outline, lower-middle third, inside the tightest safe zone. See `../remotion-aspect-ratios/SKILL.md`.
|
||||
|
||||
## Checklist
|
||||
- [ ] Persian text split by WORD; ZWNJ preserved; `direction:"rtl"` + `fontFamily: FONT`.
|
||||
- [ ] All sizes via `vmin`/`unit`; timing/stagger via `pick(...)` per aspect — verified in 16:9, 1:1, 9:16.
|
||||
- [ ] No linear easing; ≥1 overshoot-and-settle; staggered, not all on frame 0.
|
||||
- [ ] Every `interpolate` clamps both ends; no `useFrame`/`random`/`Date.now`; `fps` not `30`.
|
||||
- [ ] Numbers formatted (`fa-IR`) and consistent; counter eases out.
|
||||
- [ ] Legible over the background (scrim/shadow); colors from props.
|
||||
- [ ] A real hold sized to reading; longest Persian string doesn't overflow, shortest doesn't look empty.
|
||||
- [ ] Re-render twice → identical pixels (deterministic).
|
||||
|
||||
Related: `../remotion-template-composition/SKILL.md`, `../persian-fonts/SKILL.md`, `../remotion-aspect-ratios/SKILL.md`, `../remotion-design-styles/SKILL.md`, `../remotion-svg-colors/SKILL.md`, `../remotion-sound-effects/SKILL.md`, `../remotion-music-picker/SKILL.md`, `../flatrender-template-seo/SKILL.md`.
|
||||
@@ -0,0 +1,167 @@
|
||||
---
|
||||
name: motion-design-principles
|
||||
description: The foundation motion-craft reference for FlatRender Remotion templates — easing curves and when to reach for each, timing & spacing, the 12 animation principles applied to Remotion, anticipation/overshoot/follow-through/settle, staggering & choreography, secondary motion, spring() vs interpolate(), and the blocking→timing→polish workflow. Use whenever animating ANY element in a template, reviewing motion quality, or deciding how something should enter, move, or leave. Read this BEFORE writing animation code.
|
||||
---
|
||||
|
||||
# Motion design principles (the FlatRender craft floor)
|
||||
|
||||
Project: `services/remotion/` (Remotion 4 + `@remotion/three`, R3F v9, `gl="angle"`). Three aspects (16:9 / 1:1 / 9:16), Persian-first (Vazirmatn, RTL). Helpers: `src/lib/anim.ts` (`hexToRgba`, `mixHex`, `rand`), `src/lib/aspect.ts` (`useLayout` → `isWide/isSquare/isTall`, `vmin`, `unit`, `pick`), `src/lib/branding.ts` (`colorSchema`, `BRAND`), `src/lib/fonts.ts` (`FONT` = Vazirmatn), `src/lib/three-kit.tsx` (`StudioEnv/Lights/Floor/Effects`, `Confetti3D`).
|
||||
|
||||
**Linear motion is the sound of an amateur. Almost nothing in a FlatRender template should move at a constant rate.** This skill is the floor every template stands on.
|
||||
|
||||
## The one rule everything hangs on
|
||||
A Remotion frame is **pure**: `frame → pixels`, sampled at an arbitrary `t` (the After Effects mental model — a keyframe graph read at time `t`). The renderer samples frames **out of order and in parallel**.
|
||||
|
||||
- Derive every value from `useCurrentFrame()`. If a value can't be, it doesn't belong in the render.
|
||||
- **Never** `useFrame` (R3F), `Math.random()`, `Date.now()`, `setState`, or `useEffect`-driven motion. For "randomness" use `rand(seed)` from `anim.ts`.
|
||||
- **Never hardcode 30fps.** `const { fps } = useVideoConfig(); const sec = (s: number) => Math.round(s * fps);`
|
||||
|
||||
## `spring()` vs `interpolate()` — pick deliberately
|
||||
|
||||
| | `interpolate()` | `spring()` |
|
||||
|---|---|---|
|
||||
| Who authors the curve | **you** (explicit easing) | **physics** (mass/damping/stiffness) |
|
||||
| Reach for it when | a value must hit an exact mark on an exact frame — storyboard reveals, crossfades, value remaps, color/blur sweeps | organic entrances, pops, bounces, anything that should "feel" alive |
|
||||
| The trap | forgetting `extrapolate*: "clamp"` → elements drift off-screen / opacity goes negative | trying to land a value on an exact frame |
|
||||
|
||||
**Always combine them** — spring drives the *feel* (0→1), interpolate *remaps* it to real px/units in the layout's own scale:
|
||||
```tsx
|
||||
const L = useLayout();
|
||||
const p = spring({ frame: frame - start, fps, config: { mass: 0.6, damping: 12, stiffness: 180 } });
|
||||
const y = interpolate(p, [0, 1], [L.vmin(80), 0]); // remap into layout units
|
||||
const opacity = interpolate(p, [0, 1], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
```
|
||||
|
||||
### Spring config cheat-sheet
|
||||
Lower `damping` = more overshoot · higher `mass` = heavier/slower · higher `stiffness` = faster snap.
|
||||
|
||||
| Feel | mass | damping | stiffness | Use for |
|
||||
|---|--:|--:|--:|---|
|
||||
| Snappy, no overshoot | 0.5 | 200 | 200 | Clean UI / logo reveals |
|
||||
| **Natural pop (default)** | 0.6 | 12 | 180 | Cards, badges, icons |
|
||||
| Bouncy / playful | 1 | 8 | 120 | Kids, birthday, mascots |
|
||||
| Heavy / weighty | 2.5 | 26 | 90 | Big titles, 3D objects landing |
|
||||
| Loose wobble (follow-through) | 1 | 6 | 80 | Secondary / trailing parts |
|
||||
|
||||
## Easing cheat-sheet (`import { Easing } from "remotion"`)
|
||||
|
||||
| Situation | Curve | Why |
|
||||
|---|---|---|
|
||||
| **Entrances (default)** | `Easing.out(Easing.cubic)` | things arrive and decelerate |
|
||||
| Hero title entrance | `Easing.out(Easing.quint)` or `Easing.bezier(0.16, 1, 0.3, 1)` | dramatic deceleration |
|
||||
| **Exits** | `Easing.in(Easing.cubic)` — **always sharper than the entrance** | things leave faster than they arrive |
|
||||
| A→B on-screen move / camera | `Easing.inOut(Easing.cubic)` | smooth both ends |
|
||||
| "Ta-da" overshoot | `Easing.bezier(0.34, 1.56, 0.64, 1)` | snappy pop past target |
|
||||
| Wind-up / anticipation | `Easing.bezier(0.36, 0, 0.66, -0.56)` | dips below before launch |
|
||||
| **Linear ONLY** | `Easing.linear` | rotation, scroll, conveyor, marquee — mechanical continuous motion |
|
||||
|
||||
```tsx
|
||||
const t = interpolate(frame, [start, start + 24], [0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
|
||||
```
|
||||
|
||||
## Timing & spacing (30fps baseline — but always derive with `sec()`)
|
||||
Spacing (the easing) sets *feel*; timing (frame count) sets *weight & mood*. **Cut frames before you add them — amateurs over-animate.**
|
||||
|
||||
| Beat | Frames @30fps |
|
||||
|---|---|
|
||||
| Micro pop (icon, badge) | 8–14 |
|
||||
| Standard reveal | 18–28 |
|
||||
| Hero entrance | 28–40 |
|
||||
| Scene transition | 12–20 |
|
||||
| Hold | a comfortable read of the text (size to the longest Persian string) |
|
||||
|
||||
Symptoms: robotic = linear spacing · floaty/late = timing too long · jittery = no hold between moves.
|
||||
|
||||
## The 12 principles → Remotion (the four in **bold** you reach for every shot)
|
||||
|
||||
| Principle | Remotion expression |
|
||||
|---|---|
|
||||
| Squash & stretch | `scaleX`/`scaleY` inversely around an impact frame, conserve volume (`sx = 1/sy`) |
|
||||
| **Anticipation** | dip the value below its start before the main move |
|
||||
| Staging | stagger reveals; dim/blur everything but the hero — one idea per beat |
|
||||
| Straight-ahead vs pose-to-pose | `interpolate` between keyed frames vs per-frame formula (sim, e.g. `Confetti3D`) |
|
||||
| **Follow-through & overlapping** | same trigger, **delayed per child** + a *looser* spring so parts settle later |
|
||||
| **Slow in & slow out** | `Easing.bezier` / `spring()` — the single biggest quality lever |
|
||||
| Arcs | drive `y` with `sin`/parabola while `x` moves linearly |
|
||||
| Secondary action | a small `sin` bob/shimmer alongside the primary reveal |
|
||||
| Timing | frame count + spring `mass`/`damping` = weight & mood |
|
||||
| **Exaggeration / overshoot** | overshoot > 1.0, then settle to 1.0 |
|
||||
| Solid drawing | `StudioLights` + reflective material + floor shadows (3D) |
|
||||
| Appeal | choreography + `StudioEffects` (bloom/DOF/vignette) + good type |
|
||||
|
||||
## The four quality multipliers (concrete, reusable)
|
||||
|
||||
**Anticipation** — a small negative dip before launch:
|
||||
```tsx
|
||||
const scale = interpolate(frame, [start, start + 6, start + 30], [0, -0.12, 1],
|
||||
{ extrapolateRight: "clamp", easing: Easing.bezier(0.36, 0, 0.66, -0.56) });
|
||||
```
|
||||
|
||||
**Overshoot + settle** — reach past, then land. Ensure the curve *holds* the target (clamp) or it micro-drifts forever:
|
||||
```tsx
|
||||
const pop = interpolate(frame, [start, start + 18], [0, 1],
|
||||
{ extrapolateRight: "clamp", easing: Easing.bezier(0.34, 1.56, 0.64, 1) });
|
||||
// or: spring with low damping (config { mass: 0.6, damping: 10, stiffness: 170 })
|
||||
```
|
||||
|
||||
**Follow-through** — drive children from the *same* trigger, delay each, looser spring so they settle after the parent. The biggest "feels professional" upgrade for grouped elements:
|
||||
```tsx
|
||||
function Child({ i, start }: { i: number; start: number }) {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const p = spring({ frame: frame - start - i * 4, fps, config: { mass: 1, damping: 6, stiffness: 80 } });
|
||||
return <g style={{ transform: `translateY(${interpolate(p, [0, 1], [24, 0])}px)`, opacity: p }} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Secondary motion** — never let a held element go dead. Add a tiny `sin` breathe/shimmer:
|
||||
```tsx
|
||||
const bob = Math.sin(frame / fps * Math.PI) * L.vmin(4); // gentle float during the hold
|
||||
```
|
||||
|
||||
## Staggering & choreography
|
||||
Default to a **cascade**, and **tune the stagger per aspect** — wider frames read faster (tighter stagger), tall frames read slower (looser):
|
||||
```tsx
|
||||
const L = useLayout();
|
||||
const stagger = L.pick(/*wide*/ 3, /*square*/ 4, /*tall*/ 5); // pick(wide, square, tall)
|
||||
const start = i * stagger;
|
||||
```
|
||||
Patterns: **cascade** (lists/features) · **center-out** (logo/hero rows: `delay = Math.abs(i - mid) * stagger`) · **deterministic random** (particles: `rand(i)` for delay/offset) · **beat-synced** (snap `start` to music beat frames — see `../remotion-music-picker/SKILL.md`). **One thing enters the eye at a time.**
|
||||
|
||||
> `pick` is the standard per-aspect selector on `useLayout()`. If it isn't on `Layout` yet, add it in `aspect.ts`: `pick: <T,>(wide: T, square: T, tall: T): T => kind === "wide" ? wide : kind === "tall" ? tall : square,`
|
||||
|
||||
## 3D motion (`@remotion/three`)
|
||||
Drive every transform off `useCurrentFrame()` (deterministic under ANGLE) — **never `useFrame`**. Rotation/orbit = `linear` (mechanical); entrances/landings = `spring` with **high mass** for weight. Keep crisp Persian text as a 2D `<AbsoluteFill>` overlay above `<ThreeCanvas>`. Let `StudioEffects` (bloom + DOF + vignette) carry the cinematic polish in one component; tune `camera.fov`/`position.z` per aspect so the subject fills the frame.
|
||||
|
||||
## The pro workflow — 5 passes, IN ORDER
|
||||
Polishing before timing is locked wastes the most time.
|
||||
1. **Reference** — decide the feel before code; pick style (`../remotion-design-styles/SKILL.md`), type (`../persian-fonts/SKILL.md`), composition (`../remotion-template-composition/SKILL.md`), per-aspect rules (`../remotion-aspect-ratios/SKILL.md`). Write the beat list ("logo in → tagline → 3 features cascade → CTA → out").
|
||||
2. **Blocking** — every element at its final position with crude `interpolate` fades, no easing. Fix off-screen/cropping in all three aspects NOW.
|
||||
3. **Timing** — lock frame counts, stagger, beats, holds, transitions. Watch at full speed repeatedly. Mood lives here.
|
||||
4. **Polish** — swap linear for easing/springs; add anticipation + overshoot/settle, follow-through, secondary motion, arcs, squash/stretch; `StudioEffects` for 3D; wire SFX (`../remotion-sound-effects/SKILL.md`) + music sync (`../remotion-music-picker/SKILL.md`) to the locked frames.
|
||||
5. **Review** — scrub frame-by-frame + full speed against the checklist below.
|
||||
|
||||
## Top amateur mistakes → fixes (review gate)
|
||||
- Linear motion → ease/spring · no anticipation/overshoot → dip-then-launch / back bezier
|
||||
- Everything on one frame → stagger · forgot `clamp` → clamp both ends
|
||||
- Hardcoded 30fps → `useVideoConfig().fps` + `sec()`
|
||||
- `useFrame`/`random`/`Date.now()` → `useCurrentFrame` + `rand`
|
||||
- Pixel-hardcoded sizes → `vmin`/`unit` + `pick`/`isWide/isSquare/isTall`
|
||||
- Over-animating → one idea per beat · no hold → real hold sized to reading
|
||||
- Exit speed = entrance speed → exits sharper · dead holds → `sin` bob/breathe/shimmer
|
||||
- Color hardcoded → read from `colorSchema` props
|
||||
|
||||
## Pre-ship motion checklist
|
||||
- [ ] No linear easing anywhere except mechanical continuous motion (rotation/marquee).
|
||||
- [ ] Entrances ease-out; exits ease-in **and sharper** than entrances.
|
||||
- [ ] Every `interpolate` that could overshoot has `extrapolateLeft/Right: "clamp"`.
|
||||
- [ ] At least one anticipation (dip) and one overshoot-and-settle in the piece.
|
||||
- [ ] Grouped elements stagger; trailing parts follow through (looser spring).
|
||||
- [ ] No dead holds — held heroes have a subtle `sin` breathe/shimmer.
|
||||
- [ ] Stagger/scale tuned per aspect via `pick`; verified in 16:9 / 1:1 / 9:16.
|
||||
- [ ] All timing from `sec()`/`fps`; no hardcoded 30; no `useFrame`/`random`/`Date.now`.
|
||||
- [ ] One clear hero moment with the biggest motion; the eye always knows where to look.
|
||||
- [ ] Re-render twice → pixel-identical (deterministic).
|
||||
|
||||
Related: `../remotion-design-styles/SKILL.md`, `../remotion-aspect-ratios/SKILL.md`, `../remotion-template-composition/SKILL.md`, `../remotion-character-design/SKILL.md`, `../remotion-sound-effects/SKILL.md`, `../remotion-music-picker/SKILL.md`, `../persian-fonts/SKILL.md`, `../flatrender-template-seo/SKILL.md`.
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
name: particles-and-effects
|
||||
description: How to add production-value FX — confetti, sparkles, bokeh, light leaks, dust, smoke, glow, lens flare, film grain, chromatic aberration, vignette, camera shake — to FlatRender Remotion templates, in both 2D (SVG/CSS) and 3D (@remotion/three). Use when a template needs atmosphere, finishing texture, particle systems, or a celebratory/cinematic hit. Every effect is a deterministic function of useCurrentFrame() — never Math.random.
|
||||
---
|
||||
|
||||
# Particles & effects for Remotion
|
||||
|
||||
Project: `services/remotion/` (Remotion 4 + `@remotion/three`, R3F v9, `gl="angle"`). Effects are the **8th finishing layer** — the thing that separates "made in a tool" from "made by a studio." A flat, ungrainy, perfectly-locked frame reads as AI/template. Imperfect-by-design wins.
|
||||
|
||||
## The one non-negotiable rule
|
||||
Render is headless Chrome sampling frames out of order, in parallel. **Every particle position, every grain offset, every flicker MUST derive from `useCurrentFrame()`.** Never `Math.random()`, `Date.now()`, `useFrame` (R3F), `useState`, or `useEffect` motion. Use `rand(seed)` from `src/lib/anim.ts` for stable per-index pseudo-randomness, and `rand(i + frame)`-style offsets when you want it to *move*. Re-render twice → identical bytes, or it's wrong.
|
||||
|
||||
Helpers you build on:
|
||||
- `anim.ts` — `rand(i)` (deterministic 0..1), `hexToRgba(hex,a)`, `mixHex(a,b,t)`.
|
||||
- `aspect.ts` — `useLayout()` → `isWide/isSquare/isTall`, `vmin(n)`, `unit`, and `pick(wide,square,tall)`. **Scale particle COUNT and SIZE per aspect** — a tall 9:16 needs fewer, bigger sparkles than a wide 16:9.
|
||||
- `branding.ts` — `colorSchema` props are `accentColor / secondaryColor / backgroundColor / textColor`. FX color comes from these so the studio recolors them.
|
||||
- `three-kit.tsx` — `StudioEnv`, `StudioLights`, `StudioFloor`, `StudioEffects` (bloom+DOF+vignette), `Confetti3D`.
|
||||
|
||||
## 2D vs 3D — pick per effect
|
||||
- **2D (SVG/CSS)** is the default: cheap, crisp, no WebGL. Confetti, sparkles, grain, light leaks, vignette, aberration, camera shake — all better/cheaper in 2D as an `<AbsoluteFill>` overlay, even on top of a 3D scene.
|
||||
- **3D (@remotion/three)** when the effect must respond to scene lighting/depth: volumetric bloom, real bokeh/DOF, `emissive` glow that bloom picks up, 3D confetti with perspective. Let `StudioEffects` do bloom/DOF/vignette in ONE component — don't re-roll them.
|
||||
- Persian text NEVER goes in 3D — keep it as a 2D overlay above `<ThreeCanvas>`.
|
||||
|
||||
## Effect → recipe table
|
||||
|
||||
| Effect | Layer | Core technique | Determinism |
|
||||
|---|---|---|---|
|
||||
| **Confetti (2D)** | overlay | N `<rect>`/`<path>`, `rand(i)` for x/rot/color; `y` = `(frame*speed + rand(i)*span) % span` | `rand(i)` seed |
|
||||
| **Confetti (3D)** | scene | reuse `Confetti3D` from three-kit | built-in |
|
||||
| **Sparkles / shine** | overlay | 4-point star SVG, twinkle `opacity = abs(sin((frame+rand(i)*60)/12))`, scale pulse | `rand(i)` |
|
||||
| **Bokeh** | bg | big blurred radial-gradient circles drifting on `sin(frame/period)`, low opacity, `mix-blend:screen` | per-circle seed |
|
||||
| **Light leaks** | overlay | warm radial/linear gradient sweeping across via `interpolate(frame,...)` translate, `mix-blend:screen` | frame |
|
||||
| **Dust motes** | overlay | tiny dim dots, slow upward drift + lateral `sin`, `rand` size/speed | `rand(i)` |
|
||||
| **Smoke / fog** | bg/3D | 2D: layered blurred blobs drifting+scaling; 3D: stacked transparent planes | frame |
|
||||
| **Glow** | any | 2D `filter:drop-shadow(0 0 Npx accent)` / `textShadow`; 3D `emissive`+`emissiveIntensity`, `toneMapped={false}`, let bloom bloom it | static |
|
||||
| **Lens flare** | overlay | bright core + chromatic ring sprites along a line from a light point, opacity by angle/frame | frame |
|
||||
| **Film grain** | top | SVG `feTurbulence` with per-frame `seed`, `mix-blend:overlay`, low opacity — MUST animate or it looks frozen | frame |
|
||||
| **Chromatic aberration** | top | duplicate layer, offset R/B channels ±1–3px, strongest at impact frames | frame |
|
||||
| **Vignette** | top | `boxShadow: inset 0 0 vmin(600) rgba(0,0,0,.6)` or `StudioEffects` in 3D | static |
|
||||
| **Camera shake** | root | translate whole frame by `rand(frame)`-driven jitter, decaying after an impact | `rand(frame)` |
|
||||
|
||||
## Deterministic particle field (the pattern to memorize)
|
||||
```tsx
|
||||
const frame = useCurrentFrame();
|
||||
const { vmin, pick } = useLayout();
|
||||
const count = pick(60, 48, 36); // fewer on tall
|
||||
{Array.from({ length: count }).map((_, i) => {
|
||||
const x = rand(i) * 100; // % of width
|
||||
const drift = Math.sin((frame + rand(i + 9) * 200) / 40) * 3;
|
||||
const fall = (frame * (0.3 + rand(i + 1) * 0.5) + rand(i + 5) * 120) % 120;
|
||||
const twinkle = Math.abs(Math.sin((frame + rand(i + 2) * 60) / 12));
|
||||
return <div key={i} style={{
|
||||
position: "absolute", left: `${x + drift}%`, top: `${fall - 10}%`,
|
||||
width: vmin(6), height: vmin(6), opacity: twinkle,
|
||||
background: i % 2 ? accentColor : secondaryColor,
|
||||
transform: `rotate(${frame * 2 + rand(i) * 360}deg)`,
|
||||
}} />;
|
||||
})}
|
||||
```
|
||||
Notice: `rand(i)` = stable identity per particle; `frame` = motion; `% span` = seamless wrap; aspect drives count via `pick`.
|
||||
|
||||
## Animated film grain (SVG — the cheapest authenticity layer)
|
||||
```tsx
|
||||
<svg style={{ position: "absolute", inset: 0, mixBlendMode: "overlay", opacity: 0.08 }}>
|
||||
<filter id="grain">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.9"
|
||||
numOctaves="2" seed={frame % 100} stitchTiles="stitch" />
|
||||
</filter>
|
||||
<rect width="100%" height="100%" filter="url(#grain)" />
|
||||
</svg>
|
||||
```
|
||||
`seed={frame % 100}` is what makes it crawl. Keep opacity 0.05–0.12. For paper/vignette use `mix-blend:multiply` instead.
|
||||
|
||||
## Chromatic aberration & impact-driven FX
|
||||
Aberration should be **strongest at impacts** (a hard cut, the hero reveal, a confetti burst) and near-zero otherwise:
|
||||
```tsx
|
||||
const ab = interpolate(frame, [hit - 2, hit, hit + 8], [0, vmin(4), 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
```
|
||||
Render the content twice, offset the red copy `translateX(-ab)` `mix-blend:screen` and the blue copy `translateX(+ab)`. Same `interpolate` curve also drives a one-shot camera-shake amplitude — things calm down fast.
|
||||
|
||||
## Camera shake (subtle continuous + impact)
|
||||
```tsx
|
||||
// continuous "frame alive" drift — tiny, always on
|
||||
const driftX = Math.sin(frame / 50) * vmin(3) + (rand(frame) - 0.5) * vmin(1);
|
||||
// impact shake — decays
|
||||
const amp = interpolate(frame, [hit, hit + 12], [vmin(14), 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
const shake = (rand(frame * 7) - 0.5) * amp;
|
||||
// apply to a root <AbsoluteFill style={{ transform: `translate(${driftX+shake}px, ${...}px)` }}>
|
||||
```
|
||||
A locked, perfectly-still frame reads amateur. A *tiny* always-on drift makes it feel hand-held and alive — keep it under ~`vmin(4)` or it's distracting.
|
||||
|
||||
## 3D glow & bloom
|
||||
Make a material glow into bloom: `<meshStandardMaterial emissive={accentColor} emissiveIntensity={2} toneMapped={false} />`, then mount `<StudioEffects bloom={0.9} />`. For sparkly metal confetti raise `metalness`. Drive every transform off `useCurrentFrame()` (deterministic under ANGLE), rotation = `linear` (mechanical), entrances = `spring` with high mass.
|
||||
|
||||
## Reusable components — make these, don't inline
|
||||
Put shared FX in `src/lib/fx.tsx` so every template gets the same texture:
|
||||
- `<GrainOverlay opacity? blend? />` — animated `feTurbulence`.
|
||||
- `<Vignette strength? />` — inset boxShadow.
|
||||
- `<Confetti2D colors count? burstFrame? />` — burst (spring spread) vs rain (continuous fall) modes.
|
||||
- `<Sparkles colors count? area? />` — twinkling 4-point stars.
|
||||
- `<Bokeh colors count? />` + `<LightLeak color from to />` — bg/overlay atmosphere.
|
||||
- `<Aberration amount /> <CameraShake amount />` — finishing pair, wrap the whole comp.
|
||||
Each takes `colorSchema` colors so the studio picker recolors the FX, and reads `useLayout()` for per-aspect count/size.
|
||||
|
||||
## Restraint — FX amplify a hero, they are not the show
|
||||
- One celebratory burst on the **hero moment**, not raining the whole video. Often **silence before** + confetti + sparkle SFX on the same frame (see `../remotion-sound-effects/SKILL.md`).
|
||||
- Finishing texture (grain, vignette, drift) is *subtle and always-on*; spectacle (confetti, flare, big aberration) is *brief and on a beat*.
|
||||
- Don't stack 6 effects at full strength — that reads as a tool preset. Grain at 0.08, vignette at 0.5, aberration only at impacts.
|
||||
- All FX color from `colorSchema`; pass a user's garish hex through `mixHex(hex, background, 0.2)` so it doesn't blow out.
|
||||
|
||||
## Pre-ship checklist
|
||||
- [ ] Zero `Math.random` / `Date.now` / `useFrame` — only `rand()` + `frame`. Re-render twice → identical.
|
||||
- [ ] Grain is *animated* (per-frame seed), not frozen.
|
||||
- [ ] Particle count & size scale per aspect via `pick`/`vmin` — verified in 16:9, 1:1, 9:16; particles stay in the safe zone, never crop Persian text.
|
||||
- [ ] Every `interpolate` has `extrapolateLeft/Right: "clamp"` — no drift, no negative opacity.
|
||||
- [ ] Spectacle FX land on a beat / the hero; texture FX are subtle & continuous.
|
||||
- [ ] FX colors read from `colorSchema`; a continuous camera drift keeps the frame alive.
|
||||
- [ ] 3D glow uses `emissive`+`toneMapped={false}` + `StudioEffects` (not hand-rolled bloom).
|
||||
|
||||
Related: `../remotion-design-styles/SKILL.md`, `../remotion-character-design/SKILL.md`, `../remotion-aspect-ratios/SKILL.md`, `../remotion-template-composition/SKILL.md`, `../remotion-sound-effects/SKILL.md`, `../remotion-music-picker/SKILL.md`, `../remotion-svg-colors/SKILL.md`, `../persian-fonts/SKILL.md`, `../remotion-template-catalog/SKILL.md`, `../flatrender-template-seo/SKILL.md`.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: persian-fonts
|
||||
description: Persian (Farsi) and Latin font selection for FlatRender — which font to use where, with a Persian-first priority. Use when choosing typefaces for a template or UI, pairing display vs body fonts, handling RTL/Persian numerals, or adding a new font to the Remotion project. Persian fonts are the priority.
|
||||
---
|
||||
|
||||
# Persian-first typography
|
||||
|
||||
This is an Iran-facing product: **Persian (Farsi) is the default and the priority**. Latin is secondary. Get the Persian type right first.
|
||||
|
||||
## What the project already uses
|
||||
- **Vazirmatn** — the default Persian face everywhere. Remotion bundles `services/remotion/public/fonts/vazirmatn-{400,600,700,800,900}.woff2` and exposes `FONT` from `src/lib/fonts.ts` (use `fontFamily: FONT` + `direction: "rtl"` in every composition).
|
||||
- Web app: `globals.css` has a `[dir="rtl"]` block that FORCES Vazirmatn on all elements — don't fight it with utility classes; work with it.
|
||||
- Latin pairing in the web app: **Plus Jakarta Sans** + **Inter**.
|
||||
|
||||
## Persian font palette (pick by role)
|
||||
| Font | Character | Use for |
|
||||
|---|---|---|
|
||||
| **Vazirmatn** | clean, neutral, many weights | DEFAULT — body, UI, most template text |
|
||||
| **Estedad** | modern, geometric, friendly | headings, modern brand templates |
|
||||
| **Yekan Bakh** | contemporary sans, professional | corporate/business templates |
|
||||
| **Shabnam / Sahel** | soft, readable | body text, calm/elegant designs |
|
||||
| **IRANSans / IRANYekan** | familiar Iranian UI standard | UI-style promos, app mockups |
|
||||
| **Morabba** | bold display, rounded | big punchy headlines, posters |
|
||||
| **Gandom** | strong display | impactful titles, sale/sport |
|
||||
| **Lalezar** | playful, heavy, fun | kids, party, birthday, casual |
|
||||
| **Shekari / Vahid** | decorative/calligraphic | festive, traditional (Nowruz, wedding, Yalda) |
|
||||
| **Nastaliq (e.g. IranNastaliq)** | classical calligraphy | very formal/traditional, invitations — use sparingly, hard to read small |
|
||||
|
||||
Pairing rule: one display face for the headline + Vazirmatn (or Shabnam) for everything else. Don't mix two display faces.
|
||||
|
||||
## Match font to template mood
|
||||
- Corporate / SaaS → Estedad / Yekan Bakh + Vazirmatn.
|
||||
- Festive (birthday, party) → Lalezar / Morabba + Vazirmatn.
|
||||
- Traditional / occasion (Nowruz, wedding, Yalda, Eid) → a decorative/Nastaliq display for the greeting + Vazirmatn for details.
|
||||
- Sale / bold promo → Gandom / Morabba (heavy) + Vazirmatn.
|
||||
- Minimal / elegant → Shabnam / Sahel, lighter weights.
|
||||
|
||||
## Persian typesetting rules
|
||||
- **RTL:** always `direction: "rtl"`; align right or center. Mixed Persian+Latin/numbers needs care (bidi) — test the actual string.
|
||||
- **Numerals:** decide Persian (۱۲۳) vs Latin (123) and be consistent. For Persian digits, format with `toLocaleString('fa-IR')` or use a font that supports Persian numerals. Years/prices in templates are usually Persian digits (۱۴۰۶, ۲۹۹٬۰۰۰).
|
||||
- **Weights:** Persian script needs a bit more weight to feel solid — headings 700-900, body 400-600. Avoid ultra-thin for small text.
|
||||
- **Line-height:** Persian needs slightly more (`lineHeight` 1.4-1.6 for body) — descenders/diacritics need room.
|
||||
- **ZWNJ (نیمفاصله, ``):** preserve it in words like «میشود», «نیمفاصله» — don't strip it.
|
||||
- **No fake bold/italic:** use real weights; Persian has no italic — don't slant it.
|
||||
|
||||
## Adding a new font to a Remotion template
|
||||
1. Get the woff2 (license-checked — Vazirmatn/Estedad/Shabnam are SIL OFL, free for commercial; verify others). Place in `services/remotion/public/fonts/`.
|
||||
2. Register it (a `@font-face` injected via `<style>` in the composition, or `@remotion/fonts` `loadFont`) and expose a `FONT_X` const next to `FONT` in `lib/fonts.ts`.
|
||||
3. Use `fontFamily: FONT_X` only for that template's display text; keep body on Vazirmatn.
|
||||
4. Embed the actual weights you use (don't ship 9 weights if you use 2) to keep bundles small.
|
||||
|
||||
## Licensing
|
||||
Prefer SIL OFL / free-for-commercial Persian fonts (Vazirmatn, Estedad, Shabnam, Sahel, Samim, Gandom, Morabba — most from the `font-store`/Google Persian sets are OFL). Verify each before shipping in an exported-video product; keep a license record.
|
||||
|
||||
Related: `../remotion-template-composition/SKILL.md`, `../remotion-design-styles/SKILL.md`.
|
||||
@@ -0,0 +1,244 @@
|
||||
# FlatRender Design & Motion R&D — Trends + Professional Craft
|
||||
|
||||
> Single-source R&D brief for the FlatRender Remotion engine (`services/remotion`). Stack: **Remotion 4 + `@remotion/three`** (R3F v9, `gl="angle"`), Persian-first (Vazirmatn, RTL), three mandatory aspects (16:9 / 1:1 / 9:16), color-customizable templates driven by `colorSchema` props. Operating context: **Iran** — geo-blocked CDNs, sanctioned SaaS dashboards, reachable Nexus mirror (`mirror.soroushasadi.com`). Render is headless Chrome in Docker, so **every value must be a pure function of `useCurrentFrame()`** and **every asset must be vendored** into `public/`.
|
||||
>
|
||||
> Existing grounding files: `src/lib/anim.ts`, `aspect.ts`, `branding.ts`, `fonts.ts`, `three-kit.tsx`, `kit.tsx`; templates in `src/templates.tsx`; `public/` currently holds only `fonts/`.
|
||||
|
||||
---
|
||||
|
||||
## The two meta-truths to keep over everything
|
||||
|
||||
1. **Imperfect-by-design beats glossy.** As feeds fill with AI-perfect imagery, deliberate imperfection — grain, texture, hand-rendered type, natural-feeling motion — now signals "a real human made this." Even fully-rendered templates win by *adding back* texture and human-feeling motion. Apply this lens to every trend below.
|
||||
2. **A masterpiece is ~8 finishing layers, not one big thing.** Sound design, micro-easing, a design system, depth/lighting, color grade, pacing, a clear hero moment, and subtle texture. Amateurs stop at "the text animates in." We must finish all eight.
|
||||
|
||||
---
|
||||
|
||||
## 1) Design trends to adopt (each with a concrete how-to in our stack)
|
||||
|
||||
Every trend below survives all three aspects only if you anchor to safe-zone **percentages / `layout.vmin()`**, never absolute pixels. Read `../remotion-aspect-ratios/SKILL.md` before positioning anything.
|
||||
|
||||
### Typography (type is a first-class motion element, not a label)
|
||||
|
||||
| Trend | When to use | How in our stack |
|
||||
|---|---|---|
|
||||
| **Bold / oversized hero type** (fills 60–90% of frame, clipped by edges) | Logo reveals, promo hooks; strongest on 9:16 | `fitText` from `@remotion/layout-utils` to auto-scale a word to frame width; animate `scale`/`translateY` with `spring()`; parent `overflow:hidden` to clip. |
|
||||
| **Variable-font animation** (`wght`/`wdth`/`slnt` over time) | Premium beat-synced intros | `style={{ fontVariationSettings: \`'wght' ${interpolate(frame,[0,30],[100,900])}\` }}`. **Vazirmatn ships a variable build** — animate its weight axis for Persian hero type. Load via `@font-face` (vendored in `public/fonts/`, never Google CDN at render). |
|
||||
| **Kinetic typography** (word-by-word / line-by-line) | Quotes, captions, fast hooks | Split into spans; per-word `delay = i * staggerFrames`; drive each with `spring({frame: frame - delay, fps})`; combine `translateY` + `opacity` + slight `rotate`. `<Sequence>` per line for timeline clarity. |
|
||||
| **Anti-AI / hand-rendered / scribbled** | Grunge/street/youth/music/events | Pre-make rough-edged SVG/PNG lettering; "draw" it on with a `clipPath`/mask wipe; add `filter:url(#displace)` (SVG `feTurbulence` + `feDisplacementMap`) with a per-frame jitter for photocopy wobble. |
|
||||
| **Chrome / Y2K metallic type** | Hype, music, fashion, "premium" reveals | CSS: layered `linear-gradient` text fill via `background-clip:text` cycling silver→steel→highlight by shifting `background-position` per frame. For real reflections, use Three.js (see liquid-chrome below). |
|
||||
|
||||
### 3D / Blender-look (`@remotion/three`)
|
||||
|
||||
- **Real-time 3D logo reveals** — the default "premium intro." Render `<ThreeCanvas>`, drive camera/object rotation from `useCurrentFrame()` (**never `useFrame`**). Extrude logo via `TextGeometry`/extruded SVG shape, `meshStandardMaterial`, an HDRI `Environment`, `spring()`-driven entrance. Use our `StudioEnv/StudioLights/StudioFloor/StudioEffects` from `three-kit.tsx`.
|
||||
- **"Plushcore" / soft-3D / claymation** — friendly counter to hard chrome. Pre-render GLTF in Blender (subsurface/soft shaders), import via `useGLTF`; bobbing idle = `Math.sin(frame/fps)` on position. 2D fake-3D = big soft inner-shadows + highlight gradients in CSS.
|
||||
- **Mixed 2D/3D** — one of the strongest 2026 looks. Layer a `<ThreeCanvas>` behind/within absolutely-positioned 2D Remotion layers (SVG strokes, flat shapes); composite with blend modes.
|
||||
|
||||
### Surface & color treatments
|
||||
|
||||
- **Grain / texture / noise** — near-universal in 2026; add to almost everything, especially flat/gradient backgrounds. Cheap: tiling noise PNG overlay at low opacity with `mix-blend-mode: overlay`/`soft-light`. **Animated grain must move or it looks frozen** — offset `background-position` per frame, or SVG `feTurbulence` with a per-frame `seed`/`baseFrequency` jitter.
|
||||
- **Mesh gradients** — soft multi-point blends (not linear), the sophisticated 2026 background. Pre-bake a mesh PNG and slowly drift/scale it, or animate live with a fragment shader in Three.js driven by `frame`. **Always add grain on top.**
|
||||
- **Glassmorphism (evolved)** — used *selectively* (cards, lower-thirds), multi-layer depth, dynamic blur. `backdrop-filter: blur(16px) saturate(140%)`, semi-transparent `rgba` fill, 1px light top-border, soft shadow; animate blur radius + a moving specular highlight per frame; stack 2–3 panels at different parallax depths.
|
||||
- **Retro / Y2K** — chrome + iridescent mesh gradient + occasional pixelate (`image-rendering:pixelated` on a downscaled layer) + sparkle SVG bursts. Music/fashion/Gen-Z/party greetings.
|
||||
- **Anti-design / tactile brutalism** — hard `1px solid #000` borders, no radius, pure-saturated bg, system/monospace fonts, deliberate overlap. Motion is blunt — **hard cuts and `step`/`linear` snaps, not smooth springs.**
|
||||
- **Mixed-media / collage** — PNG cutouts with rough edges as layers; "paper-drop" overshoot springs on slight rotation/scale; tape/staple SVGs, paper-grain overlay, occasional 1-frame jump-cut jitter.
|
||||
- **Isometric** — `transform: rotateX(60deg) rotateZ(-45deg)` on stacked layers with `transform-style:preserve-3d`; stagger layer entrances; move elements along iso axes (matched X/Y deltas). Great for SaaS/feature explainers.
|
||||
- **Kinetic / liquid / morph** — morphing blobs (animate SVG `path d` with `flubber`; gooey via `feGaussianBlur` + `feColorMatrix`). **Liquid chrome (top-5 2026 animation trend):** Three.js `meshStandardMaterial` `metalness:1, roughness:~0.05` + HDRI `Environment` on a morphing/`MeshDistort`-style geometry — color shifts as the camera moves.
|
||||
- **AI-aesthetic vs anti-AI** — AI stills/clips as `<Img>`/`<OffthreadVideo>` backgrounds with Ken-Burns for surreal promos; lean into hand-type + grain + collage for trust/authenticity brands.
|
||||
|
||||
### Color direction 2026
|
||||
|
||||
- **Dopamine / electric accents** (electric blue, neon coral, acid yellow, vivid teal, cobalt) — as high-energy *accents* to guide the eye, never whole palettes.
|
||||
- **Tech pastels** (lavender haze, powder blue, digital pink) — calm/mature, great for SaaS/UI promos.
|
||||
- **Warm earth neutrals** (mocha/espresso/caramel/tan) — "quiet luxury," premium and human.
|
||||
- **Strategy:** bold dopamine accent + neutral/pastel base + grain on top. **Avoid all-flat saturated fields — they read as AI/template.** All color comes from `colorSchema` props so the studio can recolor; pass a user's hex through a grade so a garish value doesn't break the look.
|
||||
|
||||
---
|
||||
|
||||
## 2) Animating anything (craft + the pro workflow)
|
||||
|
||||
### The one rule everything hangs on
|
||||
A frame in Remotion is **pure**: `frame → pixels`. Motion is *evaluated* at an arbitrary frame, exactly the After Effects mental model (a keyframe graph sampled at time `t`). **Never** use `useFrame`, `Math.random()`, `Date.now()`, `setState`, or `useEffect`-driven motion — the renderer samples frames out of order and in parallel. Use `rand(seed)` from `anim.ts` for deterministic "randomness." If a value can't be derived from `frame`, it doesn't belong in the render.
|
||||
|
||||
### The 12 principles → Remotion (the four you reach for every shot in bold)
|
||||
|
||||
| Principle | Remotion expression |
|
||||
|---|---|
|
||||
| Squash & stretch | `scaleX`/`scaleY` inversely around an impact frame (conserve volume: `sx = 1/sy`) |
|
||||
| **Anticipation** | Dip the value below its start before the main move |
|
||||
| Staging | Stagger reveals; dim/blur everything but the hero |
|
||||
| Straight-ahead vs pose-to-pose | `interpolate` between frames (keyed) vs per-frame formula (sim, e.g. `Confetti3D`) |
|
||||
| **Follow-through & overlapping** | Same motion, **delayed per child** + a *looser* spring so parts settle later |
|
||||
| **Slow in & slow out** | `Easing.bezier` / `spring()` — the single biggest quality lever |
|
||||
| Arcs | Drive `y` with a `sin`/parabola while `x` moves linearly |
|
||||
| Secondary action | A small `sin` bob/shimmer alongside the primary reveal |
|
||||
| Timing | Frame count + spring `mass`/`damping` = weight & mood |
|
||||
| **Exaggeration / overshoot** | Overshoot > 1.0, then settle to 1.0 |
|
||||
| Solid drawing | `StudioLights` + reflective material + shadows (3D) |
|
||||
| Appeal | Choreography + `StudioEffects` (bloom/DOF/vignette) + good type |
|
||||
|
||||
### `spring()` vs `interpolate()`
|
||||
- **`interpolate`** — *you* author the curve. Use when a value must hit an exact mark on an exact frame (storyboard reveals, crossfades, value remaps). **Always set `extrapolateLeft/Right: "clamp"`** — forgetting this is the #1 cause of elements drifting off-screen or opacity going negative.
|
||||
- **`spring`** — *physics* authors the curve. Use for organic entrances, pops, bounces.
|
||||
- **Combine:** spring drives the *feel*, interpolate *remaps* its 0→1 output to real px/units: `const y = interpolate(spring(...), [0,1], [vmin(80), 0])`.
|
||||
|
||||
**Spring config cheat-sheet:**
|
||||
|
||||
| Feel | mass | damping | stiffness | Use for |
|
||||
|---|--:|--:|--:|---|
|
||||
| Snappy, no overshoot | 0.5 | 200 | 200 | Clean UI/logo reveals |
|
||||
| Natural pop (slight overshoot) | 0.6 | 12 | 180 | **Default** cards/badges/icons |
|
||||
| Bouncy / playful | 1 | 8 | 120 | Kids, birthday, mascots |
|
||||
| Heavy / weighty | 2.5 | 26 | 90 | Big titles, 3D objects landing |
|
||||
| Loose wobble (follow-through) | 1 | 6 | 80 | Secondary / trailing parts |
|
||||
|
||||
Lower damping = more overshoot; higher mass = heavier/slower; higher stiffness = faster snap.
|
||||
|
||||
### Easing cheat-sheet (linear is the sound of an amateur)
|
||||
- **Entrances → ease-out** (`Easing.out(Easing.cubic)`), your default. Hero titles → `Easing.out(Easing.quint)` or `Easing.bezier(0.16,1,0.3,1)`.
|
||||
- **Exits → ease-in** (`Easing.in(Easing.cubic)`), and **always sharper than the entrance** — things leave faster than they arrive.
|
||||
- **A→B on-screen moves / camera → ease-in-out.**
|
||||
- **Snappy "ta-da" → back/overshoot** `Easing.bezier(0.34,1.56,0.64,1)`.
|
||||
- **Wind-up → anticipate** `Easing.bezier(0.36,0,0.66,-0.56)`.
|
||||
- **Linear ONLY for mechanical continuous motion** — rotation, scroll, conveyor, marquee.
|
||||
|
||||
### Timing & frame budgets (30fps default — but use `sec(s)=Math.round(s*fps)`, never hardcode 30)
|
||||
Micro pop 8–14f · standard reveal 18–28f · hero entrance 28–40f · scene transition 12–20f · hold = a comfortable read of the text. **Cut frames before you add them — amateurs over-animate.** Robotic = linear spacing; floaty/late = timing too long.
|
||||
|
||||
### The four quality multipliers (concrete patterns)
|
||||
- **Anticipation:** `interpolate(frame,[0,6,30],[0,-0.12,1])` — small negative dip before launch.
|
||||
- **Overshoot+settle:** the back bezier, or a low-damping spring. Ensure the curve *reaches and holds* the target (clamp) or it micro-drifts forever.
|
||||
- **Follow-through:** drive children from the same trigger frame, **delay each** (`frame - start - i*stagger`) with a *looser* spring so they settle after the parent. Biggest "feels professional" upgrade for grouped elements.
|
||||
- **Staggering / choreography:** default to a **cascade**; tune per aspect via a `pick(wide,square,tall)` helper (wider reads faster → tighter stagger; tall reads slower → looser). Patterns: cascade (lists), center-out (logos/hero rows), random-but-deterministic via `rand(i)` (particles), beat-synced (snap `start` to music beats). **One thing enters the eye at a time** — staging.
|
||||
|
||||
### 3D motion
|
||||
Drive every transform off `useCurrentFrame()` (deterministic under ANGLE). Rotation/orbit = `linear` (mechanical); entrances/landings = `spring` with high mass for weight. Let `StudioEffects` (bloom + DOF + vignette) do the cinematic polish in one component.
|
||||
|
||||
### The pro workflow (5 passes, IN ORDER — polishing before timing is locked wastes the most time)
|
||||
1. **Reference** — decide the feel before code; pull an AE template/Dribbble loop; pick style (`../remotion-design-styles/SKILL.md`), type (`../persian-fonts/SKILL.md`), composition (`../remotion-template-composition/SKILL.md`), per-aspect rules (`../remotion-aspect-ratios/SKILL.md`); write the beat list ("logo in → tagline → 3 features cascade → CTA → out").
|
||||
2. **Blocking** — every element on screen at final position with crude `interpolate` fades, no easing. Fix off-screen/cropping in all three aspects now.
|
||||
3. **Timing** — lock frame counts, stagger, beats, holds, transitions. Watch at full speed repeatedly. Mood lives here.
|
||||
4. **Polish** — swap linear for easing/springs; add anticipation+overshoot+settle, follow-through, secondary motion, arcs, squash/stretch; `StudioEffects` for 3D; wire SFX (`../remotion-sound-effects/SKILL.md`) and music sync (`../remotion-music-picker/SKILL.md`) to the locked frames.
|
||||
5. **Review** — scrub frame-by-frame + full speed. Nothing pops without an ease; nothing leaves slower than it arrived; the eye always knows where to look; reads in all three aspects; Persian RTL not clipped; colors from `colorSchema`; re-render twice → identical (deterministic). Then run `../flatrender-template-seo/SKILL.md`.
|
||||
|
||||
### Top amateur mistakes → fixes (review gate)
|
||||
Linear motion → ease/spring · no anticipation/overshoot → dip-then-launch / back bezier · everything on one frame → stagger · forgot `clamp` → clamp both ends · hardcoded 30fps → `useVideoConfig().fps` · `useFrame`/`random`/`Date.now` → `useCurrentFrame` + `rand` · pixel-hardcoded sizes → `vmin`/`unit` + branch on `isWide/isSquare/isTall` · over-animating → one idea per beat · no hold → real hold sized to reading · exit = entrance speed → exits sharper · dead holds → `sin` bob/breathe/shimmer · flat 3D lighting → `StudioLights` + floor shadows + `StudioEffects` · color hardcoded → read from props.
|
||||
|
||||
---
|
||||
|
||||
## 3) Asset pipeline (collecting + designing footage, Iran-aware)
|
||||
|
||||
### The Iron Rule
|
||||
The Iran environment punishes runtime dependencies. **All assets are vendored** — download once (over VPN if needed), commit into `services/remotion/public/`, reference with `staticFile()`. **Never** `<Video src="https://somecdn..." />` in a template — a geo-block or flaky tunnel kills the render mid-frame. Mirror npm/NuGet/Docker via Nexus; asset *binaries* are sourced manually and committed. **Track licenses at acquisition time, not later.**
|
||||
|
||||
### License taxonomy (know cold)
|
||||
- **Ship freely, no attribution:** CC0 / Public Domain, Pixabay License, Pexels License, Unsplash License. **Default target.**
|
||||
- **Allowed with a credit:** CC-BY (track per-asset; needs attribution UI/end-card).
|
||||
- **Off-limits:** CC-BY-SA (share-alike can "infect" our proprietary template), CC-BY-NC (we are a paid product = commercial), editorial/rights-managed.
|
||||
- **Paid stock (Envato/Adobe/Shutterstock):** allowed per license; keep the receipt/license PDF. Note their dashboards/checkout often geo-block Iran at the account/payment layer even with a VPN — use a foreign-established account or a partner.
|
||||
|
||||
### Sourcing (CC0 / no-attribution first)
|
||||
- **Footage:** Pexels Videos (first stop), Pixabay Video, Mixkit, Coverr (check AI badge), Free Nature Stock, Videvo (filter to CC0). Pick exact resolution (don't ship 4K into a 1080p comp), prefer **H.264 MP4** for `<OffthreadVideo>`, commit under `public/footage/`.
|
||||
- **Images:** Pexels, Pixabay, Unsplash, StockSnap, Burst. For Persian/Iran imagery search English terms ("Tehran", "Iranian food") + self-shot/local stock.
|
||||
- **Textures & overlays:** Poly Haven Textures (CC0), ambientCG (CC0); grain/light-leak/dust = CC0 video clips you screen-blend.
|
||||
- **HDRIs:** Poly Haven (1k–2k for render speed), ambientCG.
|
||||
- **3D (GLB):** Poly Haven Models (CC0, cleanest), Kenney.nl (CC0 low-poly), Khronos glTF samples, Sketchfab (**mixed — check each**, filter Downloadable + CC0/CC-BY). **Prefer GLB over glTF+separate-textures** (one file, no broken paths); Draco-compress with `gltf-pipeline -b`; keep low-poly for headless render speed.
|
||||
- **Icons (bundle via Nexus npm, never CDN):** Lucide, Tabler, Heroicons, Phosphor, Iconoir. **Illustrations (recolorable SVG):** unDraw, Open Peeps, Humaaans — ship as SVG so the studio color picker recolors them (`../remotion-svg-colors/SKILL.md`).
|
||||
|
||||
**Iran access:** generally reachable — Pixabay, Mixkit, Coverr, Poly Haven, ambientCG, npm via Nexus. VPN-recommended/intermittent — Pexels, Unsplash, Sketchfab, GitHub raw. Sanction-blocked at account/payment — Adobe Stock, Envato. **Mitigation: batch all sourcing in one "asset run" over a stable tunnel, commit binaries, render never touches the open internet again.**
|
||||
|
||||
### AI-generated assets — when it's right
|
||||
- **Use when:** the asset doesn't exist as stock (specific Persian cultural scene, branded mascot), you need consistency across a template set (reference-image controls), or it's faster than a 5-site license hunt.
|
||||
- **Don't use when:** clean CC0 stock already exists, you need photographic authenticity, or the **free-tier commercial license is unclear** (many free tiers forbid commercial use / watermark — a legal landmine for a paid product).
|
||||
- **Iran-pragmatic recommendation: locally-hosted open models** — **HunyuanVideo 1.5** (self-hosted, ~RTX 4090, no geo-block/payment/watermark, full commercial control) for video; **FLUX/SDXL** locally for image/texture/illustration. Use hosted SaaS (Runway Gen-4, Kling 3.0) only when local quality is insufficient and a VPN+foreign-account path exists. **Always record prompt + tool + plan-tier + date** in the asset's sidecar.
|
||||
|
||||
### Designing & preparing footage in Remotion
|
||||
- **Media primitives:** `<OffthreadVideo>` = **default for all video in a render** (FFmpeg frame extraction, deterministic, no seek drift). `<Video>` = preview only. `<Img>` over raw `<img>` (waits for load → no half-loaded frames). `staticFile()` for every vendored asset; never an external URL in a shipped template.
|
||||
- **Color grading:** per-layer CSS `filter` + blend modes (`contrast/saturate/brightness/hue-rotate`). Build a shared `lib/grades.ts` preset set (`warm`, `teal-orange`, `mono`, `filmic`) so all templates grade consistently and the palette can drive `hue-rotate`/`saturate`. Heavy grading → pre-grade in DaVinci Resolve (free) before committing.
|
||||
- **Masking / keying:** no native keyer — pre-key in Resolve/AE and export **alpha** (ProRes 4444 or WebM/VP9 alpha), then `<OffthreadVideo>` it. Shape/gradient masks via CSS `maskImage`/`clipPath` or SVG `<mask>`.
|
||||
- **Seamless loops:** source loop-designed clips (Coverr/Mixkit) or crossfade-to-self with overlapping `<Sequence>`s; mirror-pingpong for imperfect footage. `<OffthreadVideo loop>` once first/last frames match.
|
||||
- **Overlays (the cheap "authentic" layer):** stack grayscale-on-black/white clips — **screen** for light-leaks/bokeh/dust, **overlay/soft-light** for grain, **multiply** for vignettes/paper. Keep palette-independent.
|
||||
- **Ken-Burns:** `interpolate` scale (start ≥ 1 overscan so no edge reveals) + translate; ease with `spring`/bezier; `objectFit:cover` + center-safe framing so all three aspects crop cleanly.
|
||||
- **Performance (headless Docker):** right-sized media, H.264 + `<OffthreadVideo>`, 1k–2k HDRIs, Draco GLB; raise `concurrency` carefully and watch RAM.
|
||||
|
||||
### Library structure + attribution firewall
|
||||
Create under `services/remotion/public/` (today only `fonts/`): `footage/{nature,business,abstract}/`, `overlays/`, `images/`, `textures/`, `hdri/`, `models/`, `icons/`, `illustrations/`, plus **`assets.json` + `ASSETS.md`**. Lowercase-kebab names, no spaces.
|
||||
|
||||
**`assets.json` — one row per asset, added at download time** (`file, source, url, author, license, attribution_required, commercial_ok, acquired, notes`). Conventions: every asset gets a row (no row = "unknown license = do not ship"); `attribution_required:true` must surface its credit on a shippable surface; sidecar `.license.txt` for AI prompts / paid receipts. **CI validation script** asserts every file in the media folders has a matching row with `commercial_ok:true`, else fails the build — this is the license firewall. `ASSETS.md` is a generated readable table for humans/legal. If the repo bloats, move large media to Git LFS or MinIO (already in stack) with a `predeploy` sync into `public/` — but present at render time.
|
||||
|
||||
---
|
||||
|
||||
## 4) Masterpiece production + platform playbook
|
||||
|
||||
### The 8 production-value layers (what separates "made in a tool" from "made by a studio")
|
||||
1. **Sound design + beat-sync** — the fastest "professional" tell. Beat-sync every key reveal (map BPM, keyframe on beat boundaries — hero on a downbeat); layered SFX (whoosh on transitions, thump on hard cuts, sparkle on shine sweeps, riser into the hero, pop on icon entrances); **ducking** (music dips under VO/key sound); **silence before the hero reveal** makes the payoff hit harder.
|
||||
2. **Micro-detail** — easing never linear; overshoot & settle; staggered 2–5f entrances; secondary motion (shadow/contents react); anticipation.
|
||||
3. **Design system** — one type scale (4–5 sizes), one spacing rhythm, constrained palette (1 primary + 1–2 accents + neutrals), consistent radii/strokes/elevation; Persian-first type handled deliberately (Vazirmatn + matched Latin pairing, not one font stretched across scripts).
|
||||
4. **Depth & lighting** — layered parallax (bg/mid/fg different speeds), soft directional shadows with one consistent light direction, atmospheric depth (bg blur/desaturate, fg sharp/saturated), rim light on hero.
|
||||
5. **Color grade** — one unified grade over the whole comp (not per-element colors fighting); lifted/tinted shadows, controlled highlights, deliberate temperature; user hex still passes through the grade.
|
||||
6. **Pacing / rhythm** — vary cut length, build to a climax, match cut rhythm to music, trim ruthlessly.
|
||||
7. **A clear hero moment** — one designated peak (logo/price-drop/product/CTA) with the biggest motion, strongest hit, often silence before, most screen real-estate. Flat = nothing lands.
|
||||
8. **Finishing texture (subtle!)** — low-opacity film grain, gentle vignette, 1–2px chromatic aberration strongest at impacts, tiny continuous camera drift (frame alive, not locked), sparing light-leaks/bokeh, motion blur on fast elements (its absence is a classic amateur tell).
|
||||
|
||||
### Pre-ship polish checklist (if you can't tick it, it's not done)
|
||||
- **Motion:** no linear easing anywhere; staggered entrances; motion blur on fast elements; ≥1 overshoot-and-settle; nothing pops on/off without a transition.
|
||||
- **Audio:** BPM mapped, reveals on beat; whoosh on every scene change; accent SFX on hero; music ducks under VO, no clipping, clean end.
|
||||
- **Composition/design system:** verified in 16:9 / 1:1 / 9:16 (not a letterboxed 16:9); text in platform safe zones; consistent scale/spacing/radii/shadows; constrained palette; FA + EN both correct (RTL, font, numerals).
|
||||
- **Depth & grade:** consistent light direction; bg depth treatment; unifying grade over the comp.
|
||||
- **Pacing & hero:** one unmistakable hero; varied cuts matched to music; engaging first frame (it's the thumbnail).
|
||||
- **Finishing:** subtle grain OR vignette present; frame alive; aberration/light-leak at transitions if style allows.
|
||||
- **Technical/QA:** clean render at target res (no flicker/z-fighting/font fallback); all editable fields (text/logo/image/colors) swap without breaking layout; longest text doesn't overflow, shortest doesn't look empty; loops cleanly if meant to.
|
||||
|
||||
### Platform playbook (2026)
|
||||
All vertical = **1080×1920, 9:16**. **First frame = the hook = the cover.** High-contrast captions (white/yellow, black outline) in the lower-middle third are the cross-platform default.
|
||||
|
||||
| Platform | Length sweet spot | Hook / retention | Safe zone (1080×1920) | Template implication |
|
||||
|---|---|---|---|---|
|
||||
| **IG Reels** | 7–15s punchy; 30–90s for depth (up to 90s) | First 2–3s decide stay/swipe; cleaner/less-cluttered text than TikTok; rewards 3-sec view rate + completion | ~108 top, ~320 bottom, ~60 L, ~120 R; hook text Y≈200–600 | Cleaner kinetic type, mesh-gradient + glass lower-thirds, refined transitions |
|
||||
| **IG Story** | full-bleed | heavy UI chrome | avoid top ~250 (profile) + bottom ~250 (reply/link) | design poll/quiz/link sticker zones into layout |
|
||||
| **IG Feed post** | — | first caption line is the hook | — | Portrait **4:5 (1080×1350)** standard; 1:1 for grid consistency |
|
||||
| **TikTok** | 15–30s engagement; 11–18s virality; ≤60s educational | **3-second rule**; curiosity-gap / bold-claim hooks; word-by-word captions beat full sentences | keep right ~120, bottom ~320 clear of key content | calm neutral grain+warm-earth variant; **word-by-word captions as a first-class editable layer** |
|
||||
| **YT Shorts** | 15–35s | **no runway** — open on the most compelling moment; intro retention >70%, completion >60% (<30s) | center within middle ~1080×1350; clear bottom UI + right buttons | cinematic 3D logo reveals, graded looks |
|
||||
| **YT long-form intro** | — | cold-open hook in first 5–15s; branded sting <3s | — | state payoff first, brand second |
|
||||
| **YT end screen** | last 5–20s | — | leave clean plate (lower + right) for subscribe/next/playlist | reserve an end-card-safe zone |
|
||||
| **All three** | drifting to **60–90s** | authenticity > perfection, phone-feel > studio, natural light, cinematic grading | — | hook in first 1–2s; grain/texture everywhere; support longer durations; safe-zone all text |
|
||||
|
||||
**Cross-platform synthesis rules:** (1) design to the *tightest* safe zone (Story/TikTok), then it's safe everywhere; (2) first frame = the hook = the cover; (3) front-load the payoff, no preamble; (4) captions are a first-class editable layer (word-by-word), not an afterthought; (5) one template, **three real aspects** — re-flow, never letterbox.
|
||||
|
||||
---
|
||||
|
||||
## 5) Prioritized "level up our skills + templates" action list
|
||||
|
||||
Ordered by ROI. Each item ties to our stack and the relevant skill file.
|
||||
|
||||
**Tier 0 — foundation infra (do first; unblocks everything else)**
|
||||
1. **Establish the asset library + license firewall.** Create the `public/{footage,overlays,images,textures,hdri,models,illustrations}` tree, `assets.json` + `ASSETS.md`, and the **CI validation script** (`commercial_ok` + matching-row check). Do one batched VPN "asset run" of a CC0 starter pack (grain/light-leak/dust overlays, 3–4 mesh-gradient PNGs, a few Poly Haven HDRIs + GLBs, business/nature/abstract footage). *(Asset pipeline §3.)*
|
||||
2. **Promote shared helpers into `lib/`.** Add `pick(wide,square,tall)` onto `Layout` in `aspect.ts` (currently only `isWide/isSquare/isTall/vmin/unit`); create `lib/grades.ts` (warm/teal-orange/mono/filmic + palette-driven `hue-rotate/saturate`); confirm `rand`/`hexToRgba`/`mixHex` in `anim.ts` cover deterministic needs. *(Animation §2, Asset §3.)*
|
||||
3. **Stand up a local AI-asset box** (HunyuanVideo 1.5 + FLUX/SDXL) so bespoke Persian/branded assets don't depend on geo-blocked SaaS. *(Asset §3.)*
|
||||
|
||||
**Tier 1 — highest-ROI template work**
|
||||
4. **Build a "captions" engine as a reusable first-class layer** — word-by-word kinetic captions, high-contrast white/yellow + black outline, lower-middle-third, safe-zoned for all platforms, beat-syncable. This is currently an afterthought and is the biggest cross-platform win. *(Masterpiece §4, Animation §2.)*
|
||||
5. **Ship the "kinetic oversized type + grain" template** — every aspect, cheap (CSS), uses variable Vazirmatn weight-animation for Persian hero type. Highest ROI per the trends brief. *(Trends §1, Animation §2.)*
|
||||
6. **Codify the pre-ship polish checklist + 8 layers into a review gate** (extend `../flatrender-template-seo/SKILL.md`'s publish step, or a new lint pass) so no template ships without easing, beat-synced audio, three-aspect verification, and a hero moment. *(Masterpiece §4.)*
|
||||
7. **Sound-design pass on the existing pack** — wire `../remotion-music-picker/SKILL.md` BPM mapping + `../remotion-sound-effects/SKILL.md` placement + ducking into our current templates. Fastest "professional" upgrade to what already exists. *(Masterpiece §4.)*
|
||||
|
||||
**Tier 2 — premium differentiation**
|
||||
8. **3D logo-reveal template + a liquid-chrome variant** on `@remotion/three` + HDRI (`three-kit.tsx` `StudioEnv/Lights/Floor/Effects`), all motion off `useCurrentFrame()`. Premium tier. *(Trends §1, Animation §2.)*
|
||||
9. **Mesh-gradient + glass lower-third promo** — clean modern, IG-friendly, palette-driven, grain on top. *(Trends §1.)*
|
||||
10. **Grunge / collage / anti-AI pack** — rides the authenticity wave for youth/music; uses hand-type, paper overlays, deterministic jitter. *(Trends §1.)*
|
||||
|
||||
**Tier 3 — craft & process maturity**
|
||||
11. **Adopt the 5-pass workflow (reference → blocking → timing → polish → review) as the team norm**, and seed a reference library (AE templates / Dribbble loops) per template type. *(Animation §2.)*
|
||||
12. **Per-aspect tuning audit** of existing templates — stagger + scale via `pick`, re-flow not letterbox, confirm Persian RTL never clips. *(Animation §2, `../remotion-aspect-ratios/SKILL.md`.)*
|
||||
13. **Color system discipline** — enforce dopamine-accent-+-neutral/pastel-base + grain-overlay defaults; run user hex through the grade so no garish value breaks a look. *(Trends §1, Masterpiece §4.)*
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
**Trends:** [Envato — Motion Trends 2026](https://elements.envato.com/learn/motion-design-trends) · [MonkyVision](https://monkyvision.com/blog/motion-design-trends/) · [GraphicDesignJunction](https://graphicdesignjunction.com/2026/01/video-and-motion-creative-trends-2026/) · [Krumzi](https://www.krumzi.com/blog/12-graphic-design-trends-shaping-2026-and-how-ai-is-changing-the-game) · [It's Nice That](https://www.itsnicethat.com/features/forward-thinking-graphic-trends-2026-graphic-design-120126) · [Fontfabric](https://www.fontfabric.com/blog/10-design-trends-shaping-the-visual-typographic-landscape-in-2026/) · [Kittl](https://www.kittl.com/blogs/graphic-design-trends-2026/) · [StudioMeyer](https://studiomeyer.io/en/blog/webdesign-trends-2026) · [Envato — 3D Trends](https://elements.envato.com/learn/3d-design-trends) · [Patata School](https://www.patataschool.com/blender-typography-in-motion) · [Lummi — Animation Trends](https://www.lummi.ai/blog/animation-trends-2026) · [Fireart — Tactile Brutalism](https://fireart.studio/blog/the-best-web-design-trends/) · [AND Academy — Color](https://www.andacademy.com/resources/blog/graphic-design/color-trends-for-designers/) · [Gelato — Colors](https://www.gelato.com/blog/trending-colors) · [Adobe Express — Color of Year](https://www.adobe.com/express/learn/blog/color-of-year-trends) · [ALM Corp — Short-form](https://almcorp.com/blog/short-form-video-mastery-tiktok-reels-youtube-shorts-2026/) · [ShortSync](https://www.shortsync.app/resources/short-form-video-trends-2026) · [Sprout Social](https://sproutsocial.com/insights/social-media-trends/) · [FrameFlow](https://frameflowedit.com/article/top-5-video-editing-trends-in-2026)
|
||||
|
||||
**Assets:** [Colorlib — Stock Video](https://colorlib.com/wp/best-free-stock-video-sites/) · [FreeConvert](https://www.freeconvert.com/blog/best-stock-video-sites/) · [Moonb](https://www.moonb.io/blog/best-stock-video-sites) · [awesome-cc0](https://github.com/madjin/awesome-cc0) · [Poly Haven](https://polyhaven.com/) · [Poly Haven License](https://polyhaven.com/license) · [Khronos glTF Samples](https://github.com/khronosgroup/gltf-sample-models) · [11 Free 3D Asset Sites](https://dev.to/markyu/11-free-3d-asset-sites-for-games-blender-and-webgl-ah2) · [Iran censorship (Wikipedia)](https://en.wikipedia.org/wiki/Internet_censorship_in_Iran) · [Iran tiered internet (Rest of World)](https://restofworld.org/2026/iran-blackout-tiered-internet/) · [Tech sanctions sheet](https://docs.google.com/spreadsheets/d/1b9tetXkMg4PB_XyWcsC_UWGv45MX3pZmasnDBhyQxlY/edit) · [Blocked in Iran](https://www.irun2iran.com/websites-and-social-media-blocked-in-iran/) · [AI Video 2026](https://aiunpacking.com/guides/ai-video-generation-sora-runway-kling-veo/) · [Best AI Video (PixVerse)](https://pixverse.ai/en/blog/best-ai-video-generators) · [Best AI Video (Pixflow)](https://pixflow.net/blog/best-ai-video-generator/)
|
||||
|
||||
**Masterpiece + platform:** [IG Safe Zone (Outfy)](https://www.outfy.com/blog/instagram-safe-zone/) · [Reels Safe Zones (TryMyPost)](https://www.trymypost.com/blog/instagram-reels-safe-zones-text-placement-2026) · [IG Reel Size (InVideo)](https://invideo.io/blog/instagram-reel-size-guide/) · [IG Story Dimensions (AdMake)](https://admakeai.com/blog/instagram-story-dimensions-2026) · [TikTok Length (Go-Viral)](https://www.go-viral.app/blog/tiktok-video-length/) · [TikTok 3-Second Rule (2Point)](https://www.2pointagency.com/glossary/tiktok-creative-best-practices-the-3-second-rule/) · [TikTok Hooks (Selfstorming)](https://www.selfstorming.com/guides/social-media-hooks/tiktok-video-hooks) · [TikTok Captions (Blitzcut)](https://blitzcutai.com/blog/best-caption-style-tiktok) · [Shorts Length (OpusClip)](https://www.opus.pro/blog/ideal-youtube-shorts-length-format-retention) · [Shorts Best Practices (Miraflow)](https://miraflow.ai/blog/youtube-shorts-best-practices-2026-complete-guide) · [Shorts Safe Zone (Kreatli)](https://kreatli.com/guides/youtube-shorts-safe-zone) · [Post Production (Balance Studio)](https://www.balancestudio.tv/blog/color-grading-sound-mixing-motion-graphics-what-youre-really-paying-for-in-post-production) · [Sound Design in Motion (GUVI)](https://www.guvi.in/blog/sound-design-in-motion-graphics/)
|
||||
|
||||
**Stack files referenced:** `D:\Projects\Flatrender2\services\remotion\src\lib\{anim,aspect,branding,fonts}.ts`, `three-kit.tsx`, `kit.tsx` · `src\templates.tsx` · asset root to create: `services\remotion\public\` (currently only `fonts\`) · manifest to create: `services\remotion\public\{assets.json,ASSETS.md}` · suggested: `src\lib\grades.ts`.
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
name: remotion-aspect-ratios
|
||||
description: How to design ONE Remotion template that genuinely fits all three FlatRender aspects — 16:9, 1:1, 9:16 — without text cropping, off-screen elements, or a layout that is really just the 16:9 version letterboxed. Use whenever building or reviewing a template's layout. Read this BEFORE positioning any text or element.
|
||||
---
|
||||
|
||||
# Designing for 16:9 / 1:1 / 9:16 (do this right)
|
||||
|
||||
Every template registers in all three aspects (`ASPECTS` in `src/lib/aspect.ts`). A common mistake (made in early FlatRender templates) is to design for 16:9 and just let the same coordinates render in 9:16 — which **crops text, pushes elements off-screen, and looks broken**. A template must be *re-laid-out* per aspect, not scaled.
|
||||
|
||||
## Two strategies — responsive component OR per-aspect components
|
||||
There are TWO legitimate ways to support the three aspects; pick per template:
|
||||
|
||||
1. **One responsive component** (default) — a single composition that adapts via `useLayout()` (`isWide/isSquare/isTall`, `pick()`). Use when the design is fundamentally the same and only positions/sizes change. Less code, stays in sync.
|
||||
|
||||
2. **A dedicated component per aspect** — when the design must differ STRUCTURALLY (different layout, different scene, different element set), not just reposition. e.g. a cinematic wide hero vs a stacked vertical story vs a centered square badge can be genuinely different scenes.
|
||||
|
||||
The registry supports both. In `services/remotion/src/templates.tsx` a `TemplateDef` has `component` (shared default) plus an optional `componentsByAspect` map keyed by aspect id:
|
||||
```tsx
|
||||
{
|
||||
id: "MyTemplate",
|
||||
component: MyTemplateWide, // fallback for any aspect not overridden
|
||||
componentsByAspect: {
|
||||
"1x1": MyTemplateSquare, // dedicated square design
|
||||
"9x16": MyTemplateTall, // dedicated vertical design
|
||||
},
|
||||
schema, durationSec, defaultProps, // SHARED across aspects — keep the editable
|
||||
} // fields, props and duration identical
|
||||
```
|
||||
`Root.tsx` picks `componentsByAspect[aspectId] ?? component`. **Keep `schema`, `defaultProps`, and `durationSec` shared** so the studio shows the same editable fields and the same composition ids (`${id}-${aspect}`) regardless — only the visual layout differs. Reuse shared sub-components (background, characters, text overlay) across the per-aspect files so they don't drift.
|
||||
|
||||
Guideline: start with one responsive component; split into per-aspect components only when responsive branching gets gnarly or the designs truly diverge. Don't duplicate three files when `pick()` would do.
|
||||
|
||||
## Why the naive approach breaks
|
||||
`useLayout().vmin(n)` sizes off the SHORT side (1080 in all three aspects), so a `vmin(92)` font is the same pixel size everywhere. But the WIDTH differs hugely: **1920px (16:9) vs 1080px (9:16)**. A headline that fits 1920 wide overflows/crops at 1080 wide. Likewise positioning at `width*0.34` puts an element in a totally different place relative to its own size when width changes.
|
||||
|
||||
## The rules
|
||||
|
||||
1. **Design 9:16 (tall) first.** It's the tightest. If it fits there, widening to 1:1 and 16:9 is easy. Building 16:9-first guarantees the tall version breaks.
|
||||
|
||||
2. **Branch layout on `L.isWide / L.isSquare / L.isTall`** — don't just scale. Things that sit side-by-side in 16:9 should STACK vertically in 9:16:
|
||||
```tsx
|
||||
const L = useLayout();
|
||||
// hero element position differs per aspect
|
||||
const heroX = L.isTall ? L.width * 0.5 : L.width * 0.34; // centered in tall, left in wide
|
||||
// layout direction
|
||||
flexDirection: L.isTall ? "column" : "row"
|
||||
```
|
||||
Add a tiny helper to `aspect.ts` and use it everywhere:
|
||||
```ts
|
||||
pick: <T,>(wide: T, square: T, tall: T): T =>
|
||||
kind === "wide" ? wide : kind === "tall" ? tall : square,
|
||||
```
|
||||
Then: `fontSize: L.pick(L.vmin(92), L.vmin(84), L.vmin(72))`.
|
||||
|
||||
3. **Cap font size to the WIDTH, not just the short side.** Headlines must wrap, never crop. Always set `maxWidth` and let text wrap:
|
||||
```tsx
|
||||
maxWidth: L.width * 0.86, // safe text column
|
||||
// and scale type DOWN in tall:
|
||||
fontSize: L.pick(L.vmin(90), L.vmin(80), L.vmin(64)),
|
||||
wordBreak: "normal", lineHeight: 1.15,
|
||||
```
|
||||
Test with the LONGEST realistic Persian string for that field, not the short default.
|
||||
|
||||
4. **Respect SAFE ZONES.** Keep all meaningful content inside the central safe area; give tall more vertical margin:
|
||||
- 16:9: ~5% horizontal / 8% vertical padding.
|
||||
- 9:16: ~8% horizontal, and keep the hero in the middle 60% vertically (top/bottom of phones get UI chrome).
|
||||
Anchor text blocks to a zone (top third / bottom third), put the hero visual in the center.
|
||||
|
||||
5. **Reposition the hero per aspect.** A character/object that's at `x=34%` and text on the right in 16:9 should become hero-centered with text above/below in 1:1 and 9:16. Use `pick()` for x/y and for `justifyContent`/`alignItems`.
|
||||
|
||||
6. **Scale element COUNT/spread, not just size.** A row of 5 floating shapes that spans 1920 looks sparse/clipped at 1080 — reduce spread radius or count in tall (`L.pick(...)`).
|
||||
|
||||
7. **3D:** adjust `camera.fov` / `position.z` per aspect so the subject fills the frame (a tall frame needs the camera pulled back or a narrower fov). Keep the 2D text overlay using the same `pick()` rules.
|
||||
|
||||
## Mandatory verification (the step that was skipped before)
|
||||
Render a still in **all three aspects** at a frame where text is visible, with a LONG test string, and LOOK at each:
|
||||
```
|
||||
npx remotion still src/index.ts "<Comp>-16x9" out/_a.png --frame=NN
|
||||
npx remotion still src/index.ts "<Comp>-1x1" out/_b.png --frame=NN
|
||||
npx remotion still src/index.ts "<Comp>-9x16" out/_c.png --frame=NN
|
||||
```
|
||||
Reject if: text is clipped at any edge, an element is off-frame, the hero is tiny/huge, or the tall version is obviously "the wide one squished".
|
||||
|
||||
## Checklist
|
||||
- [ ] Designed tall-first; used `pick()`/`isTall` to branch layout (not just scale).
|
||||
- [ ] Headlines wrap with `maxWidth`; tested with long Persian text — no cropping.
|
||||
- [ ] Hero repositioned/centered per aspect; content in safe zones.
|
||||
- [ ] Spread/count adjusted for narrow frames; 3D fov/camera tuned per aspect.
|
||||
- [ ] Eyeballed stills in ALL THREE aspects.
|
||||
|
||||
Related: `../remotion-template-composition/SKILL.md`, `../remotion-design-styles/SKILL.md`.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: remotion-character-design
|
||||
description: How to build and animate 2D (SVG) and 3D (@remotion/three) characters and mascots for FlatRender Remotion templates. Use when a template needs a character — a mascot, Haji Firuz, animals (goldfish, butterflies), people, or any articulated figure. Covers construction from primitives, rigging via grouped transforms, animation cycles (walk/dance/idle), facial expression, and the 2D-vs-3D / GLTF trade-offs.
|
||||
---
|
||||
|
||||
# Character design for Remotion
|
||||
|
||||
We have NO rigged 3D model assets and the asset CDNs are geo-blocked, so characters are built from **primitives** — SVG shapes (2D) or Three.js geometries (3D) — and animated off `useCurrentFrame()`. Reference implementations: `NowruzGreeting.tsx` (2D Haji Firuz, goldfish, butterflies) and `Nowruz3D.tsx` (3D scene).
|
||||
|
||||
## Core principle: build in parts, rig with groups
|
||||
A character = a tree of grouped parts, each animated by transforming its group around a PIVOT.
|
||||
- 2D: nested `<g transform="translate/rotate/scale">`. Put the pivot at the joint (e.g. shoulder) by translating the group there, drawing the limb from origin, and rotating the group.
|
||||
- 3D: nested `<group position rotation>`. Same idea — position the group at the joint, model the limb from there, rotate the group.
|
||||
|
||||
## 2D characters (SVG)
|
||||
Construction kit:
|
||||
- Head = `<circle>`; body = `<path>` trapezoid (`M.. Q..`); limbs = `<rect rx>` (rounded) or `<path>`; hands/feet = small ellipses; hat = `<path>` triangle/cone.
|
||||
- Face (friendly/stylized): two dot eyes, a `Q` curve smile, rosy `circle` cheeks at low opacity. Keep it simple — over-detailing reads worse, not better.
|
||||
- Skin/clothing: flat fills + one darker shade for a soft AO at edges (`fillOpacity` overlay).
|
||||
|
||||
Animating cycles (all from `frame`):
|
||||
- **Idle/breathe:** body `scale.y = 1 + 0.02*sin(frame/30)`.
|
||||
- **Dance/bounce:** whole body `translateY = -abs(sin(frame/7))*H`, plus a small `rotate(sin(frame/7)*4)` sway; legs counter-rotate.
|
||||
- **Limb swing:** `rotate(amp*sin(frame/period))` around the joint pivot.
|
||||
- **Hop-in entrance:** `spring()` from off-frame X to the target, then switch to the idle/dance loop.
|
||||
- **Prop shake (tambourine, flag):** `rotate(sin(frame/3.2)*18)` + sparkle accents on peaks.
|
||||
|
||||
## 3D characters (primitives)
|
||||
Build the figure from: `sphereGeometry` (head/joints), `cylinderGeometry`/`capsule` (limbs/torso), `coneGeometry` (hat/skirt), `RoundedBox` (blocky bodies), `torusGeometry` (rings/mouth). Group per limb for articulation. Material: `meshStandardMaterial` (roughness ~0.5 for cloth, lower for shiny). Light with `three-kit` StudioLights; add a soft contact shadow (a dark blurred plane or `shadows` + a floor).
|
||||
- 3D animation is the SAME math (rotate/translate groups by `frame`), just in 3D space and you can also move the camera/scene for parallax.
|
||||
- Faces in 3D are hard — keep them simple (sphere eyes, a small torus/curve mouth) or face the character slightly away.
|
||||
|
||||
## Animation principles (what makes it not look stiff)
|
||||
- **Anticipation:** dip before a jump, wind-up before a throw.
|
||||
- **Squash & stretch:** scale on impacts/landings (subtle: ±8%).
|
||||
- **Overlap / secondary motion:** hat ball, scarf, string, ears, tambourine lag behind the body — offset their phase.
|
||||
- **Easing:** `Easing.out(Easing.cubic)` for arrivals, `spring()` for bouncy pops, never linear for organic motion.
|
||||
- **Hold:** let a pose read for a beat before the next move.
|
||||
|
||||
## Cultural / brand-safety notes
|
||||
- Default to a **modern stylized** look (friendly, non-realistic) unless the user asks otherwise. Specifically for Haji Firuz: red costume + conical hat + tambourine, but a friendly non-blackface face (avoids the blackface connotation).
|
||||
- Confirm a storyboard with the user BEFORE building complex characters (the project convention) — list the cast, the beats, and the look.
|
||||
|
||||
## When NOT to hand-build
|
||||
- Photoreal humans or complex rigged motion → out of scope without GLTF assets; propose a stylized take or a non-character design instead.
|
||||
- If a GLTF model IS provided, load with drei `useGLTF` and animate transforms by `frame` (no `useFrame`).
|
||||
|
||||
## Workflow
|
||||
Storyboard → build parts → rig groups → animate cycles → **render stills at 3-4 beats and LOOK** → refine → render all 3 aspects (see `../remotion-aspect-ratios/SKILL.md` — characters must sit in the safe zone in 9:16 too).
|
||||
|
||||
Related: `../remotion-design-styles/SKILL.md`, `../remotion-aspect-ratios/SKILL.md`, `../remotion-template-catalog/SKILL.md`.
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: remotion-design-styles
|
||||
description: Visual art-direction reference for building FlatRender video templates with Remotion (2D) and Three.js/@remotion/three (3D). Use when starting a new template, picking an art style, designing color palettes, or designing objects/scenes. Covers flat, gradient/mesh, glassmorphism, neon, 3D/cinematic, paper-cut, isometric, luxury looks plus color theory, lighting, and material design.
|
||||
---
|
||||
|
||||
# Remotion design styles (2D + 3D)
|
||||
|
||||
Project location: `services/remotion/`. Shared helpers: `src/lib/anim.ts` (hexToRgba, mixHex, rand), `src/lib/branding.ts` (colorSchema + BRAND), `src/lib/aspect.ts` (useLayout), `src/lib/three-kit.tsx` (3D studio kit). Animate everything off `useCurrentFrame()` — never `Math.random()`/`Date.now()` (breaks determinism; use `rand(i)`), and in 3D never use R3F's `useFrame` (use `useCurrentFrame`).
|
||||
|
||||
## Pick a style first, then build
|
||||
Each template should commit to ONE art style — mixing reads as "basic". Catalog:
|
||||
|
||||
| Style | Look | How (Remotion) | Best for |
|
||||
|---|---|---|---|
|
||||
| Flat / minimal | solid fills, generous whitespace, 1-2 accents | SVG shapes, simple springs | corporate, clean promos |
|
||||
| Gradient / mesh | soft drifting color blobs | blurred radial-gradient divs animated by frame (see GradientPromo) | modern SaaS, backgrounds |
|
||||
| Glassmorphism | frosted translucent cards | `backdrop-filter: blur`, rgba fills, thin borders | UI/app promos |
|
||||
| Neon / glow | dark bg + luminous strokes | `drop-shadow`/`textShadow` glows, emissive in 3D | gaming, nightlife, tech |
|
||||
| 3D / cinematic | real depth, reflections, bokeh | @remotion/three + three-kit (lighting, MeshReflectorMaterial, bloom/DOF) | premium logo/product reveals |
|
||||
| Paper-cut / layered | stacked shapes with soft shadows | layered SVG + offset box-shadows | storytelling, kids, greetings |
|
||||
| Isometric | 2.5D objects on a grid | SVG with skew/`rotateX` CSS, or true 3D ortho camera | explainer, product, city scenes |
|
||||
| Luxury / gold | dark + metallic gold + serif | gold gradients, shine sweeps, slow easing | weddings, premium brands |
|
||||
|
||||
## 2D vs 3D — choose deliberately
|
||||
- **2D (SVG/CSS):** fast to render, crisp text, full control, no WebGL. Use for flat/gradient/glass/neon/character scenes, anything text-heavy.
|
||||
- **3D (@remotion/three):** depth, real lighting/reflections, bokeh, camera moves. Use for premium logo/product/abstract reveals. Costs render time. Setup is already done: R3F v9 + `Config.setChromiumOpenGlRenderer("angle")`. Reuse `three-kit.tsx` (StudioEnv, StudioLights, StudioFloor, StudioEffects, Confetti3D). Keep crisp Persian text as a 2D `<AbsoluteFill>` overlay ON TOP of `<ThreeCanvas>` — don't render Persian text in 3D.
|
||||
|
||||
## Color design
|
||||
- Drive every colorable element from the `colorSchema` props (accent / secondary / background / text) so the studio can recolor it — see `../remotion-svg-colors/SKILL.md`.
|
||||
- Build depth with VALUE, not just hue: dark bg → mid elements → bright accents/highlights. Add glow (`hexToRgba(accent, .6)` shadows) and a vignette (`inset 0 0 600px rgba(0,0,0,.6)`).
|
||||
- Gradients: 2-3 stops max; blend related hues (`mixHex(a,b,.5)`). Mesh look = several large blurred radial-gradient circles drifting on `sin(frame/…)`.
|
||||
- Contrast: text needs ≥ 4.5:1 over its backdrop — add a scrim/shadow when over busy/3D scenes.
|
||||
- Default palettes per mood: tech = blue→violet on near-black; festive = warm gold/red/green on cream or turquoise; luxury = gold on charcoal; fresh = teal/green on light.
|
||||
|
||||
## Object design
|
||||
- **2D:** compose from primitives — `<circle>`, `<rect rx>`, `<path>` (quadratic `Q` for organic curves). Reuse `rand(i)` for deterministic scatter (particles, confetti, petals). Add glow via SVG `filter: drop-shadow`.
|
||||
- **3D:** primitives + good material = premium. `meshStandardMaterial` with `metalness` 0.3-0.7, `roughness` 0.15-0.35, `flatShading` for faceted gems, `emissive`+`emissiveIntensity` for glow that bloom picks up (set `toneMapped={false}` on flames/glows). Light with 3-point + colored rims (StudioLights). Faceted icosahedron/octahedron/dodecahedron read as "gems"; RoundedBox for gifts/cards.
|
||||
|
||||
## Motion = polish
|
||||
Stagger entrances (don't reveal everything at once), use `spring()` for pops and `interpolate(..., Easing.out(Easing.cubic))` for slides, add secondary motion (breathe, drift, twinkle), and a subtle continuous camera/scene sway in 3D. Hold the final frame ~1s for readability.
|
||||
|
||||
## Quality checklist
|
||||
- One coherent style; depth via value + glow + vignette.
|
||||
- All colors come from props (recolorable).
|
||||
- Staggered, eased motion with secondary detail.
|
||||
- Renders deterministically (no random/date).
|
||||
- Verify visually: render stills at 3-4 key frames and LOOK before shipping.
|
||||
|
||||
Related: `../remotion-character-design/SKILL.md`, `../remotion-aspect-ratios/SKILL.md`, `../remotion-template-composition/SKILL.md`, `../remotion-svg-colors/SKILL.md`.
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: remotion-music-picker
|
||||
description: How to choose royalty-free background music for a FlatRender template and sync the animation to its beat/vibe. Use when picking a music bed for a template, matching mood and BPM to the visuals, syncing reveals to the beat, or sourcing free/royalty-free tracks.
|
||||
---
|
||||
|
||||
# Music picker for templates
|
||||
|
||||
> Status: templates currently ship without a music bed. This is the playbook for adding one. The right track + beat-synced motion is what makes a template feel "produced".
|
||||
|
||||
## Match music to the template's job
|
||||
| Template vibe | Genre / mood | Typical BPM |
|
||||
|---|---|---|
|
||||
| Corporate / SaaS logo | clean inspirational, soft piano + synth pluck | 90-110 |
|
||||
| Energetic promo / sale | upbeat pop, four-on-the-floor, claps | 120-130 |
|
||||
| Social / Insta / trendy | lo-fi or modern pop, punchy | 100-120 |
|
||||
| Epic / product reveal | cinematic build, big drum, riser | 70-90 build → hit |
|
||||
| Festive (birthday, Nowruz, party) | happy ukulele/marimba, bells | 110-128 |
|
||||
| Emotional (wedding, tribute) | warm piano/strings | 60-80 |
|
||||
| Tech / gaming | electronic, arpeggios, bass | 120-140 |
|
||||
| Luxury | downtempo, jazzy, smooth | 80-100 |
|
||||
| Minimal / explainer | light marimba/plucks, unobtrusive | 95-115 |
|
||||
|
||||
## Sync the animation to the beat (this is the magic)
|
||||
1. Know the track's BPM → frames per beat = `fps * 60 / bpm` (e.g. 30fps, 120bpm → 15 frames/beat).
|
||||
2. Land your hero reveals, pops, and cuts ON beats (multiples of frames-per-beat). Stagger small element pops on 1/2 or 1/4 beats.
|
||||
3. Put the BIG reveal on a musical downbeat or right after a riser/drop.
|
||||
4. For a known track, hardcode beat frames; for generic use, expose `bpm` as a prop and compute beat frames so motion stays in sync if the track changes.
|
||||
```tsx
|
||||
const FPS = 30, BPM = 120;
|
||||
const beat = (FPS * 60) / BPM; // frames per beat
|
||||
const onBeat = (n: number) => Math.round(n * beat);
|
||||
// reveal hero on beat 4, CTA on beat 8
|
||||
```
|
||||
|
||||
## Remotion wiring
|
||||
```tsx
|
||||
import { Audio, staticFile, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
const { durationInFrames } = useVideoConfig();
|
||||
const f = useCurrentFrame();
|
||||
<Audio src={staticFile("music/upbeat-120.mp3")}
|
||||
volume={(ff)=>interpolate(ff,[0,15,durationInFrames-20,durationInFrames],[0,0.7,0.7,0])} />
|
||||
```
|
||||
- Fade IN over ~0.5s and OUT over the last ~0.7s — never start/end abruptly.
|
||||
- Trim/loop the track to the template length; cut on a bar boundary so the end feels intentional.
|
||||
- If there's a voiceover, DUCK the music (~-12 dB) under it.
|
||||
- Store beds in `services/remotion/public/music/`, named with BPM (`upbeat-120.mp3`).
|
||||
|
||||
## Free / royalty-free sources (verify license per track)
|
||||
- **Uppbeat** (free tier, gives a clearance ID), **Pixabay Music** (CC0-ish), **Mixkit** (free for commercial), **Bensound** (free w/ attribution or licensed), **Free Music Archive** (per-track CC), **YouTube Audio Library** (downloadable, check terms), **Incompetech / Kevin MacLeod** (CC-BY).
|
||||
- ALWAYS check: commercial use allowed? attribution required? Keep a per-file license/attribution record. Prefer CC0 / royalty-free-with-commercial. Avoid anything Content-ID-flagged for a product that exports user videos.
|
||||
- For an Iran-facing product, also consider local royalty-free Persian/instrumental sources where licensing is clearer.
|
||||
|
||||
## Workflow
|
||||
1. Pick vibe + BPM from the table for the template's purpose.
|
||||
2. Source 2-3 candidate tracks (license-checked), audition against the animation.
|
||||
3. Re-time the key reveals to the chosen BPM's beats.
|
||||
4. Add `<Audio>` with fades; render and LISTEN; nudge beats until reveals hit on the downbeat.
|
||||
|
||||
Related: `../remotion-sound-effects/SKILL.md`, `../remotion-template-catalog/SKILL.md`.
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: remotion-sound-effects
|
||||
description: Which sound effects (SFX) to use for FlatRender video templates and exactly where to place them in the timeline. Use when adding audio punch to a template — whooshes, impacts, sparkles, pops, risers, confetti — and syncing them to keyframes with Remotion's Audio component.
|
||||
---
|
||||
|
||||
# Sound-effect design for templates
|
||||
|
||||
> Status: current FlatRender templates have NO audio yet. This skill is the playbook for adding it. SFX dramatically raise perceived quality — a logo "thud" + sparkle makes a reveal feel pro.
|
||||
|
||||
## How audio works in Remotion
|
||||
Use `<Audio>` from `remotion`, placed in the composition tree, timed to frames:
|
||||
```tsx
|
||||
import { Audio, useVideoConfig } from "remotion";
|
||||
// play a one-shot SFX starting at frame 55 (the logo-land beat)
|
||||
<Audio src={staticFile("sfx/impact.mp3")} startFrom={0} volume={0.8}
|
||||
// mount it only around its moment using a <Sequence from={55}> wrapper
|
||||
/>
|
||||
```
|
||||
Patterns:
|
||||
- Wrap one-shots in `<Sequence from={FRAME}>` so they trigger at the right beat.
|
||||
- `volume` can be a function of frame for fades: `volume={(f)=>interpolate(f,[0,10],[0,1])}`.
|
||||
- Layer SFX over the music bed (see `../remotion-music-picker/SKILL.md`); keep SFX ~ -6 dB under dialogue, on top of music.
|
||||
- Put shared SFX in `services/remotion/public/sfx/` and load with `staticFile()`.
|
||||
|
||||
## SFX → moment mapping (sync to the KEYFRAME, not vaguely)
|
||||
| Moment in the animation | SFX | Place at |
|
||||
|---|---|---|
|
||||
| Element/text flies in | **whoosh** (short, directional) | 2-3 frames BEFORE it lands |
|
||||
| Logo / hero lands | **impact / thud / boom** | the exact land frame (spring settle) |
|
||||
| Glitter / magic reveal | **sparkle / shimmer / chime** | over the particle gather (0.3-0.5s) |
|
||||
| Small element appears | **pop / tick / blip** | each appearance (stagger to match) |
|
||||
| Countdown ticking | **clock tick** per number, **ding/airhorn** on GO | each number frame |
|
||||
| Birthday / party | **party horn + confetti rustle**, soft **chime** | greeting reveal + confetti burst |
|
||||
| Sale / promo | **cash register "cha-ching" / coin**, **stamp** on the badge | badge pop |
|
||||
| Shine sweep across logo | **soft shimmer swell** | sweep start→end |
|
||||
| Transition between scenes | **whoosh + light riser** | on the cut |
|
||||
| Build-up before reveal | **riser / uplifter** (0.5-1.5s) | leading INTO the hero moment |
|
||||
|
||||
## Placement principles
|
||||
- **Anticipation:** risers and whooshes START before the visual peak and resolve ON it. A whoosh that lands with the logo sells the motion.
|
||||
- **One hero hit:** the reveal gets ONE big impact — don't stack 3 booms; it muddies.
|
||||
- **Match the motion curve:** fast spring = sharp transient; slow ease = soft swell.
|
||||
- **Stagger to the visuals:** if 5 elements pop on different frames, 5 pops on those frames (vary pitch slightly so it's not robotic).
|
||||
- **Less is more:** 3-6 well-placed SFX per template beats a wall of sound. Leave silence for contrast.
|
||||
- **Loudness:** normalize SFX, peak ~ -3 dB, sit them under the music bed; the final mix shouldn't clip.
|
||||
|
||||
## Free / royalty-aware SFX sources
|
||||
Mixkit, Pixabay (sound), Freesound (check each license — CC0 vs attribution), Uppbeat (free tier), and Remotion-safe CC0 packs. ALWAYS record the license per file; prefer CC0/royalty-free with commercial use. Keep an attributions file if any source requires it.
|
||||
|
||||
## Workflow
|
||||
1. Identify the 3-6 key beats (reveal, pops, transitions, CTA).
|
||||
2. Pick one SFX per beat from the table.
|
||||
3. Place via `<Sequence from={frame}>` + `<Audio>`; tune volume + a short fade.
|
||||
4. Render with audio and LISTEN — adjust timing by a few frames so hits land exactly on the visual.
|
||||
|
||||
Related: `../remotion-music-picker/SKILL.md`, `../remotion-template-composition/SKILL.md`.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: remotion-svg-colors
|
||||
description: How FlatRender makes template colors user-editable and generates per-scene SVG so the studio can recolor a template and preview it in real time. Use when wiring a template's colors to the studio color picker, choosing which elements are recolorable, or generating an SVG color-preview for a scene.
|
||||
---
|
||||
|
||||
# SVG + color system (real-time recolor)
|
||||
|
||||
Goal: a user opens a template, changes its colors, and sees the result update live. This works because every colorable element reads from a NAMED color, those names are stored as editable color elements in the DB, and a lightweight SVG representation lets the studio recolor without a full re-render.
|
||||
|
||||
## The color data model
|
||||
- **`content.shared_colors`** — project-wide colors (key = `element_key`, e.g. `accentColor`). Used by every scene.
|
||||
- **`content.scene_color_elements`** — per-scene colors (key = `element_key`, e.g. `frl_c1t1` for AE, or a Remotion prop name).
|
||||
- Studio copies these into `studio.saved_shared_colors` / `saved_scene_colors`; the render binder (`GetRenderBindings` in render-svc) returns them as `{element_key: hex}`.
|
||||
- For **Remotion**, those keys must equal the composition's `colorSchema` props: `accentColor`, `secondaryColor`, `backgroundColor`, `textColor` (from `src/lib/branding.ts`). The node-agent passes them as `--props`.
|
||||
- For **AE**, colours bind into the `frshare` comp's text layers (`bind.jsx`).
|
||||
|
||||
**Rule:** design every template so EVERY colorable element's color comes from a named prop — never a hardcoded hex for anything the user should be able to change. Seed a `shared_colors` row per color prop (the seed script already does accent/secondary/background/text).
|
||||
|
||||
## The SVG color-preview (live recolor without re-rendering)
|
||||
A full Remotion/AE re-render is too slow for a color picker. So a scene is also represented as an **SVG** whose shapes' `fill`/`stroke` reference the SAME color keys. The studio swaps the SVG's colors instantly as the user drags the picker.
|
||||
- AE pipeline: `content.projects.shared_colors_svg` + the `template_svg_previews` table + per-scene snapshots.
|
||||
- For a Remotion template, generate an SVG snapshot of a representative frame where colorable regions are tagged with their key, e.g.:
|
||||
```svg
|
||||
<rect ... fill="var(--accentColor)" data-color-key="accentColor"/>
|
||||
<text ... fill="var(--textColor)" data-color-key="textColor">...</text>
|
||||
```
|
||||
The studio sets CSS variables (`--accentColor: #...`) or rewrites `fill` by `data-color-key` to recolor live; on export the real props go to the renderer.
|
||||
|
||||
## How to author a recolorable template
|
||||
1. Use the 4 `colorSchema` props for all themeable color (add more named props only if a template genuinely needs them — and seed a matching `shared_colors` row for each).
|
||||
2. Keep colorable regions as FLAT fills/strokes that map cleanly to one key (gradients = blend of two named props via `mixHex`, still derived from props).
|
||||
3. Produce an SVG preview of the key frame with each region tagged `data-color-key` = the prop name, so the studio can map picker → region.
|
||||
4. Verify: changing a prop changes exactly the intended regions and nothing hardcoded stays the wrong color.
|
||||
|
||||
## Generating the SVG
|
||||
- Simple/flat scenes: hand-author or script an SVG mirroring the composition's shapes, tagging fills with the color keys.
|
||||
- 3D / complex scenes: an SVG can't represent them faithfully — fall back to a rendered key-frame thumbnail per color theme, or a simplified 2D SVG stand-in for the picker (note this limitation to the user).
|
||||
- Store alongside the template (the AE path uses `shared_colors_svg` / `template_svg_previews`; mirror that for Remotion).
|
||||
|
||||
## Pitfalls
|
||||
- A hardcoded hex on a "should-be-editable" element = the picker silently does nothing there. Audit for stray hexes.
|
||||
- Key mismatch (SVG `data-color-key` ≠ schema prop ≠ seeded `element_key`) breaks the binding — keep ONE naming source of truth.
|
||||
- Contrast: when users can recolor text + background, enforce/encourage a min contrast or the text can vanish.
|
||||
|
||||
Related: `../remotion-template-composition/SKILL.md`, `../remotion-design-styles/SKILL.md`.
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: remotion-template-catalog
|
||||
description: A taxonomy of video template TYPES to build for FlatRender, with the purpose, key editable elements, suggested style/aspect, and 2D-vs-3D recommendation for each. Use when deciding what template to create next, planning a content batch, or scoping a requested template into a known pattern.
|
||||
---
|
||||
|
||||
# Template catalog — what to build
|
||||
|
||||
FlatRender already has these (services/remotion `TEMPLATES`): IlluminatedCircles, KineticQuote, GradientPromo, VerticalStory, LogoMotion, Opener, InstaPromo, YouTubeIntro, Slideshow, HappyBirthday, SalePromo, QuoteCard, EventInvite, Countdown, GlitterReveal (editable logo), NowruzGreeting (2D characters), + 3D: Hero3D, Nowruz3D, Birthday3D, Promo3D.
|
||||
|
||||
Use this map to pick the NEXT one and to scope a request into a pattern. Each row: key editable elements · suggested style · best aspect(s) · 2D/3D.
|
||||
|
||||
## Brand / logo
|
||||
- **Logo reveal** — logo + tagline · any style · all aspects · 2D or 3D. (have: IlluminatedCircles, LogoMotion, GlitterReveal, Hero3D)
|
||||
- **Opener / intro sting** — title + subtitle · cinematic/kinetic · 16:9, 9:16 · 2D/3D. (have: Opener)
|
||||
- **Outro / subscribe / end-card** — CTA + socials + logo · flat/neon · 16:9, 9:16 · 2D. (gap)
|
||||
- **Lower-third / name tag** — name + role · clean/glass · 16:9 · 2D. (gap)
|
||||
|
||||
## Social / marketing
|
||||
- **Instagram post/story promo** — headline + image + CTA · gradient/bold · 1:1, 9:16. (have: InstaPromo, VerticalStory)
|
||||
- **YouTube intro/outro** — channel + subscribe · energetic · 16:9. (have: YouTubeIntro)
|
||||
- **TikTok/Reels hook** — big kinetic text · trendy · 9:16 · 2D. (gap)
|
||||
- **Sale / discount** — badge + headline + CTA · bold/3D gifts · all. (have: SalePromo, Promo3D)
|
||||
- **Product showcase / turntable** — product image/3D + specs · 3D cinematic · 16:9, 1:1. (gap — high value)
|
||||
- **Testimonial / review** — quote + stars + name/photo · clean · 1:1, 9:16. (gap)
|
||||
- **Explainer / feature list** — icon + text steps · isometric/flat · 16:9. (gap)
|
||||
- **Real-estate / listing** — photos + price + details · elegant · 16:9, 1:1. (gap)
|
||||
- **Restaurant / menu / food** — dish image + price · warm/appetizing · 1:1, 9:16. (gap)
|
||||
|
||||
## Greetings / occasions (great for characters + 3D)
|
||||
- **Birthday** — name + message · party/3D cake · all. (have: HappyBirthday, Birthday3D)
|
||||
- **Nowruz (نوروز)** — greeting + year · spring characters/3D Haft-Sin · all. (have: NowruzGreeting, Nowruz3D)
|
||||
- **Yalda (یلدا)** — pomegranate/watermelon, candles, warm night · 2D/3D · all. (gap — high value, Persian)
|
||||
- **Wedding / engagement** — names + date · luxury gold/floral · all. (gap)
|
||||
- **Eid / Ramadan / Mehregan** — lantern/crescent/autumn motifs · ornate · all. (gap, Persian/region)
|
||||
- **New Year / holidays** — countdown + fireworks · festive 3D · 16:9, 9:16. (gap)
|
||||
- **Condolence / tribute** — respectful, minimal, slow · muted palette · all. (gap)
|
||||
|
||||
## Content / text
|
||||
- **Quote card** — quote + author · kinetic/typographic · 1:1, 9:16. (have: QuoteCard, KineticQuote)
|
||||
- **Countdown** — target + number · energetic riser · all. (have: Countdown)
|
||||
- **Event invite** — title + date/place + RSVP · elegant · 1:1, 9:16. (have: EventInvite)
|
||||
- **Slideshow / photo gallery** — N images + captions · clean transitions · all. (have: Slideshow)
|
||||
- **Music visualizer** — audio-reactive bars + cover · neon/3D · 1:1, 9:16. (gap)
|
||||
|
||||
## How to choose next
|
||||
1. Prefer **gaps** with high value for an Iran-facing product: Yalda, wedding, product showcase, testimonial, lower-thirds, outro/subscribe, music visualizer.
|
||||
2. Pick a STYLE that differs from neighbors (don't ship five dark-particle reveals) — see `../remotion-design-styles/SKILL.md`.
|
||||
3. Decide 2D vs 3D by subject (logos/products/abstract → 3D shines; text/character/illustrative → 2D or hybrid).
|
||||
4. Confirm a storyboard with the user for anything character- or scene-heavy.
|
||||
|
||||
## Per-template build steps
|
||||
Storyboard (confirm) → build composition (lib helpers, design-styles, character-design) → make it fit all aspects (`../remotion-aspect-ratios/SKILL.md`) → wire editable text/logo/colors (`../remotion-template-composition/SKILL.md`, `../remotion-svg-colors/SKILL.md`) → pick fonts (`../persian-fonts/SKILL.md`) → optional music/SFX → render thumbnails + preview → seed (`scripts/seed_remotion_templates.py`) → deploy.
|
||||
|
||||
Related: every other remotion-* skill.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: remotion-template-composition
|
||||
description: How to compose the editable elements of a FlatRender template — text, logo, image/media, and supporting copy — into a clear, well-paced presentation. Use when laying out what goes where, deciding the visual hierarchy, wiring editable fields, or timing the reveal sequence of a template.
|
||||
---
|
||||
|
||||
# Composing a template (text / logo / image / copy)
|
||||
|
||||
A template is not just a nice animation — it's a *fill-in-the-blanks* product. Users edit a few fields and it must look great with THEIR text/logo. Design for editability + clarity.
|
||||
|
||||
## The binding model (how editable fields work)
|
||||
Editable elements live in the DB and bind to Remotion props by KEY (see `../remotion-svg-colors/SKILL.md` for the full pipeline):
|
||||
- **Text** → `content.scene_content_elements` of type `Text`; the element `key` MUST equal the composition's Zod schema field (e.g. `headline`, `tagline`). Studio shows a text input.
|
||||
- **Logo / image / media** → a `scene_content_elements` of type `Media`; key = a `z.string()` prop (e.g. `logoUrl`). Studio shows upload/replace. In the composition: `logoUrl ? <Img src={logoUrl}> : <DefaultMark/>`. See `GlitterReveal.tsx`.
|
||||
- **Colors** → `shared_colors` / `scene_color_elements`, key = a `colorSchema` prop.
|
||||
- Seed all of these via `scripts/seed_remotion_templates.py` (it has a `MEDIA` dict for image fields).
|
||||
|
||||
**Rule:** every visible piece of copy or media a user would want to change MUST be a prop + a seeded element. Don't hardcode the brand name, date, price, etc.
|
||||
|
||||
## Visual hierarchy (most templates need 2-4 tiers)
|
||||
1. **Primary** — the logo OR the headline/hero. Biggest, highest contrast, center of attention.
|
||||
2. **Secondary** — tagline / subtitle. ~35-45% of primary size.
|
||||
3. **Tertiary** — CTA, date, price badge, handle. Small but distinct (pill, badge, accent color).
|
||||
4. **Ambient** — decorative scene (particles, 3D, characters) — supports, never competes.
|
||||
|
||||
Size ratios that read well: primary `vmin(80-110)`, secondary `vmin(26-40)`, tertiary `vmin(24-32)`. Weight: primary 800-900, secondary 500-700, tertiary 600-900.
|
||||
|
||||
## Text legibility (critical over busy/3D backgrounds)
|
||||
- Add a scrim or shadow: `textShadow: 0 0 vmin(20) rgba(0,0,0,.7)`, or a semi-transparent panel behind text.
|
||||
- Persian is RTL: set `direction: "rtl"`, use `FONT` (Vazirmatn) from `lib/fonts.ts`. See `../persian-fonts/SKILL.md`.
|
||||
- Keep line length comfortable (`maxWidth ~ 86%`); never let text touch frame edges (see `../remotion-aspect-ratios/SKILL.md`).
|
||||
- For gradient text use `backgroundClip: text` + a `drop-shadow` for separation.
|
||||
|
||||
## Logo placement
|
||||
- Center-stage for logo reveals; corner/lockup for promos.
|
||||
- Always provide a branded DEFAULT (the FlatRender mark) so the template looks finished before the user uploads.
|
||||
- Constrain with `maxWidth/maxHeight` + `objectFit: contain` so any uploaded logo fits without distortion.
|
||||
|
||||
## Image / media
|
||||
- `objectFit: cover` for fullscreen backdrops (with a gradient scrim for text), `contain` for product/logo.
|
||||
- Add motion: slow Ken-Burns (`scale 1→1.08` + slight pan) so stills feel alive.
|
||||
- Mask into shapes (rounded rect, circle) for polish.
|
||||
|
||||
## Timing & pacing (the reveal sequence)
|
||||
Stagger — never reveal everything at frame 0. A typical 6s (180f @30) beat sheet:
|
||||
- 0-30: scene/background establishes, ambient starts.
|
||||
- 20-55: hero/logo springs in (`spring()`), optional flash/impact.
|
||||
- 55-90: headline rises/fades in.
|
||||
- 90-120: tagline fades, letter-spacing settles.
|
||||
- 120-160: CTA/date pops (`spring`, glow pulse).
|
||||
- last ~30f: hold everything still for readability.
|
||||
Give each text element ~0.6-0.8s on screen minimum before the next competes.
|
||||
|
||||
## Editability checklist
|
||||
- [ ] Every changeable text/logo/image/color is a prop + seeded element (key == schema field).
|
||||
- [ ] Branded default for logo + sensible default copy.
|
||||
- [ ] Clear 2-4 tier hierarchy; text legible over the background.
|
||||
- [ ] Staggered, eased reveal with a final hold.
|
||||
- [ ] Looks good with long Persian text and a tall logo (test it).
|
||||
|
||||
Related: `../remotion-svg-colors/SKILL.md`, `../remotion-aspect-ratios/SKILL.md`, `../persian-fonts/SKILL.md`, `../remotion-design-styles/SKILL.md`.
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
name: scene-transitions
|
||||
description: How to choreograph transitions BETWEEN scenes (and shots within a scene) in FlatRender Remotion templates — cut, dissolve, wipe, clip-path mask, morph, match-cut, shape transition, camera push, zoom/whip-blur — built from primitives. Use whenever a template has more than one scene/beat, when one element must hand off to the next, or when stitching multi-scene sequences so they feel seamless instead of slideshow-y. Read before sequencing scenes.
|
||||
---
|
||||
|
||||
# Scene transitions for Remotion
|
||||
|
||||
A multi-scene template lives or dies on its *joins*. A hard slideshow of fades reads as "made in a tool"; a transition that carries motion, color, or a shape across the cut reads as "made by a studio". We have **no `@remotion/transitions` package** (not a dependency) and asset CDNs are geo-blocked — so every transition is built from primitives: `<Sequence>`, `interpolate`/`spring`, CSS `clipPath`/`maskImage`, blend modes, and (for 3D) a camera move driven by `useCurrentFrame()`. Everything is a pure function of `frame` — **never** `useFrame`, `Math.random`, `Date.now` (use `rand()` from `lib/anim.ts`).
|
||||
|
||||
## The one structural rule: overlap, don't abut
|
||||
A clean transition needs scenes to **overlap** for the transition window (12–20f). Don't place `<Sequence>`s back-to-back — give the outgoing scene a tail and the incoming a head that share the window.
|
||||
|
||||
```tsx
|
||||
import { Sequence, useVideoConfig } from "remotion";
|
||||
const { fps } = useVideoConfig();
|
||||
const sec = (s: number) => Math.round(s * fps); // never hardcode 30
|
||||
const T = sec(0.5); // transition window
|
||||
// Scene A holds frames 0..120, Scene B starts at 120-T so they cross-fade
|
||||
<Sequence from={0} durationInFrames={120}><SceneA /></Sequence>
|
||||
<Sequence from={120 - T} durationInFrames={120}><SceneB /></Sequence>
|
||||
```
|
||||
Inside each scene, derive a local progress from `useCurrentFrame()` (already 0-based inside a `Sequence`) for its *in* and *out* phases.
|
||||
|
||||
## Transition catalog — what to build, when, and how
|
||||
|
||||
| Transition | فارسی | Feel / when | Build (primitive) |
|
||||
|---|---|---|---|
|
||||
| **Cut** | برش | Hard, energetic, beat-synced, brutalist/anti-design | No overlap; `<Sequence>` ends, next begins. Snap a 1f flash or shake for punch. |
|
||||
| **Dissolve / crossfade** | محو | Calm, elegant, photo decks, luxury | Outgoing `opacity 1→0`, incoming `0→1` over the window, `clamp` both. |
|
||||
| **Wipe** | پاککن | Directional energy, news/promo | `clipPath: inset()` on the incoming layer sweeps a hard edge (see below). |
|
||||
| **Clip-path mask reveal** | ماسک | Premium reveals, shape brand moment | Animate a `circle()`/`polygon()` `clipPath` open over the new scene. |
|
||||
| **Morph** | ریختگردانی | Liquid/organic, kinetic trend | Animate SVG `path d` (`flubber`-style) or `feGaussianBlur`+`feColorMatrix` gooey merge. |
|
||||
| **Match-cut** | برش تطبیقی | Storytelling, "made by a studio" | A shape/element at the SAME position+size in both scenes; cut while it's identical. |
|
||||
| **Shape transition** | گذار شکلی | Brand mark grows into scene | A circle/blob scales up to fill frame (color = `accent`), then the new scene is revealed inside it. |
|
||||
| **Camera push / dolly** | حرکت دوربین (۳بعدی) | Cinematic, 3D logo/product | Move the R3F camera `position.z` / target between two staged setups by `frame`. |
|
||||
| **Zoom / whip blur** | زوم/تار حرکتی | Fast, hype, music, TikTok | Scale up + `filter: blur()` on out, scale-down + blur-out on in; peak blur ON the cut. |
|
||||
|
||||
### Wipe (clip-path inset)
|
||||
```tsx
|
||||
import { useCurrentFrame, interpolate, Easing } from "remotion";
|
||||
const f = useCurrentFrame(); // local frame in the incoming Sequence
|
||||
const p = interpolate(f, [0, sec(0.45)], [0, 100], {
|
||||
extrapolateLeft: "clamp", extrapolateRight: "clamp",
|
||||
easing: Easing.bezier(0.16, 1, 0.3, 1),
|
||||
});
|
||||
// RTL-aware: wipe in from the right for Persian (mirror the direction in `en`)
|
||||
<AbsoluteFill style={{ clipPath: `inset(0 0 0 ${100 - p}%)` }}><SceneB/></AbsoluteFill>
|
||||
```
|
||||
Soften the edge with a leading gradient strip (a thin `accent` bar riding `p`) for a "luminance wipe".
|
||||
|
||||
### Clip-path circle / shape reveal
|
||||
```tsx
|
||||
const r = interpolate(spring({ frame: f, fps, config: { mass: 0.6, damping: 14 } }),
|
||||
[0, 1], [0, 150]); // 150% covers corners
|
||||
<AbsoluteFill style={{ clipPath: `circle(${r}% at 50% 50%)` }}><SceneB/></AbsoluteFill>
|
||||
```
|
||||
For a brand shape transition: render a full-frame circle filled with `colorSchema.accent` scaling up over Scene A, then swap to Scene B *masked by the same circle* — the brand color carries the cut.
|
||||
|
||||
### Zoom / whip-blur
|
||||
```tsx
|
||||
// outgoing tail
|
||||
const out = interpolate(f, [0, T], [1, 1.4], { extrapolateRight: "clamp" });
|
||||
const blurOut = interpolate(f, [0, T], [0, vmin(24)], { extrapolateRight: "clamp" });
|
||||
<AbsoluteFill style={{ transform: `scale(${out})`, filter: `blur(${blurOut}px)` }}><SceneA/></AbsoluteFill>
|
||||
// incoming head (local frame): scale 1.25→1, blur 24→0 — peak blur of BOTH meets on the cut
|
||||
```
|
||||
`vmin` comes from `useLayout()` (`lib/aspect.ts`) so the blur reads the same in all three aspects.
|
||||
|
||||
### Camera push (3D, @remotion/three)
|
||||
```tsx
|
||||
// inside <ThreeCanvas> — drive the camera off frame, NOT useFrame
|
||||
const z = interpolate(spring({ frame: f, fps, config: { mass: 2.5, damping: 26 } }),
|
||||
[0, 1], [7, 3.2]); // dolly in, heavy = weight
|
||||
useThree(({ camera }) => { camera.position.z = z; camera.updateProjectionMatrix(); });
|
||||
```
|
||||
Use `StudioEnv/StudioLights/StudioFloor/StudioEffects` from `lib/three-kit.tsx`; let DOF + bloom + vignette sell the move. Camera moves use `ease-in-out`/heavy spring; never linear (linear is only for continuous orbit/rotation).
|
||||
|
||||
## Match-cut & seamless choreography (the studio-grade joins)
|
||||
The eye forgives a cut if **something continues across it**. Carry one of:
|
||||
- **Position+scale** — a circle bottom-left in Scene A is a circle bottom-left, same size, in Scene B. Cut while identical. (Classic match-cut.)
|
||||
- **Color** — Scene A ends on a full-frame `accent` wash; Scene B opens from that wash. Use `mixHex`/`hexToRgba` (`lib/anim.ts`) so it's palette-driven.
|
||||
- **Motion vector** — text exits stage-left at speed `v`; the next element enters from stage-right at the same `v`. Momentum reads as continuity.
|
||||
- **A mask** — the shape that wiped scene A out is the shape scene B wipes in with.
|
||||
|
||||
For a full template: write a **beat list first** (logo in → tagline → 3 features cascade → CTA → out), assign one transition per join, and make adjacent joins *differ* (don't dissolve every cut) but **rhyme** (reuse the brand shape/color). Vary cut length and build to the hero moment — pacing is a transition too.
|
||||
|
||||
## Timing & easing (the difference between pro and slideshow)
|
||||
- **Window:** scene transition **12–20f**; whip/cut feels best at the short end, dissolve/camera at the long end.
|
||||
- **Entrances ease-out** (`Easing.out(Easing.quint)` / `Easing.bezier(0.16,1,0.3,1)`); **exits ease-in and always SHARPER than the entrance** — scenes leave faster than they arrive.
|
||||
- **A→B on-screen / camera = ease-in-out.** **Linear ONLY** for continuous rotation/marquee.
|
||||
- Snap transition `from` frames to the **music beats** (`../remotion-music-picker/SKILL.md`) so cuts land on downbeats.
|
||||
- Per-aspect: tighten the window on `isWide` (reads faster), loosen on `isTall`. Use the proposed `pick(wide,square,tall)` helper on `Layout` when it lands; until then branch on `isWide/isSquare/isTall`.
|
||||
|
||||
## Reusable transition components
|
||||
Build these once in `lib/` and reuse across templates — each takes an `enter`/`exit` phase and a window:
|
||||
|
||||
```tsx
|
||||
// CrossFade.tsx — wrap any scene; computes its own in/out from frame + duration
|
||||
export const Dissolve: React.FC<{ children: React.ReactNode; win: number }> = ({ children, win }) => {
|
||||
const f = useCurrentFrame();
|
||||
const { durationInFrames } = useVideoConfig(); // length of THIS Sequence
|
||||
const o = Math.min(
|
||||
interpolate(f, [0, win], [0, 1], { extrapolateRight: "clamp" }),
|
||||
interpolate(f, [durationInFrames - win, durationInFrames], [1, 0], { extrapolateLeft: "clamp" }),
|
||||
);
|
||||
return <AbsoluteFill style={{ opacity: o }}>{children}</AbsoluteFill>;
|
||||
};
|
||||
```
|
||||
Make sibling wrappers `Wipe`, `CircleReveal`, `WhipZoom`, `ShapeWipe` with the same `(children, win, dir)` contract so a template can swap transitions by changing one wrapper. Keep the SFX hook in mind: a whoosh 2–3f before the cut + an impact ON it (`../remotion-sound-effects/SKILL.md`).
|
||||
|
||||
## Pre-ship transition checklist
|
||||
- [ ] No back-to-back `<Sequence>`s where a join should be smooth — scenes **overlap** the window.
|
||||
- [ ] Every join has a chosen transition with an *intent* (energy/calm/brand), not a default fade everywhere.
|
||||
- [ ] At least one join **carries** position, color, motion, or a mask across the cut (not all isolated fades).
|
||||
- [ ] All `interpolate` have `clamp` on both ends (the #1 drift bug).
|
||||
- [ ] Exits are sharper than entrances; nothing linear except continuous motion.
|
||||
- [ ] Cuts snapped to beats; whoosh-in + impact-on-cut wired.
|
||||
- [ ] Verified in 16:9 / 1:1 / 9:16 — wipe direction & blur amount read the same (`vmin`, not px); Persian RTL wipes from the right.
|
||||
- [ ] Colors via `colorSchema` (`mixHex`/`hexToRgba`), never hardcoded; deterministic (re-render twice → identical).
|
||||
|
||||
Related: `../remotion-template-composition/SKILL.md`, `../remotion-aspect-ratios/SKILL.md`, `../remotion-design-styles/SKILL.md`, `../remotion-sound-effects/SKILL.md`, `../remotion-music-picker/SKILL.md`, `../remotion-character-design/SKILL.md`, `../remotion-svg-colors/SKILL.md`, `../persian-fonts/SKILL.md`, `../flatrender-template-seo/SKILL.md`.
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
name: video-hooks
|
||||
description: How to design the scroll-stopping first 1-3 seconds of a FlatRender Remotion template — hook archetypes, pattern interrupts, on-screen text hooks, curiosity gaps, and platform-specific (Instagram/TikTok/YouTube) hook norms — and bake them into the template's opening beats. Use whenever building or reviewing a template's first frames, the cover/first frame, the caption hook layer, or retention pacing of the open.
|
||||
---
|
||||
|
||||
# The hook (first 1-3 seconds — where templates are won or lost)
|
||||
|
||||
On a 9:16 feed the viewer decides **stay or swipe in 2-3 seconds** (TikTok's "3-second rule"; IG rewards 3-sec view rate). YouTube Shorts has **no runway** — open on the most compelling moment. So a FlatRender template doesn't get a polite logo intro: the **first frame is the cover/thumbnail and the hook**, and the first ~45-90 frames (@30fps) must arrest the eye. Everything here is a *pure function of `useCurrentFrame()`* — no `Math.random`/`Date.now`/`useFrame`; use `rand(seed)` from `lib/anim.ts`. Read `../remotion-aspect-ratios/SKILL.md` before positioning a single hook element.
|
||||
|
||||
## The frame budget for the open (30fps; use `sec(s)=Math.round(s*fps)`)
|
||||
| Beat | Frames | Job |
|
||||
|---|--:|---|
|
||||
| **f0 — cover** | 1 | Must already read as a finished, intriguing thumbnail. No black/empty frame 0. |
|
||||
| **Pattern interrupt** | 0-12 | One bold motion/sound jolt that breaks the scroll rhythm. |
|
||||
| **Hook text lands** | 6-30 | The promise/question/claim, big, high-contrast, lower-middle third. |
|
||||
| **Curiosity hold** | 30-75 | Pose an open loop the rest of the video closes. Don't resolve yet. |
|
||||
| **Hero handoff** | 60-90 | Flow into logo/headline (`../remotion-template-composition/SKILL.md`). |
|
||||
|
||||
Front-load the payoff — **no preamble, no slow brand sting first**. Brand comes *after* the hook earns the watch.
|
||||
|
||||
## Hook archetypes (Persian-first copy; pick ONE per template)
|
||||
| Archetype | Persian opener pattern | Best for | Motion signature |
|
||||
|---|---|---|---|
|
||||
| **Curiosity gap** | «اینو تا آخر ببین…» / «هیچکس اینو بهت نگفته» | tips, reveals, teasers | text snaps in, then a held pause (open loop) |
|
||||
| **Bold claim / contrarian** | «این روش رو فراموش کن» / «۹۰٪ اشتباه انجامش میدن» | how-to, product | hard cut + overshoot back-bezier |
|
||||
| **Question** | «دنبال … میگردی؟» | services, lead-gen | rise + tilt, then steady |
|
||||
| **Negativity / warning** | «این اشتباه رو نکن» | finance, health, safety | red accent flash + shake |
|
||||
| **Number / list** | «۳ دلیل که…» / «۵ نکته…» | listicles, carousels | counter ticks up, items pre-stack off-screen |
|
||||
| **Result-first** | show the after/price-drop/win immediately | promo, sale, before-after | hero appears f0, *then* explains |
|
||||
| **Direct address** | «تو که … هستی، اینو لازم داری» | niche/targeted | type fills 70-90% of frame |
|
||||
|
||||
Use Persian numerals (`۰-۹`) — never Latin digits — in hook copy and counters; `fa` is source of truth, `en` mirrors 1:1.
|
||||
|
||||
## Pattern interrupts (the scroll-breaking jolt in f0-12)
|
||||
The feed has a rhythm; a hook *breaks* it. Stack 1-2 of these, never all:
|
||||
- **Motion jolt** — whip-in with overshoot: `Easing.bezier(0.34,1.56,0.64,1)`, or a low-damping `spring({mass:0.6,damping:9,stiffness:200})`. Add motion blur on the fast frames (its absence is an amateur tell).
|
||||
- **Hard cut + flash** — a 1-2 frame white/accent wash: `opacity = frame < 2 ? 1 : 0` over a `hexToRgba(accentColor, …)` fill. Pair with a thump SFX (`../remotion-sound-effects/SKILL.md`).
|
||||
- **Scale punch** — start at `scale` 1.6→1.0 (clamp) so the subject "slams" toward camera.
|
||||
- **Color shock** — open on a dopamine accent (electric blue/coral/acid) on a neutral base; pull it from `accentColor` so the studio recolors it.
|
||||
- **Silence-then-hit** — a held silent f0-8, then riser+downbeat on the hook (`../remotion-music-picker/SKILL.md` BPM map). The pause *is* the interrupt.
|
||||
|
||||
```tsx
|
||||
// Pattern-interrupt whip-in for the hook line (deterministic, clamped)
|
||||
const f = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const intro = spring({ frame: f, fps, config: { mass: 0.6, damping: 9, stiffness: 200 } });
|
||||
const y = interpolate(intro, [0, 1], [L.vmin(60), 0]); // rises into place
|
||||
const flash = interpolate(f, [0, 2, 5], [1, 0.5, 0], { extrapolateRight: "clamp" });
|
||||
```
|
||||
|
||||
## On-screen text hooks (the highest-ROI layer)
|
||||
The hook text is a **first-class editable field**, not decoration — it is the captions/cover layer the whole brief calls the biggest cross-platform win.
|
||||
- **Placement:** lower-middle third, inside the *tightest* safe zone (Story/TikTok) so it's safe everywhere. For 1080×1920 keep hook Y ≈ `height*0.18-0.55`; clear top ~108 and bottom ~320 (UI chrome).
|
||||
- **Legibility:** high-contrast white or acid-yellow fill + **black outline** (`WebkitTextStroke` or layered `textShadow`), never thin grey on busy bg. Add a scrim if over media.
|
||||
- **Oversized & clipped:** the hook word can fill 60-90% of frame (`fitText` from `@remotion/layout-utils`); clip with `overflow:hidden`. Strongest on 9:16.
|
||||
- **Kinetic / word-by-word:** beats full sentences on TikTok. Split to spans, `delay = i*stagger`, drive each with `spring({frame: f - delay, fps})`. Stagger looser on tall, tighter on wide via `pick`.
|
||||
- **Variable weight pop:** Vazirmatn ships a variable build — animate `fontVariationSettings: "'wght' " + interpolate(f,[0,12],[300,900])` for a Persian hero hook.
|
||||
|
||||
```tsx
|
||||
// Word-by-word Persian hook, RTL, outlined, beat-staggered
|
||||
const words = hookText.split(" ");
|
||||
const stagger = L.pick(2, 3, 4); // wide reads faster → tighter
|
||||
return (
|
||||
<div style={{ direction: "rtl", fontFamily: FONT, display: "flex",
|
||||
gap: L.vmin(8), justifyContent: "center", flexWrap: "wrap",
|
||||
maxWidth: L.width * 0.86 }}>
|
||||
{words.map((w, i) => {
|
||||
const s = spring({ frame: f - i * stagger, fps, config: { damping: 12 } });
|
||||
return (
|
||||
<span key={i} style={{
|
||||
fontSize: L.pick(L.vmin(96), L.vmin(84), L.vmin(72)), fontWeight: 900,
|
||||
color: textColor, WebkitTextStroke: `${L.vmin(6)}px ${BRAND.ink}`,
|
||||
paintOrder: "stroke", transform: `translateY(${(1 - s) * L.vmin(40)}px)`,
|
||||
opacity: s,
|
||||
}}>{w}</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## Curiosity & retention pacing across the open
|
||||
- **Open a loop, close it later** — the hook *promises*, the hero *pays off*. Never resolve the question in the first 2s or there's no reason to stay.
|
||||
- **One idea per beat** — staging: dim/blur everything but the hook; let it own the eye before the next element competes.
|
||||
- **Hold for the read** — a hook line needs ~0.6-0.8s minimum on screen before motion competes. Robotic = linear; floaty = held too long. Cut frames before adding.
|
||||
- **Tiny life in the hold** — a `sin(f/fps)` breathe/shimmer so the held hook isn't a frozen frame.
|
||||
- **Grain + texture** from f0 — even the cover frame should have animated grain (offset `background-position` per frame); flat-saturated = reads as AI/template.
|
||||
|
||||
## Platform hook norms → template implication
|
||||
| Platform | Hook window | Norm | Template move |
|
||||
|---|---|---|---|
|
||||
| **TikTok** | 3s | curiosity-gap / bold-claim; word-by-word captions | calm neutral grain + warm-earth variant; word-by-word hook as editable layer |
|
||||
| **IG Reels** | 2-3s | cleaner, less-cluttered than TikTok | refined kinetic type, glass lower-third, mesh-gradient bg, one clean interrupt |
|
||||
| **YT Shorts** | f0 | no runway — open on the peak | result-first / hero-at-f0; cinematic graded look |
|
||||
| **YT long-form intro** | 5-15s | cold-open hook, brand sting <3s | state payoff first, brand second |
|
||||
| **IG Story** | full-bleed | heavy UI chrome | keep hook clear of top ~250 / bottom ~250 |
|
||||
| **All three** | 1-2s | first frame = hook = cover; authenticity > gloss | hook prop in every aspect, re-flowed not letterboxed |
|
||||
|
||||
## Tie the hook into template structure
|
||||
- Make the hook copy a Zod prop (e.g. `hookText: z.string()`) + a seeded `Text` element whose `key` matches — same binding model as `../remotion-template-composition/SKILL.md`. Ship strong Persian default copy so it reads finished pre-edit.
|
||||
- Hook color = `accentColor`/`textColor` from `colorSchema`; pass user hex through a grade so a garish value doesn't break the open (`../remotion-svg-colors/SKILL.md`).
|
||||
- The hook is a `<Sequence from={0} durationInFrames={sec(2.5)}>`; the hero sequence overlaps its tail so the handoff is a flow, not a cut.
|
||||
- 3D hooks: keep the interrupt object filling the frame per aspect (tune `fov`/`position.z`), drive entrance from `useCurrentFrame()` with high `mass` for weight; let `StudioEffects` (bloom/DOF/vignette) finish it.
|
||||
|
||||
## Hook checklist (gate the open)
|
||||
- [ ] Frame 0 reads as a finished, intriguing cover — no black/empty/half-loaded frame.
|
||||
- [ ] A single clear pattern interrupt in f0-12 (motion / flash / scale / color / silence-then-hit) with SFX.
|
||||
- [ ] ONE hook archetype; Persian-first copy with Persian numerals; `en` mirror present.
|
||||
- [ ] Hook text is an editable prop, high-contrast + outlined, in the tightest safe zone, no clipping with long Persian strings.
|
||||
- [ ] An open loop is posed and NOT resolved in the first 2s; payoff lands at the hero.
|
||||
- [ ] Eased/overshoot motion (no linear), held for the read, with a tiny live shimmer; animated grain from f0.
|
||||
- [ ] Verified the open in all three aspects (`pick`-tuned), recolors cleanly, re-renders identical (deterministic).
|
||||
|
||||
Related: `../remotion-template-composition/SKILL.md`, `../remotion-aspect-ratios/SKILL.md`, `../remotion-design-styles/SKILL.md`, `../remotion-sound-effects/SKILL.md`, `../remotion-music-picker/SKILL.md`, `../remotion-svg-colors/SKILL.md`, `../persian-fonts/SKILL.md`, `../remotion-template-catalog/SKILL.md`, `../flatrender-template-seo/SKILL.md`.
|
||||
@@ -1,7 +1,8 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
# dependencies (root + any nested, e.g. services/remotion/node_modules)
|
||||
/node_modules
|
||||
node_modules/
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
@@ -55,3 +56,12 @@ node-agent.exe
|
||||
# node-agent local build + secrets
|
||||
services/node-agent/dist/
|
||||
agent.env
|
||||
|
||||
# remotion render outputs (regenerated; thumbnails/previews live in public/template-media)
|
||||
services/remotion/out/
|
||||
|
||||
# local scratch / agent work
|
||||
/-w
|
||||
/.agent-work/
|
||||
dist/
|
||||
services/remotion/player-dist/
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# ── Stage 1: install dependencies ────────────────────────────────────────────
|
||||
FROM mirror.soroushasadi.com/node:20-alpine AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
FROM mirror.soroushasadi.com/node:20-slim AS deps
|
||||
# Debian (glibc) base on purpose: Alpine (musl) needs `libc6-compat` for next-swc,
|
||||
# which is only on the geo-blocked Alpine CDN (unreachable from the CI server).
|
||||
# Debian ships glibc, so next-swc's gnu binary loads natively — no apk, no CDN.
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
@@ -18,7 +20,7 @@ RUN for i in 1 2 3 4 5; do \
|
||||
echo "npm ci failed after 5 attempts" && exit 1
|
||||
|
||||
# ── Stage 2: build ───────────────────────────────────────────────────────────
|
||||
FROM mirror.soroushasadi.com/node:20-alpine AS builder
|
||||
FROM mirror.soroushasadi.com/node:20-slim AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
@@ -51,15 +53,15 @@ ENV NODE_ENV=production
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 3: production runner ────────────────────────────────────────────────
|
||||
FROM mirror.soroushasadi.com/node:20-alpine AS runner
|
||||
FROM mirror.soroushasadi.com/node:20-slim AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Create a non-root user (security best practice)
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 nextjs
|
||||
# Create a non-root user (security best practice). Debian uses groupadd/useradd.
|
||||
RUN groupadd --system --gid 1001 nodejs \
|
||||
&& useradd --system --uid 1001 --gid nodejs nextjs
|
||||
|
||||
# Copy public assets
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
-- =====================================================================
|
||||
-- PAYMENT BROKER SCHEMA — generic multi-client ZarinPal gateway
|
||||
-- Served on pay.flatrender.ir (the single ZarinPal-verified callback domain).
|
||||
-- Other sites (meezi.ir, bargevasat.ir, FlatRender) register as client_apps
|
||||
-- and route payments through this broker.
|
||||
--
|
||||
-- NOTE: migrations auto-run only on FIRST volume creation. On an existing
|
||||
-- DB volume, apply this manually:
|
||||
-- docker exec -i fr2-postgres psql -U postgres -d flatrender < 31_payment_broker.sql
|
||||
-- =====================================================================
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS payment;
|
||||
SET search_path TO payment, public;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- client_apps — each site that pays through the broker
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS payment.client_apps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID, -- optional link to identity.tenants
|
||||
name TEXT NOT NULL, -- "meezi.ir", "FlatRender"
|
||||
slug TEXT NOT NULL UNIQUE, -- "meezi"
|
||||
api_key TEXT NOT NULL UNIQUE, -- public id (pk_...)
|
||||
secret TEXT NOT NULL, -- shared HMAC secret (sk_...) — signs in+out
|
||||
|
||||
-- ZarinPal: per-client override; NULL → broker default merchant/sandbox
|
||||
zarinpal_merchant_id TEXT,
|
||||
zarinpal_sandbox BOOLEAN,
|
||||
|
||||
allowed_return_origins TEXT[] NOT NULL DEFAULT '{}', -- e.g. {'https://meezi.ir'}; empty = permissive
|
||||
webhook_url TEXT, -- server-to-server result notification
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- transactions — one row per payment attempt
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS payment.transactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_app_id UUID NOT NULL REFERENCES payment.client_apps(id) ON DELETE RESTRICT,
|
||||
|
||||
status TEXT NOT NULL DEFAULT 'Created', -- Created|Pending|Paid|Failed|Cancelled|Expired
|
||||
gateway TEXT NOT NULL DEFAULT 'ZarinPal',
|
||||
|
||||
amount_rial BIGINT NOT NULL, -- canonical Rial
|
||||
currency TEXT NOT NULL DEFAULT 'IRR',
|
||||
description TEXT,
|
||||
|
||||
client_ref TEXT, -- the client's own order id
|
||||
return_url TEXT NOT NULL, -- where the user is sent back
|
||||
metadata JSONB, -- echoed back to the client
|
||||
payer_mobile TEXT,
|
||||
payer_email TEXT,
|
||||
|
||||
authority TEXT, -- ZarinPal authority token
|
||||
ref_id TEXT, -- ZarinPal ref_id (receipt)
|
||||
card_pan TEXT,
|
||||
fee_rial BIGINT,
|
||||
gateway_response JSONB,
|
||||
failure_reason TEXT,
|
||||
|
||||
paid_at TIMESTAMPTZ,
|
||||
failed_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pay_txn_client ON payment.transactions(client_app_id, created_at DESC);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_pay_txn_authority ON payment.transactions(authority) WHERE authority IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_pay_txn_clientref ON payment.transactions(client_app_id, client_ref) WHERE client_ref IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_pay_txn_status ON payment.transactions(status);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- webhook_deliveries — outbound signed notifications with retry
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS payment.webhook_deliveries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
transaction_id UUID NOT NULL REFERENCES payment.transactions(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
signature TEXT NOT NULL,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
delivered BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
last_status INT,
|
||||
last_error TEXT,
|
||||
next_attempt_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pay_wh_pending ON payment.webhook_deliveries(delivered, next_attempt_at) WHERE delivered = FALSE;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- updated_at triggers (helper tg_set_updated_at() created in 00_setup.sql)
|
||||
-- ---------------------------------------------------------------------
|
||||
DROP TRIGGER IF EXISTS tg_pay_client_apps_updated ON payment.client_apps;
|
||||
CREATE TRIGGER tg_pay_client_apps_updated BEFORE UPDATE ON payment.client_apps
|
||||
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS tg_pay_transactions_updated ON payment.transactions;
|
||||
CREATE TRIGGER tg_pay_transactions_updated BEFORE UPDATE ON payment.transactions
|
||||
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS tg_pay_webhook_updated ON payment.webhook_deliveries;
|
||||
CREATE TRIGGER tg_pay_webhook_updated BEFORE UPDATE ON payment.webhook_deliveries
|
||||
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
@@ -0,0 +1,16 @@
|
||||
-- 32_content_render_engine.sql
|
||||
-- Two render engines per template: After Effects (.aep, rendered by a node-agent)
|
||||
-- and Remotion (code-based React composition). render_engine selects which; for
|
||||
-- Remotion templates render_remotion_comp holds the composition id to render
|
||||
-- (the .aep / render_aep_comp columns stay null for those).
|
||||
--
|
||||
-- Apply manually on the live DB (migrations are not auto-run):
|
||||
-- docker exec -i <postgres> psql -U postgres -d flatrender < 32_content_render_engine.sql
|
||||
|
||||
ALTER TABLE content.projects
|
||||
ADD COLUMN IF NOT EXISTS render_engine TEXT NOT NULL DEFAULT 'AfterEffects';
|
||||
|
||||
ALTER TABLE content.projects
|
||||
ADD COLUMN IF NOT EXISTS render_remotion_comp TEXT;
|
||||
|
||||
-- Existing templates are all After Effects; the default already covers them.
|
||||
@@ -0,0 +1,35 @@
|
||||
-- =====================================================================
|
||||
-- PAYMENT BROKER — global settings (admin-editable ZarinPal config) + is_test
|
||||
-- Lets the merchant id / sandbox flag / amount unit be set from the admin
|
||||
-- panel instead of env + redeploy. A client_app may still override per-site.
|
||||
-- Also adds transactions.is_test so admin smoke-test payments never fire a
|
||||
-- client's production webhook.
|
||||
--
|
||||
-- Apply manually on an existing volume (runs after 31_payment_broker.sql):
|
||||
-- docker exec -i fr2-postgres psql -U flatrender -d flatrender < 33_payment_settings.sql
|
||||
-- =====================================================================
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS payment;
|
||||
SET search_path TO payment, public;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payment.settings (
|
||||
id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1), -- singleton row
|
||||
zarinpal_merchant_id TEXT NOT NULL DEFAULT '',
|
||||
zarinpal_sandbox BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
zarinpal_amount_unit TEXT NOT NULL DEFAULT 'rial', -- 'rial' | 'toman'
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- NOTE: the singleton row is intentionally NOT pre-seeded. Until an admin saves
|
||||
-- settings, GetSettings returns no-row and the broker falls back to ENV
|
||||
-- (ZARINPAL_MERCHANT_ID / ZARINPAL_SANDBOX / ZARINPAL_AMOUNT_UNIT). Seeding a
|
||||
-- default row here would force sandbox=TRUE and silently override a production
|
||||
-- env (ZARINPAL_SANDBOX=false), routing real payments to the sandbox gateway.
|
||||
|
||||
DROP TRIGGER IF EXISTS tg_pay_settings_updated ON payment.settings;
|
||||
CREATE TRIGGER tg_pay_settings_updated BEFORE UPDATE ON payment.settings
|
||||
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- Mark admin smoke-test transactions so the webhook dispatcher never notifies a
|
||||
-- real client (which could otherwise credit coins/activate a plan from a test).
|
||||
ALTER TABLE payment.transactions ADD COLUMN IF NOT EXISTS is_test BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -22,6 +22,7 @@ EDGE_BIND=0.0.0.0
|
||||
# nginx-facing host ports (must be free on 171.22.25.73 — :3000 is Gitea, avoid it).
|
||||
FRONTEND_PORT=1600
|
||||
GATEWAY_PORT=1605
|
||||
PAY_PORT=1607
|
||||
MINIO_PORT=1610
|
||||
MINIO_CONSOLE_PORT=1611
|
||||
|
||||
@@ -58,10 +59,27 @@ MINIO_HOST_USE_SSL=true
|
||||
RENDER_DEV_WORKER=false
|
||||
RENDER_DEV_SNAPSHOTS=false
|
||||
|
||||
# ── Payment broker (pay.flatrender.ir) ───────────────────────────────────────
|
||||
# Standalone ZarinPal gateway shared by FlatRender + meezi.ir + bargevasat.ir.
|
||||
# ZARINPAL_MERCHANT_ID below is the SHARED merchant (verified domain = pay.flatrender.ir).
|
||||
PAY_PUBLIC_URL=https://pay.flatrender.ir
|
||||
# Unit ZarinPal expects in `amount`: "rial" (official v4) or "toman".
|
||||
# ⚠️ Your identity service historically sends Toman — confirm with one sandbox
|
||||
# payment which unit YOUR merchant settles in, then set this to match.
|
||||
ZARINPAL_AMOUNT_UNIT=rial
|
||||
|
||||
# FlatRender's OWN plan purchases through the broker. Create a "flatrender" client
|
||||
# app in Admin → پرداخت (allowed origin https://api.flatrender.ir), then paste its
|
||||
# key+secret here. Empty ⇒ identity calls ZarinPal directly (legacy).
|
||||
FLATPAY_FLATRENDER_API_KEY=
|
||||
FLATPAY_FLATRENDER_SECRET=
|
||||
FLATPAY_RETURN_BASE=https://api.flatrender.ir
|
||||
|
||||
# ── Payments (fill the providers you use; leave others blank) ────────────────
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_PUBLISHABLE_KEY=
|
||||
# Shared ZarinPal merchant — used by BOTH the identity service and the pay broker.
|
||||
ZARINPAL_MERCHANT_ID=
|
||||
ZARINPAL_CALLBACK_URL=https://api.flatrender.ir/v1/payments/callback/zarinpal
|
||||
ZARINPAL_SANDBOX=false
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# FlatRender Pay — ZarinPal Broker Integration Guide
|
||||
|
||||
`pay.flatrender.ir` is a **standalone, multi-client ZarinPal gateway**. Any site
|
||||
(FlatRender, meezi.ir, bargevasat.ir, …) routes payments through it, because
|
||||
ZarinPal only accepts callbacks on the single verified domain `pay.flatrender.ir`.
|
||||
|
||||
```
|
||||
your site ──POST /v1/pay/request──► pay.flatrender.ir ──► ZarinPal request.json
|
||||
▲ (api key + HMAC) │ │
|
||||
│ ▼ authority
|
||||
└──◄ 302 return_url (signed) ◄── /callback/zarinpal ◄── user pays on ZarinPal
|
||||
└──◄ POST webhook_url (signed) ◄────────┘ (verify.json → ref_id)
|
||||
```
|
||||
|
||||
You get a **client app** from the FlatRender admin (Admin → پرداخت → اپلیکیشنها):
|
||||
|
||||
- `api_key` — public id, sent as `X-Api-Key` (e.g. `pk_…`)
|
||||
- `secret` — shown **once**; signs your requests AND verifies broker callbacks (`sk_…`)
|
||||
- `webhook_url` — optional server-to-server result endpoint
|
||||
- `allowed_return_origins` — the origins your `return_url` may use (empty = any)
|
||||
|
||||
---
|
||||
|
||||
## 1. Create a payment
|
||||
|
||||
`POST https://pay.flatrender.ir/v1/pay/request`
|
||||
|
||||
Headers:
|
||||
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| `Content-Type` | `application/json` |
|
||||
| `X-Api-Key` | your `api_key` |
|
||||
| `X-Signature` | `hex( HMAC_SHA256(secret, <raw request body bytes>) )` |
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"amount": 50000,
|
||||
"currency": "IRT",
|
||||
"description": "خرید اشتراک طلایی",
|
||||
"client_ref": "order-1234",
|
||||
"return_url": "https://meezi.ir/payment/return",
|
||||
"mobile": "09120000000",
|
||||
"email": "user@example.com",
|
||||
"metadata": { "user_id": "42", "plan": "gold" }
|
||||
}
|
||||
```
|
||||
|
||||
- `amount` — integer. `currency` is `"IRR"` (Rial, default) or `"IRT"` (Toman). The
|
||||
broker stores the canonical Rial value and converts for ZarinPal.
|
||||
- `client_ref` — your own order id (echoed back everywhere).
|
||||
- `return_url` — where the user's browser is sent after payment. Must match an
|
||||
`allowed_return_origins` entry if you configured any.
|
||||
- `metadata` — arbitrary JSON, echoed back in the redirect signature scope + webhook.
|
||||
|
||||
Response `200`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "9b2c…", // broker transaction id
|
||||
"status": "Pending",
|
||||
"payment_url": "https://www.zarinpal.com/pg/StartPay/A000…",
|
||||
"authority": "A000…",
|
||||
"amount_rial": 500000
|
||||
}
|
||||
```
|
||||
|
||||
**Redirect the user's browser to `payment_url`.**
|
||||
|
||||
---
|
||||
|
||||
## 2. The user comes back (browser redirect)
|
||||
|
||||
After ZarinPal, the broker verifies the payment and `302`-redirects the browser to
|
||||
your `return_url` with a **signed** result appended:
|
||||
|
||||
```
|
||||
https://meezi.ir/payment/return?status=Paid&id=9b2c…&ref_id=123456789&sign=<hex>
|
||||
```
|
||||
|
||||
- `status` — `Paid` | `Failed` | `Cancelled`
|
||||
- `ref_id` — ZarinPal receipt (only when paid)
|
||||
- `sign` — `hex( HMAC_SHA256(secret, "{id}.{status}.{ref_id}.{amount_rial}") )`
|
||||
|
||||
⚠️ The redirect is **not** proof of payment on its own (a user can craft a URL).
|
||||
Treat it as a UX hint, then **confirm with the webhook (§3) or the inquiry API (§4)**.
|
||||
|
||||
To verify the redirect signature you need `amount_rial`; fetch it via the inquiry
|
||||
API, or just rely on the webhook / inquiry as the source of truth.
|
||||
|
||||
---
|
||||
|
||||
## 3. Webhook (recommended — the source of truth)
|
||||
|
||||
If `webhook_url` is set, the broker POSTs a **signed** JSON body to it when a payment
|
||||
finishes (with retry + exponential backoff up to ~1h):
|
||||
|
||||
Headers: `X-FlatPay-Signature: <hex>`, `X-FlatPay-Event: payment`
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "payment.paid",
|
||||
"id": "9b2c…",
|
||||
"status": "Paid",
|
||||
"amount_rial": 500000,
|
||||
"currency": "IRR",
|
||||
"client_ref": "order-1234",
|
||||
"ref_id": "123456789",
|
||||
"authority": "A000…",
|
||||
"card_pan": "6037********1234",
|
||||
"metadata": { "user_id": "42", "plan": "gold" },
|
||||
"paid_at": "2026-06-15T14:00:00Z",
|
||||
"ts": 1750000000
|
||||
}
|
||||
```
|
||||
|
||||
Verify: `HMAC_SHA256(secret, <raw body bytes>) == X-FlatPay-Signature`. Respond `2xx`
|
||||
to acknowledge (anything else is retried). Make handling **idempotent** (keyed on
|
||||
`id` or `client_ref`) — duplicate deliveries are possible.
|
||||
|
||||
---
|
||||
|
||||
## 4. Inquiry (authoritative pull)
|
||||
|
||||
`POST https://pay.flatrender.ir/v1/pay/inquiry` (same `X-Api-Key` + `X-Signature` as §1)
|
||||
|
||||
```json
|
||||
{ "id": "9b2c…" }
|
||||
```
|
||||
|
||||
Returns the full transaction (status, `ref_id`, `amount_rial`, …). Use this from your
|
||||
`return_url` handler to confirm before granting the user anything.
|
||||
|
||||
---
|
||||
|
||||
## Reference signature recipe
|
||||
|
||||
```
|
||||
signature = hex( HMAC_SHA256(client_secret, message_bytes) )
|
||||
```
|
||||
|
||||
- **request / inquiry**: `message_bytes` = the exact raw JSON body you send.
|
||||
- **return redirect**: `message_bytes` = UTF-8 of `"{id}.{status}.{ref_id}.{amount_rial}"`.
|
||||
- **webhook**: `message_bytes` = the exact raw JSON body received.
|
||||
|
||||
See [`sdk/flatpay.js`](./sdk/flatpay.js) for a drop-in Node client + Express webhook
|
||||
verifier.
|
||||
@@ -14,17 +14,32 @@ Stack: gateway · identity · content · studio (.NET/Go) · file · render · n
|
||||
mirror-nginx (:443, /etc/ssl/flatrender)
|
||||
flatrender.ir → 171.22.25.73:1600 (fr2-frontend)
|
||||
api.flatrender.ir → 171.22.25.73:1605 (fr2-gateway)
|
||||
pay.flatrender.ir → 171.22.25.73:1607 (fr2-payment, ZarinPal broker)
|
||||
storage.flatrender.ir → 171.22.25.73:1610 (fr2-minio)
|
||||
```
|
||||
|
||||
The **payment broker** (`fr2-payment`, `pay.flatrender.ir`) is a standalone generic
|
||||
ZarinPal gateway shared by FlatRender + meezi.ir + bargevasat.ir — ZarinPal only
|
||||
accepts callbacks on that one verified domain. It does NOT sit behind the API
|
||||
gateway (clients authenticate with an API key + HMAC). See
|
||||
[`PAYMENTS.md`](./PAYMENTS.md) for the integration contract. The `payment` schema
|
||||
is migrations `31_payment_broker.sql` (tables) + `33_payment_settings.sql`
|
||||
(admin-editable ZarinPal config + `transactions.is_test`) — apply BOTH, in order,
|
||||
on an existing DB volume (migrations only auto-run on first volume creation):
|
||||
```
|
||||
docker exec -i fr2-postgres psql -U flatrender -d flatrender < backend/db/migrations/31_payment_broker.sql
|
||||
docker exec -i fr2-postgres psql -U flatrender -d flatrender < backend/db/migrations/33_payment_settings.sql
|
||||
```
|
||||
The broker image expects `is_test` (migration 33) — deploy it together with both migrations.
|
||||
|
||||
## One-time setup (do these BEFORE the first `git push gitea master`)
|
||||
|
||||
1. **DNS** — this box sits BEHIND NAT: its interface IP is `171.22.25.73` (private),
|
||||
public NAT IPs are `31.171.101.127/.211`, and inbound 443 normally arrives via the
|
||||
edge/CDN `185.239.1.100` (same entry your other sites use, e.g. `meezi.ir`). So a new
|
||||
domain must enter the SAME way the others do — either:
|
||||
- register `flatrender.ir` + `api` + `storage` + `www` in that edge/CDN (origin = this
|
||||
server) and point DNS there, **or**
|
||||
- register `flatrender.ir` + `api` + `pay` + `storage` + `www` in that edge/CDN (origin =
|
||||
this server) and point DNS there, **or**
|
||||
- bypass the CDN and point DNS straight at the server's public IP (like the hokm `api`
|
||||
subdomain does — "must bypass").
|
||||
Pointing DNS at a random/registrar IP shows that host's default page (e.g. a "not
|
||||
@@ -37,7 +52,7 @@ Stack: gateway · identity · content · studio (.NET/Go) · file · render · n
|
||||
cp <yourcert>/fullchain.pem /etc/ssl/soroushasadi/flatrender/
|
||||
cp <yourcert>/privateKey.pem /etc/ssl/soroushasadi/flatrender/
|
||||
```
|
||||
Cert must cover `flatrender.ir` + `api.` + `storage.` (wildcard `*.flatrender.ir` + apex, or SAN).
|
||||
Cert must cover `flatrender.ir` + `api.` + `pay.` + `storage.` (wildcard `*.flatrender.ir` + apex, or SAN).
|
||||
3. **mirror-nginx** — add the server blocks from [`mirror-nginx-flatrender.conf`](./mirror-nginx-flatrender.conf)
|
||||
to the proxy's `http{}` (the host file is `/root/mirror-server/nginx/nginx.conf`), then:
|
||||
`docker exec mirror-nginx nginx -t && docker exec mirror-nginx nginx -s reload`.
|
||||
@@ -63,10 +78,11 @@ blocks (step 3) and visit `https://flatrender.ir`.
|
||||
|
||||
## Host ports (must be free on 171.22.25.73)
|
||||
|
||||
`1600` frontend · `1605` gateway · `1610` MinIO · `1611` MinIO console. Postgres (5432)
|
||||
and render (5010) bind to `127.0.0.1` only. Avoid `:3000` (Gitea), `:8081-8083` (Nexus),
|
||||
`:1500/1505/1520` (bargevasat), `:3010/3101-3103/5080/5081` (meezi), `:3020`, `:2569`.
|
||||
Change them via `FRONTEND_PORT`/`GATEWAY_PORT`/`MINIO_PORT` in the secret if any collide.
|
||||
`1600` frontend · `1605` gateway · `1607` payment broker · `1610` MinIO · `1611` MinIO
|
||||
console. Postgres (5432) and render (5010) bind to `127.0.0.1` only. Avoid `:3000` (Gitea),
|
||||
`:8081-8083` (Nexus), `:1500/1505/1520` (bargevasat), `:3010/3101-3103/5080/5081` (meezi),
|
||||
`:3020`, `:2569`. Change them via `FRONTEND_PORT`/`GATEWAY_PORT`/`PAY_PORT`/`MINIO_PORT` in
|
||||
the secret if any collide.
|
||||
|
||||
## First-run notes
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name flatrender.ir www.flatrender.ir api.flatrender.ir storage.flatrender.ir;
|
||||
server_name flatrender.ir www.flatrender.ir api.flatrender.ir storage.flatrender.ir pay.flatrender.ir;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
@@ -87,3 +87,23 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# ── Payment broker (→ PAY_PORT) — pay.flatrender.ir ───────────────────────
|
||||
# The single ZarinPal-verified callback domain. ZarinPal redirects users to
|
||||
# pay.flatrender.ir/callback/zarinpal; the broker then bounces them to the
|
||||
# originating site's return_url. Must NOT be cached by any upstream CDN.
|
||||
server {
|
||||
listen 443 ssl; http2 on;
|
||||
server_name pay.flatrender.ir;
|
||||
client_max_body_size 5m;
|
||||
ssl_certificate /etc/ssl/soroushasadi/flatrender/fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/soroushasadi/flatrender/privateKey.pem;
|
||||
location / {
|
||||
proxy_pass http://171.22.25.73:1607;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// FlatRender Pay — drop-in Node client for the ZarinPal broker (pay.flatrender.ir).
|
||||
// Zero dependencies: Node 18+ (global fetch) + built-in crypto. CommonJS.
|
||||
//
|
||||
// const { FlatPay } = require("./flatpay");
|
||||
// const pay = new FlatPay({ apiKey: process.env.FLATPAY_KEY, secret: process.env.FLATPAY_SECRET });
|
||||
//
|
||||
// // 1. create + redirect
|
||||
// const r = await pay.createPayment({ amount: 50000, currency: "IRT",
|
||||
// description: "اشتراک", clientRef: order.id, returnUrl: "https://meezi.ir/pay/return",
|
||||
// metadata: { userId: user.id } });
|
||||
// res.redirect(r.payment_url);
|
||||
//
|
||||
// // 2. on your return_url handler — confirm authoritatively
|
||||
// const txn = await pay.inquire(req.query.id);
|
||||
// if (txn.status === "Paid") { /* grant */ }
|
||||
//
|
||||
// // 3. webhook (recommended) — Express:
|
||||
// app.post("/flatpay/webhook", express.raw({ type: "*/*" }), (req, res) => {
|
||||
// if (!pay.verifyWebhook(req.body, req.get("X-FlatPay-Signature"))) return res.sendStatus(401);
|
||||
// const ev = JSON.parse(req.body.toString("utf8"));
|
||||
// if (ev.status === "Paid") { /* idempotent grant keyed on ev.id / ev.client_ref */ }
|
||||
// res.sendStatus(200);
|
||||
// });
|
||||
|
||||
const crypto = require("crypto");
|
||||
|
||||
const DEFAULT_BASE = "https://pay.flatrender.ir";
|
||||
|
||||
function hmac(secret, message) {
|
||||
return crypto.createHmac("sha256", secret).update(message).digest("hex");
|
||||
}
|
||||
|
||||
function timingSafeEqualHex(a, b) {
|
||||
try {
|
||||
const ba = Buffer.from(a, "hex");
|
||||
const bb = Buffer.from(b, "hex");
|
||||
return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class FlatPay {
|
||||
constructor({ apiKey, secret, baseUrl = DEFAULT_BASE } = {}) {
|
||||
if (!apiKey || !secret) throw new Error("FlatPay: apiKey and secret are required");
|
||||
this.apiKey = apiKey;
|
||||
this.secret = secret;
|
||||
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
async _signedPost(path, payload) {
|
||||
const body = JSON.stringify(payload);
|
||||
const res = await fetch(this.baseUrl + path, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": this.apiKey,
|
||||
"X-Signature": hmac(this.secret, body),
|
||||
},
|
||||
body,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const err = new Error(data.message || `FlatPay ${path} failed (${res.status})`);
|
||||
err.code = data.code;
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Create a payment. Returns { id, status, payment_url, authority, amount_rial }. */
|
||||
createPayment({ amount, currency = "IRR", description, clientRef, returnUrl, mobile, email, metadata }) {
|
||||
if (!returnUrl) throw new Error("FlatPay: returnUrl is required");
|
||||
return this._signedPost("/v1/pay/request", {
|
||||
amount,
|
||||
currency,
|
||||
description,
|
||||
client_ref: clientRef,
|
||||
return_url: returnUrl,
|
||||
mobile,
|
||||
email,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
|
||||
/** Authoritative server-side status check. Returns the full transaction. */
|
||||
inquire(id) {
|
||||
return this._signedPost("/v1/pay/inquiry", { id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the signed return-redirect query.
|
||||
* Pass the query params { id, status, ref_id, sign } AND the amount_rial you got
|
||||
* from createPayment/inquire (the redirect itself doesn't carry the amount).
|
||||
*/
|
||||
verifyRedirect({ id, status, ref_id = "", sign }, amountRial) {
|
||||
const message = `${id}.${status}.${ref_id}.${amountRial}`;
|
||||
return !!sign && timingSafeEqualHex(hmac(this.secret, message), sign);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a webhook. `rawBody` MUST be the exact bytes received (Buffer or string —
|
||||
* do not re-stringify a parsed object, signatures won't match).
|
||||
*/
|
||||
verifyWebhook(rawBody, signatureHeader) {
|
||||
const msg = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), "utf8");
|
||||
return !!signatureHeader && timingSafeEqualHex(hmac(this.secret, msg), signatureHeader);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { FlatPay, hmac };
|
||||
@@ -32,7 +32,9 @@ services:
|
||||
ports:
|
||||
# HOST_BIND=127.0.0.1 in prod keeps these off the public interface (only
|
||||
# Caddy's 80/443 face the internet). Unset → 0.0.0.0 for local/LAN dev.
|
||||
- "${HOST_BIND:-0.0.0.0}:5432:5432"
|
||||
# Host port is configurable (PG_HOST_PORT) — 5432 is often taken by another
|
||||
# local project's postgres; the internal stack always uses postgres:5432.
|
||||
- "${HOST_BIND:-0.0.0.0}:${PG_HOST_PORT:-5532}:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d flatrender"]
|
||||
interval: 5s
|
||||
@@ -86,9 +88,19 @@ services:
|
||||
Jwt__Audience: "flatrender"
|
||||
Jwt__AccessTokenMinutes: "${JWT_ACCESS_MINUTES:-1440}"
|
||||
ServiceToken: "${SERVICE_TOKEN:-internal-service-secret}"
|
||||
# Payment callbacks land on this service (api.*); the result page is on the
|
||||
# frontend. Used to make /payment/result redirects absolute to the site.
|
||||
Frontend__BaseUrl: "${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}"
|
||||
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
|
||||
ZarinPal__CallbackUrl: "${ZARINPAL_CALLBACK_URL:-http://localhost:8080/v1/payments/callback/zarinpal}"
|
||||
ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}"
|
||||
# FlatRender Pay broker — when ApiKey+Secret are set, plan purchases route
|
||||
# through pay.flatrender.ir (the single ZarinPal-verified domain) instead of a
|
||||
# direct ZarinPal call. ReturnBase = this identity service's public base.
|
||||
FlatPay__BaseUrl: "${FLATPAY_BASE_URL:-https://pay.flatrender.ir}"
|
||||
FlatPay__ApiKey: "${FLATPAY_FLATRENDER_API_KEY:-}"
|
||||
FlatPay__Secret: "${FLATPAY_FLATRENDER_SECRET:-}"
|
||||
FlatPay__ReturnBase: "${FLATPAY_RETURN_BASE:-https://api.flatrender.ir}"
|
||||
Stripe__SecretKey: "${STRIPE_SECRET_KEY:-}"
|
||||
Stripe__WebhookSecret: "${STRIPE_WEBHOOK_SECRET:-}"
|
||||
SnapPay__ClientId: "${SNAPPAY_CLIENT_ID:-}"
|
||||
@@ -255,6 +267,43 @@ services:
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
# ── Payment Broker (Go) — pay.flatrender.ir ─────────────────────────────────
|
||||
# Standalone generic ZarinPal gateway. Other sites (meezi, bargevasat) and
|
||||
# FlatRender register as client_apps and route payments through it, because
|
||||
# ZarinPal only accepts callbacks on the single verified domain pay.flatrender.ir.
|
||||
# Exposed on its OWN host port (mirror-nginx → pay.flatrender.ir → here);
|
||||
# it does NOT sit behind the API gateway (clients auth with API key + HMAC).
|
||||
|
||||
payment-svc:
|
||||
build:
|
||||
context: ./services/payment
|
||||
container_name: fr2-payment
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Default to the production port 1607 so the bind works without an ENV_FILE
|
||||
# edit (8090 collided on the server). Override via PAY_PORT if 1607 is taken.
|
||||
- "${EDGE_BIND:-0.0.0.0}:${PAY_PORT:-1607}:8080"
|
||||
environment:
|
||||
DATABASE_URL: "postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/flatrender?search_path=payment,public"
|
||||
JWT_SECRET: "${JWT_SECRET}"
|
||||
PORT: "8080"
|
||||
# Externally reachable base — ZarinPal callback + user redirect are built from it.
|
||||
PUBLIC_BASE_URL: "${PAY_PUBLIC_URL:-http://localhost:1607}"
|
||||
# Shared default ZarinPal merchant (a client_app may override per-site).
|
||||
ZARINPAL_MERCHANT_ID: "${ZARINPAL_MERCHANT_ID:-}"
|
||||
ZARINPAL_SANDBOX: "${ZARINPAL_SANDBOX:-true}"
|
||||
# Unit ZarinPal expects in the amount field: "rial" (official v4) or "toman".
|
||||
ZARINPAL_AMOUNT_UNIT: "${ZARINPAL_AMOUNT_UNIT:-rial}"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
# ── API Gateway (Go) ────────────────────────────────────────────────────────
|
||||
|
||||
gateway:
|
||||
@@ -318,11 +367,13 @@ services:
|
||||
HOSTNAME: "0.0.0.0"
|
||||
# Server-side: Next route handlers reach the gateway over the internal network.
|
||||
API_GATEWAY_URL: "http://gateway:8080"
|
||||
# Admin proxy reaches the payment broker directly (not via the gateway).
|
||||
PAYMENT_SVC_URL: "http://payment-svc:8080"
|
||||
depends_on:
|
||||
gateway:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000 || exit 1"]
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000',r=>process.exit(r.statusCode<500?0:1)).on('error',()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -144,14 +144,14 @@
|
||||
"a7": "Yes. Cancel from your account settings at any time. You keep access through the end of your billing period, and you can downgrade to Free without losing your projects."
|
||||
},
|
||||
"pricing": {
|
||||
"heading": "Choose your FlatRender plan",
|
||||
"heading": "Pay by the second, not by the video",
|
||||
"monthly": "Monthly",
|
||||
"annual": "Annual",
|
||||
"saveBadge": "Save up to {percent}%",
|
||||
"subscribe": "Subscribe",
|
||||
"freeBannerTitle": "Free plan",
|
||||
"freeBannerDesc": "Free forever, no credit card required",
|
||||
"perMonth": "/ mo",
|
||||
"perMonth": "monthly",
|
||||
"billedAnnually": "billed annually",
|
||||
"compareTitle": "Compare all plans",
|
||||
"allFeatures": "All features",
|
||||
@@ -161,7 +161,45 @@
|
||||
"proName": "Pro",
|
||||
"proDesc": "Become a pro and unlock more powerful video, design and website editing tools for commercial use.",
|
||||
"businessName": "Business",
|
||||
"businessDesc": "Advanced level solution for teams and businesses. Includes reseller license."
|
||||
"businessDesc": "Advanced level solution for teams and businesses. Includes reseller license.",
|
||||
"subheading": "Each render costs render-seconds equal to the video length × a quality multiplier. Every plan gives you a monthly bucket of render-seconds.",
|
||||
"toman": "Toman",
|
||||
"free": "Free",
|
||||
"mostPopular": "Most popular",
|
||||
"currentPlan": "Current plan",
|
||||
"choosePlan": "Choose plan",
|
||||
"startFree": "Start free",
|
||||
"processing": "Redirecting…",
|
||||
"signInToBuy": "Sign in to buy",
|
||||
"emptyState": "No plans are available right now.",
|
||||
"perMonthSuffix": "/ mo",
|
||||
"featSeconds": "{seconds} render-seconds / month",
|
||||
"featResolution": "Up to {res} quality",
|
||||
"featParallelOne": "1 render at a time",
|
||||
"featParallel": "{n} parallel renders",
|
||||
"featStorage": "{gb} GB cloud storage",
|
||||
"featSpeed": "{factor}× render speed",
|
||||
"featWatermarkOn": "FlatRender watermark",
|
||||
"featWatermarkOff": "No watermark",
|
||||
"calcTitle": "How many seconds do I need?",
|
||||
"calcDesc": "Pick a video length and quality to see the per-render cost in seconds.",
|
||||
"calcLength": "Video length",
|
||||
"calcResolution": "Output quality",
|
||||
"calcCost": "Cost per render",
|
||||
"calcSecondsUnit": "seconds",
|
||||
"calcRendersWith": "With each plan:",
|
||||
"calcVideosFmt": "≈ {count} videos",
|
||||
"multiplierTitle": "Quality multiplier",
|
||||
"multiplierDesc": "Render-seconds per render = video length × the multiplier below.",
|
||||
"multiplierColRes": "Quality",
|
||||
"multiplierColMul": "Multiplier",
|
||||
"faqTitle": "Frequently asked",
|
||||
"faqQ1": "What is a render-second?",
|
||||
"faqA1": "Instead of a video-count limit, you buy render-seconds. A 15-second video at 720p uses exactly 15 seconds of your balance.",
|
||||
"faqQ2": "Why does higher quality cost more seconds?",
|
||||
"faqA2": "4K rendering is much heavier, so each second of video counts as 4 render-seconds; 1080p counts as 2×.",
|
||||
"faqQ3": "What if I run out of seconds?",
|
||||
"faqA3": "Upgrade your plan or wait for the next period. Your max resolution and parallel renders also follow your plan."
|
||||
},
|
||||
"footer": {
|
||||
"brandName": "FlatRender",
|
||||
@@ -387,6 +425,7 @@
|
||||
"templates": "Templates",
|
||||
"media": "Media",
|
||||
"discounts": "Discounts",
|
||||
"payments": "Payments",
|
||||
"siteSettings": "Settings",
|
||||
"messaging": "Messaging",
|
||||
"marketing": "Marketing",
|
||||
@@ -1109,8 +1148,13 @@
|
||||
"description": "Generate voiceovers from your script directly in the studio."
|
||||
},
|
||||
"componentsStudioSidebarColorsCustomTab": {
|
||||
"mainColor": "Main Color",
|
||||
"additionalColor": "Additional Color",
|
||||
"mainColor": "Background",
|
||||
"additionalColor": "Accent",
|
||||
"secondaryColor": "Secondary",
|
||||
"textColor": "Text",
|
||||
"themePresets": "Themes",
|
||||
"applyThemePreset": "Apply {name} theme",
|
||||
"applyTheme": "Apply theme",
|
||||
"applyToAllScenes": "Apply to all scenes"
|
||||
},
|
||||
"componentsStudioSidebarColorsPalettesTab": {
|
||||
@@ -1145,6 +1189,14 @@
|
||||
"replaceImage": "Replace image",
|
||||
"uploadImage": "Upload image"
|
||||
},
|
||||
"componentsStudioSidebarBlockFieldForm": {
|
||||
"panelTitle": "Edit Scene",
|
||||
"emptyState": "This scene has no editable fields.",
|
||||
"fieldFallback": "Field {index}",
|
||||
"textPlaceholder": "Type here…",
|
||||
"replaceImage": "Replace image",
|
||||
"uploadImage": "Upload image"
|
||||
},
|
||||
"componentsStudioSidebarTransitionsSidebarContent": {
|
||||
"heading": "Transitions",
|
||||
"randomTransition": "Random Transition",
|
||||
@@ -1185,7 +1237,10 @@
|
||||
"deleteScene": "Delete {name}",
|
||||
"resizeSceneDuration": "Resize {name} duration",
|
||||
"sceneNameLabel": "Scene name",
|
||||
"doubleClickToRename": "Double-click to rename"
|
||||
"doubleClickToRename": "Double-click to rename",
|
||||
"reorderScene": "Reorder {name}",
|
||||
"durationLabel": "Scene duration (seconds)",
|
||||
"secondsUnit": "s"
|
||||
},
|
||||
"componentsStudioTimelineSceneThumbnailStrip": {
|
||||
"browseScenes": "Browse scenes",
|
||||
|
||||
@@ -144,14 +144,14 @@
|
||||
"a7": "بله. هر زمان از تنظیمات حساب لغو کنید. دسترسی تا پایان دوره صورتحساب باقی میماند و میتوانید به پلن رایگان برگردید بدون اینکه پروژههایتان از دست بروند."
|
||||
},
|
||||
"pricing": {
|
||||
"heading": "پلن فلترندر خود را انتخاب کنید",
|
||||
"heading": "پرداخت بر اساس ثانیه، نه تعداد ویدیو",
|
||||
"monthly": "ماهانه",
|
||||
"annual": "سالانه",
|
||||
"saveBadge": "تا {percent}٪ صرفهجویی",
|
||||
"subscribe": "اشتراک",
|
||||
"freeBannerTitle": "پلن رایگان",
|
||||
"freeBannerDesc": "برای همیشه رایگان، بدون نیاز به کارت اعتباری",
|
||||
"perMonth": "/ ماه",
|
||||
"perMonth": "ماهانه",
|
||||
"billedAnnually": "پرداخت سالانه",
|
||||
"compareTitle": "مقایسه همه پلنها",
|
||||
"allFeatures": "همه امکانات",
|
||||
@@ -161,7 +161,45 @@
|
||||
"proName": "Pro",
|
||||
"proDesc": "حرفهای شوید و ابزارهای قدرتمندتر ویدیو، طراحی و وبسایت را برای استفاده تجاری باز کنید.",
|
||||
"businessName": "Business",
|
||||
"businessDesc": "راهحل پیشرفته برای تیمها و کسبوکارها. شامل مجوز فروش مجدد."
|
||||
"businessDesc": "راهحل پیشرفته برای تیمها و کسبوکارها. شامل مجوز فروش مجدد.",
|
||||
"subheading": "هزینهٔ هر رندر برابر است با طول ویدیو ضربدر ضریب کیفیت. هر پلن ماهانه مقداری «ثانیهٔ رندر» در اختیار شما میگذارد.",
|
||||
"toman": "تومان",
|
||||
"free": "رایگان",
|
||||
"mostPopular": "محبوبترین",
|
||||
"currentPlan": "پلن فعلی",
|
||||
"choosePlan": "انتخاب پلن",
|
||||
"startFree": "شروع رایگان",
|
||||
"processing": "در حال انتقال…",
|
||||
"signInToBuy": "برای خرید وارد شوید",
|
||||
"emptyState": "در حال حاضر پلنی برای نمایش وجود ندارد.",
|
||||
"perMonthSuffix": "/ ماه",
|
||||
"featSeconds": "{seconds} ثانیهٔ رندر در ماه",
|
||||
"featResolution": "کیفیت تا {res}",
|
||||
"featParallelOne": "۱ رندر همزمان",
|
||||
"featParallel": "{n} رندر همزمان",
|
||||
"featStorage": "{gb} گیگابایت فضای ابری",
|
||||
"featSpeed": "سرعت رندر ×{factor}",
|
||||
"featWatermarkOn": "دارای واترمارک FlatRender",
|
||||
"featWatermarkOff": "بدون واترمارک",
|
||||
"calcTitle": "چند ثانیه لازم دارم؟",
|
||||
"calcDesc": "طول و کیفیت ویدیو را انتخاب کنید تا هزینهٔ ثانیهای هر رندر را ببینید.",
|
||||
"calcLength": "طول ویدیو",
|
||||
"calcResolution": "کیفیت خروجی",
|
||||
"calcCost": "هزینهٔ هر رندر",
|
||||
"calcSecondsUnit": "ثانیه",
|
||||
"calcRendersWith": "با هر پلن:",
|
||||
"calcVideosFmt": "≈ {count} ویدیو",
|
||||
"multiplierTitle": "ضریب کیفیت",
|
||||
"multiplierDesc": "ثانیهٔ مصرفی هر رندر = طول ویدیو × ضریب کیفیت زیر.",
|
||||
"multiplierColRes": "کیفیت",
|
||||
"multiplierColMul": "ضریب",
|
||||
"faqTitle": "پرسشهای پرتکرار",
|
||||
"faqQ1": "ثانیهٔ رندر یعنی چه؟",
|
||||
"faqA1": "بهجای محدودیت تعداد ویدیو، شما مقداری ثانیهٔ رندر میخرید. یک ویدیوی ۱۵ ثانیهای با کیفیت ۷۲۰p دقیقاً ۱۵ ثانیه از سهم شما کم میکند.",
|
||||
"faqQ2": "چرا کیفیت بالاتر ثانیهٔ بیشتری میبرد؟",
|
||||
"faqA2": "رندر ۴K پردازش سنگینتری دارد، بنابراین هر ثانیه ویدیو معادل ۴ ثانیهٔ رندر حساب میشود؛ ۱۰۸۰p معادل ۲ برابر.",
|
||||
"faqQ3": "اگر ثانیههایم تمام شود چه میشود؟",
|
||||
"faqA3": "میتوانید پلن خود را ارتقا دهید یا تا شروع دورهٔ بعد صبر کنید. سقف کیفیت و رندر همزمان نیز بر اساس پلن شماست."
|
||||
},
|
||||
"footer": {
|
||||
"brandName": "فلترندر",
|
||||
@@ -387,6 +425,7 @@
|
||||
"templates": "قالبها",
|
||||
"media": "رسانه",
|
||||
"discounts": "تخفیفها",
|
||||
"payments": "درگاه پرداخت",
|
||||
"siteSettings": "تنظیمات سایت",
|
||||
"messaging": "پیامرسانی",
|
||||
"marketing": "بازاریابی",
|
||||
@@ -1109,8 +1148,13 @@
|
||||
"description": "صداگذاری را مستقیماً از روی متن خود در استودیو بسازید."
|
||||
},
|
||||
"componentsStudioSidebarColorsCustomTab": {
|
||||
"mainColor": "رنگ اصلی",
|
||||
"additionalColor": "رنگ مکمل",
|
||||
"mainColor": "پسزمینه",
|
||||
"additionalColor": "رنگ اصلی",
|
||||
"secondaryColor": "رنگ دوم",
|
||||
"textColor": "رنگ متن",
|
||||
"themePresets": "تمها",
|
||||
"applyThemePreset": "اعمال تم {name}",
|
||||
"applyTheme": "اعمال تم",
|
||||
"applyToAllScenes": "اعمال به همه صحنهها"
|
||||
},
|
||||
"componentsStudioSidebarColorsPalettesTab": {
|
||||
@@ -1145,6 +1189,14 @@
|
||||
"replaceImage": "جایگزینی تصویر",
|
||||
"uploadImage": "بارگذاری تصویر"
|
||||
},
|
||||
"componentsStudioSidebarBlockFieldForm": {
|
||||
"panelTitle": "ویرایش صحنه",
|
||||
"emptyState": "این صحنه فیلد قابلویرایشی ندارد.",
|
||||
"fieldFallback": "فیلد {index}",
|
||||
"textPlaceholder": "اینجا بنویسید…",
|
||||
"replaceImage": "جایگزینی تصویر",
|
||||
"uploadImage": "بارگذاری تصویر"
|
||||
},
|
||||
"componentsStudioSidebarTransitionsSidebarContent": {
|
||||
"heading": "ترانزیشنها",
|
||||
"randomTransition": "ترانزیشن تصادفی",
|
||||
@@ -1185,7 +1237,10 @@
|
||||
"deleteScene": "حذف {name}",
|
||||
"resizeSceneDuration": "تغییر مدت زمان {name}",
|
||||
"sceneNameLabel": "نام صحنه",
|
||||
"doubleClickToRename": "برای تغییر نام، دوبار کلیک کنید"
|
||||
"doubleClickToRename": "برای تغییر نام، دوبار کلیک کنید",
|
||||
"reorderScene": "جابهجایی {name}",
|
||||
"durationLabel": "مدت صحنه (ثانیه)",
|
||||
"secondsUnit": "ث"
|
||||
},
|
||||
"componentsStudioTimelineSceneThumbnailStrip": {
|
||||
"browseScenes": "مرور صحنهها",
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
"lucide-react": "^1.16.0",
|
||||
"next": "14.2.35",
|
||||
"next-intl": "^4.12.0",
|
||||
"plyr": "^3.7.8",
|
||||
"plyr-react": "^5.3.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.76.0",
|
||||
@@ -4002,6 +4004,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
@@ -4042,6 +4055,12 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/custom-event-polyfill": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz",
|
||||
"integrity": "sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
@@ -6270,6 +6289,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loadjs": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/loadjs/-/loadjs-4.3.0.tgz",
|
||||
"integrity": "sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -7005,6 +7030,44 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/plyr": {
|
||||
"version": "3.7.8",
|
||||
"resolved": "https://registry.npmjs.org/plyr/-/plyr-3.7.8.tgz",
|
||||
"integrity": "sha512-yG/EHDobwbB/uP+4Bm6eUpJ93f8xxHjjk2dYcD1Oqpe1EcuQl5tzzw9Oq+uVAzd2lkM11qZfydSiyIpiB8pgdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "^3.26.1",
|
||||
"custom-event-polyfill": "^1.0.7",
|
||||
"loadjs": "^4.2.0",
|
||||
"rangetouch": "^2.0.1",
|
||||
"url-polyfill": "^1.1.12"
|
||||
}
|
||||
},
|
||||
"node_modules/plyr-react": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/plyr-react/-/plyr-react-5.3.0.tgz",
|
||||
"integrity": "sha512-m36/HrpHwg1N2rq3E31E8/kpAH55vk6qHUg17MG4uu9jbWYxnkN39lLmZQwxW7/qpDPfW5aGUJ6R3u23V0R3zA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"plyr": "^3.7.7",
|
||||
"react-aptor": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"plyr": "^3.7.7",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"plyr": {
|
||||
"optional": false
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/po-parser": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
|
||||
@@ -7264,6 +7327,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rangetouch": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz",
|
||||
"integrity": "sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/re-resizable": {
|
||||
"version": "6.11.2",
|
||||
"resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.11.2.tgz",
|
||||
@@ -7286,6 +7355,23 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-aptor": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-aptor/-/react-aptor-2.0.0.tgz",
|
||||
"integrity": "sha512-YnCayokuhAwmBBP4Oc0bbT2l6ApfsjbY3DEEVUddIKZEBlGl1npzjHHzWnSqWuboSbMZvRqUM01Io9yiIp1wcg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-date-object": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/react-date-object/-/react-date-object-2.1.9.tgz",
|
||||
@@ -8698,6 +8784,12 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/url-polyfill": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz",
|
||||
"integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
"lucide-react": "^1.16.0",
|
||||
"next": "14.2.35",
|
||||
"next-intl": "^4.12.0",
|
||||
"plyr": "^3.7.8",
|
||||
"plyr-react": "^5.3.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.76.0",
|
||||
|
||||
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 498 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 816 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 962 KiB |
|
After Width: | Height: | Size: 660 KiB |
|
After Width: | Height: | Size: 889 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |