Compare commits

...

97 Commits

Author SHA1 Message Date
soroush.asadi 62ea110605 feat(payment): admin-editable ZarinPal settings + in-panel test payment
CI/CD / CI · Web (tsc) (push) Successful in 1m33s
CI/CD / Deploy · full stack (push) Failing after 20s
Lets the broker's ZarinPal merchant / sandbox / amount-unit be set from
Admin → درگاه پرداخت (persisted in payment.settings) instead of env +
redeploy, and adds a per-app "test payment" button that mints a real
ZarinPal StartPay link straight from the panel — no site wiring needed.

- migration 33_payment_settings.sql: singleton payment.settings + a
  transactions.is_test column. (33, not 32 — 32 is content_render_engine.)
- broker read-path precedence: per-client override > DB settings > env.
- POST /v1/admin/clients/:id/test-payment + GET/PUT /v1/admin/settings.
- admin UI: «تنظیمات زرین‌پال» tab + «پرداخت آزمایشی» button.

Adversarial-review fixes (2 confirmed HIGH):
- do NOT pre-seed the settings row — a seeded sandbox=TRUE default would
  override a production ZARINPAL_SANDBOX=false env and silently route real
  payments to sandbox.zarinpal.com until an admin untouched the toggle.
  No row → env governs until an admin saves.
- test transactions are tagged is_test and the webhook dispatcher skips
  them, so an admin smoke-test can never notify (or credit) a real client,
  regardless of metadata. Broker-authoritative, not consumer-dependent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:47:10 +03:30
soroush.asadi 3748b1c8d8 fix(payment): send result redirects to the frontend + add /payment/result page
CI/CD / CI · Web (tsc) (push) Successful in 1m26s
CI/CD / Deploy · full stack (push) Failing after 28s
2026-06-25 13:17:21 +03:30
soroush.asadi dc1fe11604 feat(remotion): player default demo = IG promo (bare /player/ URL renders it)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:46:53 +03:30
soroush.asadi dc5ff09b67 feat(remotion): isolated client-side player (Approach A) — templates render in-browser
Render the React-Three-Fiber-v9 (React 19) templates client-side without touching the
React-18 Next host: a standalone Vite app (services/remotion/player) mounts
@remotion/player with the real FlexStory composition. The studio will embed it via an
iframe and feed scene data (URL hash for first paint, postMessage for live edits).

- player/main.tsx: reads {props, aspect, watermark}, computes duration via
  calcFlexStoryMetadata, renders <Player>. Free tier shows a watermark overlay
  (preview only — clean export stays server-authorized).
- vite.config.player.ts: builds to player-dist/ with relative base (servable at /player/).
- @remotion/player + vite added.

Verified: vite build bundles FlexStory + three.js (672 modules → 1.3MB) and serves
at /player/index.html (200). Browser render to be confirmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:11:27 +03:30
soroush.asadi 40fdcf280f feat(render): always-available, fully-cancel render controls
The backend cancel was solid (CancelJob/StopJob; the dev worker abandons a cancelled
job, a real node kills its process) — but the UI couldn't reach it: the render page
had NO cancel button, and the global progress pill's X only HID the pill (the job kept
running). So a render couldn't actually be stopped from where you watch it.

- Render page: a prominent «لغو رندر» button while a render is in flight (Queued or
  Running); cancelRender() calls /renders/:id/cancel and returns to config optimistically.
  The poll now also handles a `cancelled` status (when stopped from another surface).
- Global pill: the X now CANCELS the render (with confirm) instead of just hiding it —
  so any in-flight render is cancellable from any page.
- (Dashboard MyRenders already had a working cancel.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 11:31:56 +03:30
soroush.asadi 6814e64593 fix(studio): responsive scene-preview placeholders that fit the still
The hotspot overlay used fixed percentages on a w-full/h-auto image, so a 9:16 scene
ballooned vertically and the placeholders (tuned for landscape) floated off the image.

Now the still is CONTAIN-fit inside the measured area (portrait + landscape both fit,
no overflow) and the hotspot overlay is anchored to the fitted image rectangle, so
placeholders always track and scale with the image. Hotspot positions are aspect-aware
(tall vs wide) and clamped to stay on the still.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:51:12 +03:30
soroush.asadi a36e96d933 fix(templates): real scene count on template pages (was always 0)
The card + detail read template.sceneCount, but the API never sent one — so the
frontend mapper hardcoded sceneCount:0 for every DB-backed template.

- content-svc: ContainerSummaryResponse + ContainerDetailResponse now carry
  SceneCount. The list computes it with one grouped query (scenes per aspect project,
  max across aspects); the detail loads scenes and counts them.
- frontend: V2ContainerSummary.scene_count → AdminProject.sceneCount → the catalog
  card/detail (adminProjectToCatalogTemplate no longer hardcodes 0).

Verified on the live local API: fr-instagram-promo → 5, single-scene templates → 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:51:12 +03:30
soroush.asadi 21b6a30f08 feat(scripts): portable template import/export (bundles)
Move a template fully between environments (local → live): container, projects, all
scenes + editable fields, shared colours/layers, its categories & tags, and the asset
files.

- export_template.py <slug> → a self-contained bundle (template.json + assets/). One
  SQL query captures the whole tree as JSON; assets are resolved from template-media
  references and copied in. Source DB via PSQL env (default = local docker).
- import_template.py <bundle> → idempotent SQL (pipe to target psql). Replaces by slug
  via one cascading delete (all content.* FKs are ON DELETE CASCADE), recreates rows
  verbatim (UUIDs preserved → FKs intact), merges categories/tags BY SLUG so they line
  up across DBs. --assets-to copies media; docker cp / mc cp hints for remote.
- TEMPLATE_BUNDLES.md documents it.

Round-trip tested on fr-instagram-promo: DB → bundle → DB restores identical
3 projects / 15 scenes / 138 fields and field values.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:09:41 +03:30
soroush.asadi 7725c13771 feat(seed): add Instagram channel-promo template to FlatRender (local)
- scripts/seed_instagram_promo.py — dedicated seeder for the 5-scene FlexStory IG
  promo (the generic seeder only handles CharacterStory's 2-text-per-scene shape).
  Scenes keyed `<BlockId>__<n>` (render decodes the block from the key); each scene's
  content-elements are that block's real fields (Text + Media); colours as shared.
  render_remotion_comp = FlexStory-<asp> so GetFlexStoryProps routes it.
- public/template-media/InstagramPromo* — thumbnails, per-scene stills, preview MP4.

Applied to local fr2-postgres + fr2-frontend: container fr-instagram-promo (3 aspects,
15 scenes, 138 fields), served by the gateway and the /templates detail page (200).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 09:56:40 +03:30
soroush.asadi 38229185a7 feat(remotion): IG promo posts accept images AND video
- igkit: a Media component that detects video by extension and renders a frame via
  OffthreadVideo (muted), else an Img — so any post slot takes images or reels.
- IGProfile: the profile-page grid is now editable — 6 post media fields (was static
  colour placeholders); videos get a ▶ reel badge.
- IGFeed: post slots now accept video too; labels say «عکس/ویدیو».

Verified: a profile still with an image cell + a video cell + avatar image renders
both correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 09:37:34 +03:30
soroush.asadi 7ed2ccc414 feat(remotion): Instagram channel-promo template + taste system + design-quality kit
The reference-round workflow, run end to end for a real template:

Taste system (how we learn the user's taste, persisted):
- references/TASTE_PROFILE.md (living design contract) + references/README.md (the
  daily loop) + a "reference round" stage in docs/TEMPLATE_BRIEF.md (provide refs or
  I suggest+mock directions).

Design-quality before/after:
- HeroDemo — the fix recipe vs the faint default: layered-depth background, a proper
  big video type scale, and a bold composed focal object. (Backgrounds were naked,
  text too small, scenes had no objects.)
- YaldaSofreh3D + IGPromoDirections + IGProfileMock — reference-match proofs
  (low-poly 3D, 3 IG-promo style directions, the realistic IG-light page).

Instagram channel-promo template (the deliverable — a flexible 5-scene FlexStory):
- igkit + 5 blocks: IGIntro, IGProfile (realistic IG-light profile, scales to all
  aspects), IGFeed (post grid), IGStats (animated count-up), IGFollowCTA (Follow taps
  to "Following").
- FlexStory gains a `finish` toggle so the IG-light scenes render clean (no brand
  grade). INSTAGRAM_PROMO preset + 3 aspect comps in Root.

Verified: a still of every scene at 9:16 renders clean; full preview MP4 rendering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 09:16:31 +03:30
soroush.asadi 8c4bc2c626 feat(remotion): craft kit (stop-motion + paper-cut) + PaperCut block
The real visible quality leap — a handmade craft aesthetic code can't fake by being
smooth:
- craft.ts: useStopMotion (quantize the frame to "on twos/threes" + per-step jitter
  → choppy handmade motion), paperShadow (layered cast shadows for paper depth),
  PAPER_TEXTURE (procedural fibrous paper grain).
- PaperCut block: a layered paper-cut landscape — sun + 4 brand-coloured paper hills
  with real cast shadows + paper grain, rising into place on stop-motion timing with
  an idle wobble, + paper-cut title/subtitle. Re-flows 16:9/1:1/9:16.

Registry now has 13 blocks. Verified: warm Yalda render (fits the Persian/seasonal
moat) + a stop-motion demo clip showing the on-threes choppy rise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:26:01 +03:30
soroush.asadi b1a51cb01b feat(remotion): shared FinishPass cinematic grade (quality floor) + @remotion/lottie
The single highest-ROI quality lift — one finish applied at the FlexStory level
lifts all 12 blocks at once, no per-block change:
- GRADE_FILTER: a headless-safe colour grade (contrast/saturation/lift) applied as
  a CSS `filter` on the content root — backdrop-filter does NOT render in headless
  Chrome, so the grade lives on the content, not an overlay.
- FinishPass: split-tone (cool-shadows multiply + warm-highlights screen) + a soft
  brand duotone + top light-bloom, layered over each scene.
- Installed @remotion/lottie@4.0.290 (artist-made animations — next lever).

Verified: visible richer/graded look on CharacterScene + Slideshow, subtle enough
to suit the muted palette, consistent across blocks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 23:35:08 +03:30
soroush.asadi 8f34c3175f feat(remotion): +3 scene blocks (BarChart, Stomp, DeviceMockup) + catalog/toolchain docs
Unlocks the biggest catalog gaps by composition:
- BarChart: animated infographic bars (value + label, normalized, staggered grow).
- Stomp: punchy beat-synced typography — words slam in with overshoot + shake +
  accent impact bar (titles / fashion / openers).
- DeviceMockup: phone/browser frame holding the user's screenshot + title/caption
  (app / website promo); placeholder when no image.
Registry now has 12 blocks. All verified via FlexStory props-override stills.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 23:16:44 +03:30
soroush.asadi 866edbff8c feat(studio): scene-engine preview editor — scene image + clickable field hotspots
Replaces the misleading flat Konva canvas (for FLEXIBLE/Remotion templates) with a
real preview the user can edit against:
- ScenePreview shows the scene's rendered still (scene.image) centred, and overlays
  labelled, clickable HOTSPOTS over each editable field (logo / text), positioned by
  a layout heuristic tuned to our blocks (visual centred, text stacked below).
- Clicking a hotspot selects that field; BlockFieldForm highlights + scrolls to the
  matching field (and focusing a field highlights its hotspot) — "click the logo to
  edit it" works both ways.
- CanvasEditor branches to ScenePreview when isFlexStoryProject(); AE/Konva
  templates keep the full editor.

Fixes: (1) clicking a scene now shows its real image centre-screen; (2)/(3) the logo
and text are visible placeholders you can click to edit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:09:36 +03:30
soroush.asadi 055d8365fe feat(studio): per-scene loop plays on hover (scene.demo end-to-end)
Wires the per-scene loop video all the way to the scene card:
- studio-svc: SavedSceneResponse now includes Demo (was stored + copied but never
  serialized); MapSceneResponse passes s.Demo.
- Scene type gains image?/demo?; parseScene reads them from the loaded scene data.
- SceneThumbnailBlock shows scene.image as the still and plays scene.demo (muted,
  looped) on hover, resetting on mouse-leave.

Existing projects backfilled (saved_scenes.image/demo from content.scenes). Both
services rebuilt + deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:46:07 +03:30
soroush.asadi e4fd936953 feat(seed): per-scene loop video + thumbnail for every scene
Each scene now carries both a still (content.scenes.image) AND a short ~1.5s LOOP
video (content.scenes.demo), so the studio scene cards show a looping preview, not
a static swatch.

- LOOP_SCENES = {CharacterStory, LogoMotion3D}: their scenes get a dedicated
  per-scene loop ({tid}-{asp}-c{n}-loop.mp4 / {tid}-{asp}-loop.mp4); other
  templates fall back to their full preview MP4.
- Renders 42 loops: LogoMotion3D ×3 (frames 30–74) + CharacterStory 13 scenes ×3
  aspects (frames (n-1)*90+20 … +64), each a 45-frame / 1.5s clip mid-scene.
- Seed sets image + snapshot_url + demo per scene; verified all 42 serve 200 and
  the DB wires each scene to its own still + loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:09:04 +03:30
soroush.asadi 825f25be55 fix(studio): lock the canvas for scene-engine templates (no drag/resize)
For FLEXIBLE (Remotion / FlexStory) templates the render uses fixed positions —
dragging or resizing a layer on the Konva canvas does nothing to the output, which
is confusing. Make the canvas a read-only PREVIEW for those projects: the Konva
Layer is listening=false (no drag/select/transform), the Transformer is hidden, and
the auto-thumbnail capture is skipped so the flat Konva snapshot can't overwrite the
real rendered per-scene image. Editing happens only through the field form
(BlockFieldForm). AE/Konva templates are unchanged. Gated on isFlexStoryProject().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:33:20 +03:30
soroush.asadi 4bac5154ed fix(seed): real per-scene images for every scene (were only colour swatches)
Scenes were seeded with just a scene_color_svg swatch — content.scenes.image /
snapshot_url were empty, so the studio/admin scene previews showed swatches, not
the actual scene. Now every scene gets a real rendered image:
- single-scene templates → their per-aspect thumbnail;
- multi-scene templates → one still per scene, captured at that scene's own frame.

Adds the 39 CharacterStory per-scene stills (13 scenes × 3 aspects), each rendered
at (sceneIndex*90 + 45). LogoMotion3D's single scene now points at its thumbnail.
Verified: DB image/snapshot_url populated, all per-scene images serve 200, and the
stills are distinct per scene (c7 = «یک مانع», kicker ۰۷/۱۳).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:07:48 +03:30
soroush.asadi de8849bd94 feat(remotion): +LogoMotion3D template (Tech/3D cinematic logo reveal)
First template built through the new flow (brief → quality-gate approval → build →
seed → deploy). Tech/3D logo motion: a 3D metallic card + radial light rays + lens
flare + bloom (genuine @remotion/three), with the user's uploaded logo composited
on the card as a reliable HTML <Img> (renders any SVG/PNG/data-URI; static camera
keeps it aligned), brand text + tagline, grain. Falls back to a branded play-mark
when no logo is set. Re-flows across 16:9/1:1/9:16.

- LogoMotion3D.tsx registered per aspect in Root.tsx.
- Seeded as fr-logo-motion-3d: text fields (brandText, tagline) + a logoUrl image
  upload field + the dark-tech palette (light text) + per-aspect previews.
- 3 thumbnails + 3 preview MP4s rendered, deployed; detail page + assets serve 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:13:26 +03:30
soroush.asadi 7394c5ce78 feat(remotion): +ProductShowcase block (phone/browser device mockup)
Adds the product/app-showcase template type the engine was missing: a 2.5D device
frame (rounded phone with notch, or a browser window with traffic-lights + URL bar)
holding an uploaded screenshot, with title/subtitle and the shared Three backdrop.
Fields: screenshot, title, subtitle, device (phone|browser). Registry now 9 blocks.
Verified via FlexStory props-override stills (both device modes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:18:06 +03:30
soroush.asadi a48633741e docs(remotion): audio sourcing catalog (CC0 music/SFX, Iran-aware)
From the audio-sourcing-sweep (45 sources verified). The load-bearing test —
can a paid SaaS render the audio into customers' MP4s AND vendor the file —
rules out almost all "royalty-free" libraries; only CC0/PD passes cleanly.

- USE (CC0, vendorable): FreePD (music; site dead → archive.org/details/freepd),
  Kenney.nl (SFX; the one clean-from-Iran source), Freesound-CC0, OpenGameArt-CC0.
- CAUTION: incompetech CC-BY (needs attribution pipeline), aggregators (verify
  per-track), Sonniss/Pixabay (render-input-only, never vendor raw).
- AVOID/reference-only: Mixkit/Uppbeat/Bensound/Envato/Zapsplat/… (clauses + OFAC).
- Persian = no clean CC0 bulk source → commission + self-CC0 long-term.

Real files need a VPN/non-Iran fetch (acquire-once-then-vendor makes the licence
perpetual); only the 4 self-authored ffmpeg stubs are vendored today. Firewall
rules mirror the illustration assets.json + check-assets guard (already scans audio).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:03:50 +03:30
soroush.asadi 3eab1056c8 feat(remotion): audio layer — self-authored music bed + transition SFX in FlexStory
Adds audio to the scene engine without any third-party/geo-blocked sourcing: the
beds + SFX are synthesized with ffmpeg, so they're license-free (CC0, self-authored)
and need no acquisition — the same play as self-authoring Lottie.

- public/audio/: music-ambient.mp3 (soft 3-tone pad, looped) + sfx-whoosh/pop/chime.
- FlexStory: optional music/musicVolume/sfx props (optional so the existing render
  binding needs no change). Renders <Audio loop> for the bed + a whoosh at each
  scene start and a chime on the final scene, driven by precomputed scene starts.
- check-assets: now also scans public/audio (+ lottie) with folder-prefixed keys;
  assets.json ledgers the 4 audio files (CC0 self-authored).

Verified: tsc clean; a 6s FlexStory render produces an MP4 with a real audio stream
(ffprobe: codec_type=audio). NOTE: these are placeholder/SFX-grade; a premium
curated music library (by vibe) is a separate sourcing sweep, and the studio music
picker → FlexStory `music` prop is a follow-up wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:31:19 +03:30
soroush.asadi c0d04fa855 feat(studio+render): wire theme picker → saved_shared_colors → FlexStory render
Closes the theme→render gap: the studio theme picker now actually drives a
FlexStory render's colours. GetFlexStoryProps reads saved_shared_colors by
element_key (accentColor/secondaryColor/backgroundColor/textColor), but the studio
only wrote the theme into scene_data — so the picker never reached the MP4.

- studio-svc: UpdateSharedColorsAsync upserts saved_shared_colors by (project,
  element_key) + PATCH /v1/saved-projects/{id}/shared-colors endpoint +
  UpdateColorsRequest/UpdateColorItem. Mirrors UpdateSceneContentsAsync. (dotnet
  build: 0 errors.)
- gateway already wildcard-routes /v1/saved-projects/*path → studio-svc (no change).
- Next: /api/projects/[id]/colors route → gateway; project-api patchProjectColors
  + themeColorsFromSceneData (maps scene_data sceneAccentColor… → the colorSchema
  keys); performSave best-effort pushes the 4 colours alongside contents.

Chain: theme picker → store → scene_data → performSave → patchProjectColors →
gateway → studio-svc upsert → saved_shared_colors → GetFlexStoryProps → render.
Verified: Next build + dotnet build both clean; theme presets render cohesively
across all 6 (incl. dark Midnight). End-to-end studio→render needs the live stack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:04:47 +03:30
soroush.asadi c1747167f3 feat(studio): Phase 4 v1 — FlexStory block-field editor
Scene-engine (FLEXIBLE) projects now get a clean per-field content editor instead
of the Konva layer panel. The scoping confirmed content VALUES already flow to
saved_scene_contents via the existing `c-`-layer + updateLayer + autosave path —
so this is purely a cleaner presentation over the working save path, no new
persistence.

- isFlexStoryProject(chooseMode) helper (FLEXIBLE → scene engine).
- BlockFieldForm: renders one labelled field per content layer (label from
  layer.name — the field's Persian label, already preserved from the content
  title), text→textarea, image→upload; writes back via the unchanged
  updateLayer(props) call. No Konva geometry/layer chrome.
- StudioSidebarContent: the "scenes" tool branches on chooseMode — FlexStory →
  BlockFieldForm, AE/Konva → SceneEditSidebarContent (zero regression).
- i18n: componentsStudioSidebarBlockFieldForm in fa + en.

Verified `npm run build`. NOTE: preview stays the live Konva canvas for v1 (a true
@remotion/player embed is deferred — 8–12MB Three.js bundle). Remaining: confirm
the FlexStory render binder reads the 4 theme colours from scene_data (already
persisted) vs saved_shared_colors (would need a small colours endpoint).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:40:32 +03:30
soroush.asadi 383331e8f1 feat(remotion): +2 scene blocks — LogoReveal (logo motion) + StatCounter
Grows the scene-block library toward full template-type coverage:
- LogoReveal: premium logo-motion — spring scale-in + glint sweep over the logo
  (image upload or a branded play-mark placeholder) + brand text + tagline, on the
  shared 2.5D Three backdrop. Fields: logoUrl, brandText, tagline.
- StatCounter: animated count-up to a target (English-digit value → Persian
  display) + suffix + label. Fields: value, suffix, label.

Registry now has 8 blocks. Both verified via FlexStory props-override stills.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:05:15 +03:30
soroush.asadi 8582e956c9 feat(studio): theme picker — 4-color brand theme + curated preset swatches
Extends the studio's 2-color palette to the full 4-color brand theme
(accent / secondary / background / text) matching the Remotion SceneColors,
so the studio's colour state maps 1:1 to the scene engine.

- studio-store: add sceneSecondaryColor + sceneTextColor + their setters + an
  applySceneTheme(accent,secondary,background,text) action (sets all four +
  recolours canvas layers: bg→background, overlays→secondary, shapes→accent,
  text→text explicitly); persist both new fields in hydrate + getSceneDataForSave.
- studio-scene-data: carry sceneSecondaryColor + sceneTextColor through
  VideoPersistedSceneData / build / parse (with defaults).
- ColorsCustomTab: 6 one-click theme presets (Warm/Berry/Midnight/Ocean/Sunset/
  Mono) + 4 manual colour inputs + Apply.
- i18n: secondaryColor/textColor/themePresets/applyTheme(+preset) in fa + en.

Verified with `npm run build`. NOTE: the theme persists in scene_data and
recolours the canvas; wiring the 4 colours all the way to a FlexStory render's
saved_shared_colors depends on the studio-svc shared-colour sync (a small
follow-up). Block-FIELD editing remains the Phase 4 follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:56:14 +03:30
soroush.asadi 8ddca5647b feat(studio): Phase 3 — scene reorder + numeric duration + FIX/FLEXIBLE gating
Wires the scene-list operations users asked for into the existing timeline
(model-agnostic — works for any scene, layer- or block-based):

- SceneThumbnailBlock: now sortable (@dnd-kit useSortable) with a left-edge grip
  handle (listeners only on the handle so select/rename/resize still work); adds a
  numeric per-scene duration input (commit on blur/Enter, clamped 1–30s) next to
  the drag-resize; a `locked` prop makes it read-only.
- SceneThumbnailStrip: wraps the blocks in DndContext + SortableContext
  (horizontal, 6px pointer-activation so clicks/resize aren't hijacked) and calls
  the existing reorderScenes store action; gates add/browse + reorder/duplicate/
  delete/duration behind isFixedSceneMode(chooseMode).
- studio-store: isFixedSceneMode() helper (single source for FIX vs FLEXIBLE).
- i18n: reorderScene / durationLabel / secondsUnit in fa + en.

Verified with `npm run build` (rules-of-hooks clean). NOTE: a THEME PICKER and
FlexStory block-FIELD editing are deferred — the studio editor is Konva-layer-
centric, so both need a FlexStory-aware editing path (a follow-up), not this phase.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:18:00 +03:30
soroush.asadi f8ea9af3b6 feat(render): Phase 2 — FlexStory render passthrough + journey template seed
Closes the render boundary so a user's scene list (order, per-scene content,
per-scene duration, theme) actually drives the FlexStory engine — the one gap the
scene-engine mapping found.

- render-svc GetFlexStoryProps (db.go): structured per-scene query that groups
  saved_scene_contents BY scene (the flat GetRenderBindings union collides when
  scenes share keys like "title"), recovers blockId from the scene key
  ("<BlockId>__<n>"), and emits the FlexStory props object
  {scenes:[{blockId,durationSec,props}], accentColor, …}.
- render-svc Claim (internal.go): when the template is Remotion + comp starts with
  "FlexStory", send that object as a single "__flexprops__" binding (no protocol
  struct change).
- node-agent remotionProps (remotion.go): if "__flexprops__" is present, pass it
  to `remotion render --props` verbatim (it's the complete props object).
- scripts/seed_flexstory.py: seeds the CharacterJourney template (7 scenes, theme
  colours, FLEXIBLE) with blockId-encoded scene keys, so the studio's existing
  CopyTemplateGraphAsync copies them into saved_scenes with zero studio-svc change.

Both Go services compile; template is live in the catalog (detail 200, per-aspect
previews). End-to-end render verification needs a live Remotion render node.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:45:04 +03:30
soroush.asadi 2104dd3c84 feat(remotion): theme system + CharacterJourney pilot template
- src/scenes/themes.ts: 6 curated themes (the cohesion rail) — pick one, then
  tweak the 4 brand colors; every block derives its shades so a theme re-skins
  the whole video coherently (verified: same journey rendered in warm-editorial
  vs berry-pop by overriding only the 4 colors).
- src/scenes/presets.ts: CHARACTER_JOURNEY — the pilot template's scene list
  ("Idea → struggle → tool → win", 7 beats) as a FlexStory preset.
- briefs/character-journey.md: the filled Template Spec from the guided brief.
- Root.tsx: register CharacterJourney per aspect (FlexStory + the preset).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:19:51 +03:30
soroush.asadi d830c56ea0 feat(remotion): FlexStory scene engine — ordered editable scene-blocks (Phase 1)
Turns a template into an ordered list of editable scene blocks instead of one
monolithic composition — the foundation for the scene-based template engine
(all Renderforest-style types, per-scene editable duration, add/duplicate/
delete/reorder). Render-side only; backend wiring is Phase 2.

- src/scenes/types.ts: SceneInstance/BlockProps/SceneBlock + withDefaults/clamp.
- src/scenes/chrome.tsx: shared 2.5D Three.js backdrop (parallax camera, blobs,
  particles, optional 3D confetti) + grain/vignette/progress/kicker/transition.
- src/scenes/blocks/*: Core 6 blocks — TitleCard, CharacterScene (full room +
  vendored CC0 character behind a desk), ImageCaption, KineticQuote, Slideshow,
  OutroCTA — each with editable fields + its own duration range.
- src/scenes/registry.ts: the block registry (blockId -> block).
- src/compositions/FlexStory.tsx: the sequencer — stacks blocks in <Sequence>,
  clamps per-scene duration, and computes composition length dynamically via
  calculateMetadata (so add/delete/reorder/duration all flow to the render).
- StoryScenes.tsx: the 2.5D story proof this productizes; docs/TEMPLATE_BRIEF.md:
  the guided creator flow + Template Spec.

Verified: all 6 blocks render via FlexStory in 16:9/1:1/9:16; a custom props
override (reordered scenes, custom characters/durations/colors) renders correctly
and the total length tracks Σ per-scene durations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 07:45:57 +03:30
soroush.asadi fd364209e7 feat(coming-soon): hard-lock the live curtain; closable only on local/dev hosts
The curtain was sessionStorage-dismissible everywhere. NODE_ENV can't tell the
live deploy from the local Docker site (both are prod builds), so gate on the
hostname instead: localhost + private LAN ranges (incl. 172.28.x) keep the
"view experimental (local)" close button; any public domain is hard-locked to
just the countdown. Starts the curtain up by default so the live site never
flashes a page before it mounts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 07:45:16 +03:30
soroush.asadi cb6512fee3 feat(remotion): asset-library catalog + Phase 0 (license firewall, @remotion/lottie, 30 CC0 characters)
- docs/ASSET_LIBRARY.md: curated catalog from the asset sweep (91 sources -> 62
  usable) + completeness-critic reality check; clean CC0/MIT tier, license/geo
  traps, and the 2.5D layered-scene plan (sky->room->furniture->device->character
  ->grain) to fix the "naked scene".
- deps: add @remotion/lottie@4.0.290 (runtime) + DiceBear (build-time devDep).
- scripts/gen-dicebear.mjs: generate 30 CC0 Open-Peeps characters OFFLINE (no
  runtime CDN) into public/illustrations/dicebear/ + a per-file assets.json ledger.
- scripts/check-assets.mjs: license-firewall CI guard — fails on any un-ledgered
  vendored asset.
- AssetSheet dev composition: proves vendored SVG -> staticFile() -> Remotion render
  (30 real characters render cleanly).
- NOTE: GitHub (Open Peeps/IRA/Notion git clones) + Gumroad (Lukasz) are geo-blocked
  headless here; those + Humaaans (Figma export) need a manual/mirror fetch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 18:59:03 +03:30
soroush.asadi a3152ee84f feat(remotion): premium CharacterStory template (13 flexible scenes) + fix detail-page SSR
- CharacterStory: refined flat-illustration character (gradient-shaded sweater,
  modern hair, calm minimal face), muted editorial palette (coral/teal/sand/navy),
  abstract environment (soft depth blobs, ground "stage", sparse particles,
  vignette + grain), scene-number kicker. Verified in 16:9/1:1/9:16 and all poses.
- seed: 13 editable scene cards (c1..c13, keys s{N}_title/s{N}_text) via new
  MULTISCENE path; per-aspect previews; muted defaults.
- assets: 3 thumbnails + 4 preview MP4s vendored into public/template-media.
- fix: load BrandedVideoPlayer (plyr-react) client-only via next/dynamic
  (ssr:false) — plyr touches `document` at import, which was 500-ing every
  template detail page during SSR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:58:48 +03:30
soroush.asadi 863b9503b3 fix(detail+docker): per-aspect template preview + Debian frontend base
CI/CD / CI · Web (tsc) (push) Successful in 1m17s
CI/CD / Deploy · full stack (push) Failing after 15s
- Template detail page now shows the render matching the SELECTED aspect (poster +
  preview video) instead of the 16:9 cover cropped into a 9:16/1:1 box. TemplateVariant
  carries per-aspect image/previewVideo; fetchTemplateVariants + the detail page wire them.
- AppShowcase3D ships a distinct preview video per aspect (seed PERASPECT_VIDEO).
- Frontend Dockerfile: Alpine -> node:20-slim (glibc). Fixes next-swc ("ld-linux..."
  load failure that broke `next build` once libc6-compat was removed) AND the original
  CI Alpine-CDN issue. Healthcheck switched to node (slim has no wget).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:04:04 +03:30
soroush.asadi 60759f35b4 polish(remotion): shiny titanium finish on AppShowcase3D phone
CI/CD / CI · Web (tsc) (push) Successful in 1m22s
CI/CD / Deploy · full stack (push) Failing after 14s
Polished-metal look: low-roughness (0.15) titanium + contrasty studio Environment
(light bases + bright softbox strips) so the rounded edges catch hot reflection
streaks that sweep as the phone rotates; shinier side buttons. Re-rendered all
aspects + preview, redeployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:00:26 +03:30
soroush.asadi 1795bc855b feat(remotion): premium 3D app-showcase template (AppShowcase3D)
CI/CD / CI · Web (tsc) (push) Successful in 1m19s
CI/CD / Deploy · full stack (push) Failing after 12s
- New @remotion/three template: titanium flagship phone (thin rim, glossy black
  glass, rounded-corner screen via ShapeGeometry, dynamic island, side buttons),
  light keynote studio (contact shadow + env reflections + DOF + soft bloom),
  film grain + entrance light-sweep. All 3 aspects re-flowed.
- Editable screenUrl (user app screenshot textured onto the screen via TextureLoader
  + delayRender), appName/tagline/cta, 4 colours (dark text on light bg).
- Add pick(wide,square,tall) helper to lib/aspect.ts (Tier-0 from the R&D).
- Seed: AppShowcase3D + per-template text colour; built with the flat-artist skill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 21:28:49 +03:30
soroush.asadi f83d657844 chore(skills+remotion): add flat-artist skill bundle; register 3D templates
CI/CD / CI · Web (tsc) (push) Successful in 1m19s
CI/CD / Deploy · full stack (push) Failing after 12s
- .claude/skills/flat-artist: the bundled FlatRender template-creation suite
  (orchestrator + 16 sub-skills + design/motion R&D), mirrors the Gitea AISkills repo.
- services/remotion Root.tsx/templates.tsx: register the 3D templates + Three3DTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:39:25 +03:30
soroush.asadi cb11c177a7 fix(ci): stop pulling Alpine packages from the geo-blocked CDN
CI/CD / CI · Web (tsc) (push) Successful in 1m21s
CI/CD / Deploy · full stack (push) Failing after 15s
The CI server can't reach dl-cdn.alpinelinux.org (TLS error) — only the Nexus
mirror is reachable, and it proxies Docker images, not apk packages.

- frontend: drop `apk add libc6-compat` (vestigial Next.js-template line; the
  deps stage only runs `npm ci` and the build/runtime stages never had it).
- 5 Go services (file/gateway/notification/payment/render): replace
  `apk add ca-certificates tzdata` with copying ca-certificates.crt from the
  golang builder stage + embedding tzdata via `go build -tags timetzdata`.
  No more apk -> no dependency on the Alpine CDN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:08:16 +03:30
soroush.asadi af3c73c560 feat(templates): branded Plyr video player for demos with download protection
CI/CD / CI · Web (tsc) (push) Successful in 1m24s
CI/CD / Deploy · full stack (push) Successful in 8m14s
- BrandedVideoPlayer (plyr-react) plays template demo videos with FlatRender
  blue branding (Plyr CSS vars) and NO download control.
- Download protection: no download button, native controls replaced, underlying
  <video> gets controlsList="nodownload" + disablePictureInPicture, and the
  right-click context menu is blocked.
- Template detail page uses it; gallery hover-preview cards get the same
  nodownload / no-context-menu / no-PiP guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 16:44:55 +03:30
soroush.asadi 4f04f6bf75 feat(render+templates): Remotion engine, 16 branded templates (incl. 3D), seconds pricing, coming-soon
CI/CD / CI · Web (tsc) (push) Successful in 1m21s
CI/CD / Deploy · full stack (push) Failing after 20s
Render engine
- Add Remotion (code-based) as a 2nd render engine alongside After Effects.
  node-agent dispatches on Job.Engine; RunRemotion maps bindings -> --props,
  renders native then ffmpeg-scales to the quality tier (aspect-preserving).
- content.projects.render_engine + render_remotion_comp (migration 32);
  render-svc claim resolves engine and routes (skips .aep for Remotion).
- Admin TemplatesAdmin gains an engine picker + Remotion composition id field.

Template pack (services/remotion)
- 16 branded, Persian (Vazirmatn), color- and text-editable templates, each in
  3 aspects (16:9 / 1:1 / 9:16): LogoMotion, Opener, InstaPromo, YouTubeIntro,
  Slideshow, HappyBirthday, SalePromo, QuoteCard, EventInvite, Countdown,
  GlitterReveal (editable logo image), NowruzGreeting (animated characters),
  and 4 cinematic 3D templates via @remotion/three (Hero3D, Nowruz3D,
  Birthday3D, Promo3D) with reflections + bloom/DOF/vignette.
- scripts/seed_remotion_templates.py seeds containers/projects/scenes/colors.

Pricing
- Rewrite /pricing to the seconds-based model (charge = length x resolution),
  data-driven from /v1/plans, Toman, broker checkout.

Coming-soon
- Persian experimental-build overlay on all pages (launch date + countdown).

Fixes
- middleware matcher bypasses all static asset paths; catalog mapping passes
  cover image + preview video so real thumbnails render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:52:52 +03:30
soroush.asadi b9b91397b0 fix(deploy): configurable postgres host port (avoid 5432 conflict)
CI/CD / CI · Web (tsc) (push) Successful in 1m10s
CI/CD / Deploy · full stack (push) Successful in 3m58s
fr2-postgres failed to start after another local project's postgres grabbed host
port 5432 during downtime. The internal stack always connects via postgres:5432 on
the docker network, so the published host port is only for external tooling — make
it ${PG_HOST_PORT:-5532} to avoid the clash. (Also recovered from a stale bind-mount
where scripts/init-db.sh had become a directory; current compose mounts
deploy/postgres-initdb/.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 01:38:33 +03:30
soroush.asadi 6d79ddb8d1 feat(render): real progress %, ETA, and frequent preview during AE renders
The render page already displayed progress/ETA/preview — but the node agent never
fed real data: aeRender used fake +5%/10s increments, discarded aerender stdout,
and pushed a preview only every 30s. (Plus the deployed agent predated even the
progress-reporting wiring.)

node-agent (aeRender):
- Capture aerender stdout; parse "(N):" current frame + "N frames"/"to N" total.
- Real percentage when total is known (5–90%, headroom for transcode/upload),
  else a smooth time-asymptotic estimate that never sticks — message shows the
  live frame number either way.
- Push a preview frame ~every 8s (was 30s) so the box fills in quickly.

render-svc:
- GET /v1/renders/:id/progress now computes eta_seconds from started_at + progress
  (linear extrapolation) instead of returning null.

frontend:
- Thread eta_seconds → status route → render page; page prefers the server ETA and
  falls back to the client-observed rate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 01:18:54 +03:30
soroush.asadi 23d1fd8fb1 fix(payment): default broker host port to 1607 (8090 was allocated on the server)
CI/CD / CI · Web (tsc) (push) Successful in 1m10s
CI/CD / Deploy · full stack (push) Successful in 3m6s
2026-06-16 07:28:05 +03:30
soroush.asadi 376cdf6a1c feat(payment): route FlatRender plan purchases through the broker
CI/CD / CI · Web (tsc) (push) Successful in 1m10s
CI/CD / Deploy · full stack (push) Failing after 11m4s
- identity: when FlatPay (broker) is configured, InitiateZarinPalAsync
  routes through pay.flatrender.ir instead of calling ZarinPal directly;
  new HandleBrokerCallbackAsync confirms the payment via the broker
  inquiry API (authoritative, not trusting the redirect) and activates
  the plan. New public endpoint GET /v1/payments/callback/broker
  (already public at the gateway via /callback/*). Env-gated — empty
  FlatPay__ApiKey keeps the legacy direct-ZarinPal path.
- broker: deliver webhooks inline on enqueue (best-effort) in addition
  to the retry loop, so clients credit near-instantly (db.GetWebhook +
  goroutine kick).
- compose + ENV_FILE: FlatPay__* for identity (FLATPAY_FLATRENDER_*).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 00:34:45 +03:30
soroush.asadi ec51e87d2d feat(payment): standalone ZarinPal broker on pay.flatrender.ir
A generic multi-client payment gateway so FlatRender, meezi.ir and
bargevasat.ir can all pay through ZarinPal's single verified callback
domain (pay.flatrender.ir).

New Go service services/payment (clones the notification skeleton +
vendored deps):
- migration 31_payment_broker.sql — `payment` schema: client_apps,
  transactions, webhook_deliveries.
- ZarinPal v4 client ported from the proven identity PaymentService
  (request.json -> StartPay -> verify.json; codes 100/101).
- client API: POST /v1/pay/request + /v1/pay/inquiry, authed by
  X-Api-Key + HMAC body signature; GET /callback/zarinpal (the single
  verified endpoint) verifies, then 302s the user back to the site's
  return_url (signed) and fires a signed, retried webhook.
- per-client ZarinPal merchant override (default = shared merchant);
  amount stored canonically in Rial, unit to ZarinPal env-configurable.
- admin API /v1/admin/* (FlatRender admin JWT): client-app CRUD +
  key issue/rotate + transactions list.

Deploy wiring: payment-svc in docker-compose.v2.yml (host port 1607),
pay.flatrender.ir server block in mirror-nginx conf, ENV_FILE +
README updates (cert SAN + manual migration note).

Admin UI: src/components/admin/PaymentsAdmin.tsx (client apps with
one-time key reveal + rotate, transactions table) + /admin/payments
page + nav link + fa/en strings; pay-admin proxy route to payment-svc.

Docs/SDK: deploy/PAYMENTS.md (integration contract) + deploy/sdk/flatpay.js
(zero-dep Node client + webhook verifier) for meezi/any site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:59:54 +03:30
soroush.asadi 896ce3dfa9 feat(render): plan-gate quality tiers — free=360p watermarked, paid=all
CI/CD / CI · Web (tsc) (push) Successful in 1m8s
CI/CD / Deploy · full stack (push) Successful in 3m8s
Monetization gate for the template render flow:
- render-quality.ts: single source of truth (free -> 360p only +
  watermark; pro/business -> 540p..4K, no watermark).
- /api/render: server-authoritative gate — rejects >360p for free
  users with 403 quality_locked; passes a watermark flag through
  createRenderJob -> /v1/renders (render-svc passthrough, wired later).
- /api/render/limits: GET endpoint exposing the user's allowed tiers
  and watermark state to the studio.
- render page: locks higher tiers for free users (dashed + lock badge,
  click routes to /pricing), clamps the selected resolution down,
  shows the "free = 360p + watermark, upgrade" notice, and handles the
  403 quality_locked response.

AI-video "no free preview" rule is a future hook (no AI gen yet).
Watermark rendering (ffmpeg drawtext on the node) is a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:17:25 +03:30
soroush.asadi 468ae2ae97 docs(deploy): fix init-script path + add stale-volume reset note
CI/CD / CI · Web (tsc) (push) Successful in 1m10s
CI/CD / Deploy · full stack (push) Successful in 3m10s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:48:51 +03:30
soroush.asadi 1106c03feb docs(deploy): sync nginx/cert/DNS docs with the real working setup
CI/CD / CI · Web (tsc) (push) Successful in 1m12s
CI/CD / Deploy · full stack (push) Has been cancelled
Reflect what the live deploy actually required:
- cert must be NESTED under an already-mounted dir (/etc/ssl/soroushasadi/flatrender/)
  — mirror-nginx mounts cert dirs individually, so a fresh /etc/ssl/flatrender is
  invisible in the container.
- after a sed -i edit of the bind-mounted nginx.conf, restart (not reload) — inode swap.
- DNS: box is behind NAT (171.22.25.73 private; public via edge/CDN 185.239.1.100 or
  direct 31.171.101.x) — register the domain the same way the other sites enter.
- local SNI test command to verify routing bypassing DNS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:46:32 +03:30
soroush.asadi 514cd3705f ci(deploy): mount postgres init as a DIRECTORY (fix 'Is a directory')
CI/CD / CI · Web (tsc) (push) Successful in 1m8s
CI/CD / Deploy · full stack (push) Successful in 2m50s
The single-file bind mount ./scripts/init-db.sh left a stale empty dir in the
reused act_runner workspace → mounted as a directory → migrations never ran →
empty schemas → backend 28P01/connection failures. Move the init script to
deploy/postgres-initdb/00-init.sh and mount the whole DIR at
/docker-entrypoint-initdb.d (robust, like the migrations dir). Deploy checkout
now 'git clean -ffd' to purge stale workspace dirs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 21:39:15 +03:30
soroush.asadi c67d746004 ci: redeploy after resetting stale pgdata volume (fresh init + migrations)
CI/CD / CI · Web (tsc) (push) Successful in 1m8s
CI/CD / Deploy · full stack (push) Failing after 20s
2026-06-12 21:17:34 +03:30
soroush.asadi 072ac78b77 ci(deploy): pull minio -cpuv1 from Liara docker mirror (baseline CPU)
CI/CD / CI · Web (tsc) (push) Successful in 1m8s
CI/CD / Deploy · full stack (push) Failing after 20s
soroushasadi mirror only has cached x86-64-v2 minio builds; Liara
(docker-mirror.liara.ir) back-fills the -cpuv1 variants. Confirmed pullable +
runs on the server CPU. MINIO_REGISTRY defaults to Liara, MINIO_IMAGE_TAG to a
real -cpuv1 release; dev overrides both to plain Docker Hub :latest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 21:09:26 +03:30
soroush.asadi 0fefedbb86 ci(deploy): use minio -cpuv1 image for baseline-CPU server
CI/CD / CI · Web (tsc) (push) Successful in 1m8s
CI/CD / Deploy · full stack (push) Failing after 1m6s
The server CPU lacks x86-64-v2 (started being required at minio RELEASE.2023-11-01).
MinIO publishes '-cpuv1' variants compiled for plain x86-64. Pin to
RELEASE.2025-09-07T16-13-09Z-cpuv1 — same release as local dev, runs on the old CPU.
Override via MINIO_IMAGE_TAG (dev = latest).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 20:39:19 +03:30
soroush.asadi 56e2202b5b ci(deploy): pin minio to pre-x86-64-v2 release (baseline CPU)
CI/CD / CI · Web (tsc) (push) Successful in 1m9s
CI/CD / Deploy · full stack (push) Failing after 1m4s
Server VPS CPU lacks x86-64-v2; newer minio:latest is built for it and crash-loops
with 'Fatal glibc error: CPU does not support x86-64-v2'. Pin to a 2024 release that
runs on baseline x86-64 (override with MINIO_IMAGE_TAG if a different tag is on the
mirror). Local dev stays on :latest via .env.v2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:32:55 +03:30
soroush.asadi 21a203b012 ci(deploy): fix minio healthcheck for newer image (curl + mc fallback)
CI/CD / CI · Web (tsc) (push) Successful in 1m10s
CI/CD / Deploy · full stack (push) Failing after 19s
Server's mirror minio:latest is newer than dev's cached RELEASE.2025-09-07 and
dropped the bundled mc client, so 'mc ready local' failed → fr2-minio unhealthy →
up aborted. Switch to MinIO's curl liveness endpoint with an mc fallback so it
works across image versions; bump start_period 10s→20s, retries 5→8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 17:09:00 +03:30
soroush.asadi b34904549f ci(build): pull golang base image from kargadan mirror
CI/CD / CI · Web (tsc) (push) Successful in 1m8s
CI/CD / Deploy · full stack (push) Failing after 3m26s
mirror.soroushasadi.com serves only cached images (node:20 resolved, golang:1.25
was 'not found' — too new to be cached, upstream can't back-fill). Point the Go
services' golang:1.25-alpine base at mirror.kargadan.ir per infra owner; alpine/
busybox/node/postgres/minio stay on soroushasadi (cached). GOPROXY already kargadan.
2026-06-12 16:47:31 +03:30
soroush.asadi ee2a6b9b60 ci(build): pull Docker Hub base images via Nexus mirror + kargadan GOPROXY
CI/CD / CI · Web (tsc) (push) Successful in 1m8s
CI/CD / Deploy · full stack (push) Failing after 7s
Docker Hub blocks Iran (403) on the BUILD base images too (golang/alpine/busybox/
node) once they fall out of cache. Prefix every Docker Hub FROM/COPY --from with
mirror.soroushasadi.com/ (MCR dotnet images are reachable, left as-is). Go builders
also set GOPROXY=mirror.kargadan.ir/repository/go-group/ + GOSUMDB=off so any module/
toolchain fetch avoids the geo-blocked proxy.golang.org.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 16:24:38 +03:30
soroush.asadi 18cdf507f0 ci(deploy): pull infra images (postgres/minio/caddy) via Nexus mirror
CI/CD / CI · Web (tsc) (push) Successful in 1m6s
CI/CD / Deploy · full stack (push) Failing after 6s
Docker Hub blocks Iran IPs (403), so 'docker compose up' couldn't pull the base
infra images on the server even though all service images built fine through the
mirror. Prefix them with ${INFRA_REGISTRY:-mirror.soroushasadi.com/} so they pull
through Nexus by default; set INFRA_REGISTRY= to use plain Docker Hub names.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 15:23:54 +03:30
soroush.asadi cc9910451d ci: trigger deploy (ENV_FILE secret updated for nginx model)
CI/CD / CI · Web (tsc) (push) Successful in 1m8s
CI/CD / Deploy · full stack (push) Failing after 20m37s
2026-06-12 14:47:30 +03:30
soroush.asadi 12588b65df ci(deploy): integrate with mirror-nginx instead of Caddy
CI/CD / CI · Web (tsc) (push) Successful in 1m6s
CI/CD / Deploy · full stack (push) Has been cancelled
The server's central mirror-nginx already owns 80/443 + manages TLS, so FlatRender
can't run its own Caddy there. Adapt the deploy to the host-port + reverse-proxy model:

- compose: Caddy moved behind `profiles: [edge]` (not started by default); frontend/
  gateway/minio host ports are now EDGE_BIND + FRONTEND_PORT/GATEWAY_PORT/MINIO_PORT
  (so they can avoid Gitea's :3000 etc.); postgres/render stay on HOST_BIND loopback.
- deploy/ENV_FILE.production.example: nginx model, pre-filled for flatrender.ir,
  host ports 1600/1605/1610, no Caddy/ACME vars.
- deploy/mirror-nginx-flatrender.conf: ready-to-paste server blocks routing
  flatrender.ir / api / storage → 171.22.25.73:{1600,1605,1610}.
- deploy/README.md: nginx integration + cert step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 14:42:14 +03:30
soroush.asadi 127f40e1c1 ci: Gitea CI/CD pipeline + server deploy (Nexus mirror, Caddy HTTPS)
CI/CD / CI · Web (tsc) (push) Successful in 1m8s
CI/CD / Deploy · full stack (push) Failing after 1m41s
- .gitea/workflows/ci-cd.yml: frontend tsc check → self-hosted deploy job that
  builds the full compose stack and brings it up behind Caddy. Locks
  COMPOSE_PROJECT_NAME=flatrender (stable volumes), backs up the DB before each
  deploy, health-waits gateway+frontend, no `down -v`.
- Route all package installs through mirror.soroushasadi.com:
  frontend Dockerfile npm registry → NPM_REGISTRY build arg (Nexus default);
  3× NuGet.Config (content/identity/studio) → HTTPS nuget-group (were a bare IP).
- Harden host ports: ${HOST_BIND:-0.0.0.0} prefix on postgres/minio/render/gateway/
  frontend so prod (HOST_BIND=127.0.0.1) keeps them off the public internet — only
  Caddy 80/443 is public. Dev (unset → 0.0.0.0) unchanged.
- render-svc MINIO_USE_SSL now env-driven (MINIO_HOST_USE_SSL) for HTTPS storage domain.
- deploy/ENV_FILE.production.example (the Gitea secret template) + deploy/README.md
  (one-time setup + go-live checklist).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:29:09 +03:30
soroush.asadi 61ba526122 feat(admin): render-engine kill switch (block renders + show message)
Lets an admin disable rendering when no render node is available — users can't
start new renders and see a localized "service unavailable until <date>" message.

- Admin → فارم رندر → موتور رندر (RenderEngineAdmin): on/off toggle + fa/en message
  + optional Jalali "until" date; saved as one `render_service` Website Setting
  (jsonb) via /v1/settings — no backend change, no migration.
- lib/render-service.ts: fetchRenderServiceStatus (fail-open) + renderServiceMessage
  (locale + appends the date).
- Enforcement: POST /api/render returns 503 {code:render_disabled, messages} when off;
  studio render page reads GET /api/render/service on mount → disables "شروع رندر"
  and shows the banner, and handles the 503 on click.
- i18n: appAdminLayout.renderEngine (fa+en, parity 1045/1045). tsc + next build clean.
  Verified: disabled setting → /api/render/service returns enabled:false.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 09:47:42 +03:30
soroush.asadi a1414f06f6 feat(studio): phone editing for Video Studio + Image Editor (remove desktop gate)
Replaces the "desktop only" gate on phones with real mobile editing layouts.

Shared:
- BottomSheet (mobile slide-up panel) hosting the desktop side-docks on phones.
- Side panels made width-fluid (w-full on mobile, fixed on md+): StudioSidebarContent,
  ImageEditorRightPanel.

Video Studio (VideoStudioMobileLayout):
- Canvas fills the viewport; the vertical tool dock becomes a scrollable bottom bar;
  each tool's panel + the timeline open as bottom sheets. Exported MAIN_DOCK_ITEMS.

Image Editor (ImageEditorMobileLayout):
- Canvas fills the viewport; toolbar → scrollable bottom bar; Adjust/Filters/Layers
  panel + shape picker open as bottom sheets. Exported IMAGE_TOOLS/IMAGE_SHAPES.
- Touch editing: Stage now handles onTouchStart/Move/End (draw, select, move) with
  touch-action:none; draw-tool stroke works with a finger. Pointer handlers widened
  to MouseEvent | TouchEvent.

i18n: added timeline/preview/panels keys (fa+en, parity verified). Full next build +
tsc clean. (Studio is auth-gated — verify editing on a device.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 09:05:44 +03:30
soroush.asadi 05400947e4 feat(responsive): mobile fixes for pricing, dashboard, admin, templates, hero
- PricingCompareTable: wide 4-col table is hidden on mobile; new tab-per-plan card
  view (Lite/Pro/Business) so pricing fits a phone. Extracted PricingCompareValueInline.
- Dashboard: sidebar becomes an off-canvas drawer on mobile (hamburger top bar +
  overlay, closes on navigation) via DashboardSidebarDrawer; static column on lg+.
  RTL/LTR safe (max-lg: transforms avoid the lg:/rtl: specificity trap).
- AdminResource: search/add row stacks on mobile (w-full sm:w-52), tables scroll
  horizontally (overflow-x-auto + min-w) instead of clipping.
- Templates: added a mobile category chip row (lg:hidden) since the category
  sidebar is desktop-only; exported VIDEO_SIDEBAR_CATEGORY_IDS.
- Hero: CTAs full-width on mobile, auto width on sm+.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 08:23:10 +03:30
soroush.asadi 1ebde6b15c feat(admin): seed colour presets with a placeholder per shared colour
A colour preset is a setup for ALL of a project's shared colours, but the editor
forced the admin to add each colour one by one. Now:

- "+ پریست جدید" pre-fills one item per shared colour (seeded from each colour's
  default), so a new preset is a complete colour setup out of the box.
- New "+ همهٔ رنگ‌های مشترک" button back-fills placeholders for any shared colours
  missing from an existing preset (or after new shared colours are added).

Frontend-only change in ProjectScenes.tsx PresetsTab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 07:48:30 +03:30
soroush.asadi b3637cf839 feat(home): admin-managed homepage section manager (toggle/reorder/edit)
The homepage is now driven by a `home_layout` Website Setting (jsonb) instead of a
hardcoded section stack — zero backend changes, no migration.

- lib/home-layout.ts: section catalog + saved-layout merge + locale-aware config
  reader (`<field>_fa`/`<field>_en`) + public fetchHomeLayout() (falls back to
  defaults when unset/unreachable).
- app/[locale]/page.tsx: renders ordered, enabled sections from the layout, passing
  per-section content overrides.
- sections (Hero/Products/Templates/HowItWorks/Pricing/Testimonials/FAQ): accept an
  optional `config` prop overriding heading/subtitle/CTA, locale-aware, default-safe.
- new HomeSlides + HomeEvents sections render the previously-orphaned admin Slides
  (/v1/slides) and Home Events (/v1/home-events) data.
- admin: HomeSectionsManager (toggle on/off, ↑/↓ reorder, per-section FA/EN content
  editor) at /admin/home, saved via the existing /v1/settings upsert; nav item + i18n.

Verified: a saved layout overrides Hero/Pricing headings and reorders sections;
removing it reverts to the default homepage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 01:21:44 +03:30
soroush.asadi 1f6c35eb7c chore(content): seed demo blog + learn posts and CMS page rows
Build backend images / build content-svc (push) Failing after 1m16s
Build backend images / build file-svc (push) Failing after 53s
Build backend images / build gateway (push) Failing after 53s
Build backend images / build identity-svc (push) Failing after 1m7s
Build backend images / build notification-svc (push) Failing after 59s
Build backend images / build render-svc (push) Failing after 47s
Build backend images / build studio-svc (push) Failing after 1m2s
Idempotent seed (services/content/migrations/002_seed_blog_learn_pages.sql):
one Blog article, one Learn tutorial, and the 7 static Page rows
(about/contact/careers/privacy/terms/cookies/help) so the new public sections
render with real content and the admin "Pages" section has editable rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:13:48 +03:30
soroush.asadi c92de06c28 feat(content): public Blog + Learn sections and static CMS pages (full-stack)
Adds the missing public-facing content pages and their admin authoring, all
powered by the existing content-svc Blog entity discriminated by `kind`.

Backend (content-svc):
- BlogKind enum += Learn, Page (reuses Blog CRUD/SEO/slug/publish for all three).
- SQL migration services/content/migrations/001_blog_kind_learn_page.sql
  (ALTER TYPE content.blog_kind ADD VALUE 'Learn','Page').

Frontend (public, Next.js):
- lib/content-api.ts: fetchArticles(kind) / fetchArticle(slug) / fetchPage(slug)
  with safe empty/null fallbacks.
- components/content: article-ui (card/list/detail + RTL prose), CmsPageContent,
  CmsRoute (admin-authored page or localized built-in fallback copy).
- Routes: /blog, /blog/[slug], /learn, /learn/[slug] and static pages
  /about /contact /careers /privacy /terms /cookies /help.
- Navbar "tutorials" → /learn; all footer links now resolve.

Admin:
- AdminResource: new `fixedValues` option (injects kind on create/update).
- learnConfig (kind=Learn) + pagesConfig (kind=Page) reuse the /v1/blogs endpoint;
  /admin/learn + /admin/pages routes + nav items.

i18n: blog, learn and 7 *Page namespaces added to both fa.json and en.json
(verified key parity); admin nav labels learn/pages. Frontend tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:43:25 +03:30
soroush.asadi 6cf8716d7e feat(render): node-agent AE snapshot runner (Epic C2) + colour render-binding (Epic B)
Build backend images / build content-svc (push) Failing after 13s
Build backend images / build file-svc (push) Failing after 53s
Build backend images / build gateway (push) Failing after 1m22s
Build backend images / build identity-svc (push) Failing after 19s
Build backend images / build notification-svc (push) Failing after 21s
Build backend images / build render-svc (push) Failing after 20s
Build backend images / build studio-svc (push) Failing after 1m6s
C2 — real-AE scene snapshots on the node:
- node-agent: runner/snapshot.go RunSnapshot (aerender -comp <key> -s f -e f
  → findRenderedOutput → ffmpeg -frames:v 1 PNG); client ClaimSnapshot /
  GetSnapshotUploadURL / ReportSnapshotResult / ReportSnapshotFail; snapshotLoop +
  pollSnapshotOnce mirroring the scan loop (reuses the AE-exclusive lock).
- render-svc: GetSnapshotJobMeta + UploadURL handler presigns a PUT to the
  public-read user-uploads bucket at snapshots/{project}/{scene}.png and returns a
  permanent public_url (not the time-limited export presign); MINIO_UPLOAD_BUCKET +
  MINIO_PUBLIC_URL config + compose env + /snapshot/:id/upload-url route.

Epic B — bind edited colours into the render:
- render-svc GetRenderBindings UNIONs studio.saved_shared_colors +
  saved_scene_colors (type 'color') so the node writes them before render.
- node-agent binder.go routes type:"color" bindings into the bind-spec colors[]
  array that bind.jsx already applies to the frshare colour layers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 18:08:43 +03:30
soroush.asadi 8488acb115 feat(snapshots): AE scene-snapshot pipeline + admin trigger (Epic C, C1)
Build backend images / build content-svc (push) Failing after 31s
Build backend images / build file-svc (push) Failing after 30s
Build backend images / build gateway (push) Failing after 30s
Build backend images / build identity-svc (push) Failing after 30s
Build backend images / build notification-svc (push) Failing after 31s
Build backend images / build render-svc (push) Failing after 31s
Build backend images / build studio-svc (push) Failing after 31s
Per-scene preview thumbnails for templates. Admin clicks "ساخت پیش‌نمایش
صحنه‌ها" → one single-frame AE render per scene → content.scenes.snapshot_url
→ shown as a thumbnail in the admin scene list (and available to the studio).

- migration 30_render_snapshot_jobs.sql: render.snapshot_jobs (queued|running|
  done|error, per scene, image_url).
- render-svc: db/snapshotjobs.go (EnqueueSceneSnapshots, List, Claim, SetResult
  -> writes content.scenes.snapshot_url cross-schema, SetError); handlers/
  snapshotjobs.go (admin POST/GET /v1/scene-snapshots/:project_id + node-facing
  internal claim/result/fail); main.go routes; gateway route.
- devworker: RunSnapshots — fulfils snapshot jobs with a generated placeholder
  PNG (data: URL, scene-key-tinted) so the flow is verifiable without an AE node.
  Gated by RENDER_DEV_SNAPSHOTS (default off; never hijacks real render jobs).
- admin UI: ProjectScenes "generate snapshots" button (enqueue + poll + reload)
  and a thumbnail (snapshot_url || image) per scene row.

Verified e2e via the dev mock: enqueue -> jobs run -> content.scenes.snapshot_url
populated -> scenes API returns it -> admin renders the thumbnail.

Remaining (C2): node-agent real-AE runner — claim snapshot, aerender -s0 -e0 ->
ffmpeg still -> upload to a PERMANENT URL (mirror file-svc, not the time-limited
export presign) -> post result. Needs a live AE node to build + verify.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 09:54:42 +03:30
soroush.asadi 93411da462 feat(presets): pre-fill the user's project from preset values (A4)
Build backend images / build content-svc (push) Failing after 30s
Build backend images / build file-svc (push) Failing after 30s
Build backend images / build gateway (push) Failing after 31s
Build backend images / build identity-svc (push) Failing after 31s
Build backend images / build notification-svc (push) Failing after 30s
Build backend images / build render-svc (push) Failing after 30s
Build backend images / build studio-svc (push) Failing after 31s
"Use this example" now actually fills the new project, not just stores a ref.

- studio-svc: CreateProjectAsync applies the chosen preset story's saved values
  after the template-graph copy. ApplyPresetValuesAsync reads
  content.preset_stories.scenes_spa = { values: {contentKey:value},
  colors: {elementKey:hex} } and overlays them onto studio.saved_scene_contents
  (by key) + saved_shared_colors/saved_scene_colors (by element_key, is_selected).
  Keys are globally unique (AE convention) so key-only matching is safe.
  Malformed scenes_spa is skipped (defaults kept). Runs in the create tx.
- admin UI: ProjectPresetStories raw scenes_spa textarea replaced with a
  structured PresetValueEditor — loads each preset scene's content elements +
  the project's shared colours and renders a type-aware input per item
  (text/textarea/number, media→upload, fill/color→colour). Serializes to
  scenes_spa {values,colors}; parses it back on edit.

Verified e2e: authored a preset with values+colour → used it → the new
project's saved_scene_contents + saved_shared_colors carry the preset values
(which the B2 render binder then writes into AE).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 06:49:22 +03:30
soroush.asadi ab568c0663 feat(presets): admin preset stories (premade example videos) end-to-end
Build backend images / build content-svc (push) Failing after 31s
Build backend images / build file-svc (push) Failing after 31s
Build backend images / build gateway (push) Failing after 31s
Build backend images / build identity-svc (push) Failing after 30s
Build backend images / build notification-svc (push) Failing after 30s
Build backend images / build render-svc (push) Failing after 31s
Build backend images / build studio-svc (push) Failing after 31s
Epic A — admins author premade example videos per template; users pick one
on the template detail page to start a pre-filled project.

Backend (content-svc):
- PresetStory DTOs + PresetStoryService (admin CRUD + public published-only
  filter via role check + soft-delete) + PresetStoriesController (/v1/preset-stories)
- DI registration; gateway route /v1/preset-stories (optionalAuth, public read)

Frontend:
- ProjectPresetStories admin authoring UI (name/description/demo upload/published/
  sort + scene picker with order+duration + advanced scenes_spa); «ویدیوهای نمونه»
  button + modal in ProjectsAdmin
- TemplateDetailExamples renders real published stories (image/video preview,
  hover → "use this example" → creates a pre-bound project), falls back to
  placeholders when none; selected aspect's variant id keys the fetch
- public /api/preset-stories route; preset_story_id plumbed through
  createProjectFromTemplate + projects POST route; usePreset i18n (fa+en)

Verified: full CRUD via gateway (public hides unpublished); creating a project
with presetStoryId persists selected_preset_story_id on the saved project.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 05:24:14 +03:30
soroush.asadi 23624f7db9 feat(admin): auto-fill new scene length from the AEP
Build backend images / build content-svc (push) Failing after 2m22s
Build backend images / build file-svc (push) Failing after 1m49s
Build backend images / build gateway (push) Failing after 1m6s
Build backend images / build identity-svc (push) Failing after 59s
Build backend images / build notification-svc (push) Failing after 50s
Build backend images / build render-svc (push) Failing after 54s
Build backend images / build studio-svc (push) Failing after 55s
When adding a scene in the admin scene editor, its duration is now pulled
from the After Effects project automatically (scene key = comp name).

frontend (ProjectScenes):
- the new/edit scene form quick-scans the project .aep for comp names +
  durations and offers a "pick composition" dropdown that fills key, title
  and default duration in one click
- the key field gains a datalist of comp names; typing a key that matches a
  comp auto-fills the length (only when empty, never clobbering a manual value)
- an inline "AEP duration: Ns — insert" hint next to the duration field
- graceful states when no .aep is uploaded / scan fails

render-svc (aep.durationFromCdta): fix the composition-duration offset.
The duration rational lives at cdta offset 44 (numerator) / 48 (time base)
on AE 2024/2026, not 32/36 (previous guess) or 40/44 (boltframe reference,
older builds). Made it version-robust: read the time base from the framerate
dividend (offsets 4/8) and accept whichever offset places the time base right
after the numerator. Verified against a real project — render comp frfinal
parses to 15.02s (matches project_duration_sec 15.00).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:22:39 +03:30
soroush.asadi da3f92fbe8 feat(admin): full legacy controller set in scene-inputs editor
Build backend images / build content-svc (push) Failing after 2m19s
Build backend images / build file-svc (push) Failing after 1m18s
Build backend images / build gateway (push) Failing after 2m38s
Build backend images / build identity-svc (push) Failing after 6m44s
Build backend images / build notification-svc (push) Failing after 1m0s
Build backend images / build render-svc (push) Failing after 58s
Build backend images / build studio-svc (push) Failing after 59s
The V2 scene-inputs editor only exposed ~15 of the content model's
~40 fields. Restore full parity with the legacy admin controller.

content-svc:
- SaveContentElementRequest + ContentElementResponse widened to the
  complete field set (text/font, direction/RTL, media, advanced, DP)
- ApplyElement / ToElementResponse map every field 1:1
  (Enum.TryParse for JustifyKind + AiInputType)

frontend (SceneInputsEditor):
- common fields up top; an "advanced" toggle reveals grouped sections:
  Text and Font, Direction (RTL/LTR), Media, Advanced, Design-Presets (DP)
- editing an element loads the full field set; rows show font/hidden badges
- nullable numbers sent as null, enums as named values (snake_case body)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:38:54 +03:30
soroush.asadi bf6c04aba3 fix(render): node reports progress → moving bar + ETA (was stuck 0%/Preparing)
Build backend images / build content-svc (push) Failing after 58s
Build backend images / build file-svc (push) Failing after 45s
Build backend images / build gateway (push) Failing after 52s
Build backend images / build identity-svc (push) Failing after 54s
Build backend images / build notification-svc (push) Failing after 56s
Build backend images / build render-svc (push) Failing after 56s
Build backend images / build studio-svc (push) Failing after 49s
The node's onProgress callback only LOGGED — it never POSTed, so render_progress stayed
0 and step stayed Preparing (no bar, no ETA). Add render-svc POST
/v1/internal/render/jobs/{id}/progress (UpdateJobProgress: set render_progress + bump
step Queued/Preparing→Rendering once >0) + client UpdateProgress + wire onProgress to
post it (8s best-effort timeout, AE-CPU/DB-starvation tolerant). Preview already posts;
real-frame preview is epic C.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 07:51:01 +03:30
soroush.asadi 2879198dec fix(studio): accept numeric scene/content ids (template inputs now load)
THE bug behind 'nothing changed': parseScene required a STRING id, but the V2
relational scene assembly serializes numeric ids (scene 23, content 136) — so every
template scene was rejected → 0 scenes → studio fell back to its default 2-layer
title/subtitle. Coerce numeric ids to string. Verified: insta-promo project now
parses 1 scene / 6 layers (frl_c1t1-t4 text + frl_c1m1/m2 media).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 07:27:53 +03:30
soroush.asadi 04ca431fbc docs(handoff): #36/#40/#41/#42 done; remaining = epic A/C + B follow-ups
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 05:42:27 +03:30
soroush.asadi 9e16638b2d feat(#40): Persian (Jalali) date pickers in admin
Add react-multi-date-picker + a reusable PersianDateInput (displays Jalali, stores
Gregorian ISO so DTOs are unchanged). Replace admin date inputs: UserProfileEdit
birthdate + CRM date-range filters.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 05:39:46 +03:30
soroush.asadi fca6bcac53 feat(#41): admin/renders pagination + user name link + output + project name
Build backend images / build content-svc (push) Failing after 1m1s
Build backend images / build file-svc (push) Failing after 56s
Build backend images / build gateway (push) Failing after 49s
Build backend images / build identity-svc (push) Failing after 50s
Build backend images / build notification-svc (push) Failing after 49s
Build backend images / build render-svc (push) Failing after 1m2s
Build backend images / build studio-svc (push) Failing after 47s
render-svc admin-renders enriches jobs with user_name/email (cross-schema lookup to
identity.users). Page adds prev/next pagination (page_size 30). Table adds User column
(name → /admin/users?q=email) and Output column (export → /admin/exports), and shows
project_name. Verified: 21 jobs, paginated, names resolved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 05:24:47 +03:30
soroush.asadi d56bcf1b23 feat(#42): FIX projects can't add scenes (studio + admin)
Build backend images / build content-svc (push) Failing after 57s
Build backend images / build file-svc (push) Failing after 56s
Build backend images / build gateway (push) Failing after 54s
Build backend images / build identity-svc (push) Failing after 1m0s
Build backend images / build notification-svc (push) Failing after 47s
Build backend images / build render-svc (push) Failing after 53s
Build backend images / build studio-svc (push) Failing after 57s
Template copy now carries choose_mode from the content project → studio store gets
chooseMode; AddSceneMenu returns null for FIX/MusicVisualizer. Admin ProjectScenes
hides '+ صحنهٔ جدید' (shows an 'scenes defined in AE' note) for fixed modes. Verified
choose_mode=FIX flows end-to-end. (Visible admin nav link added earlier.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 05:03:46 +03:30
soroush.asadi bccebbd006 feat(render #36): real per-tier output height (360/540/720/1080/4K)
Build backend images / build content-svc (push) Failing after 50s
Build backend images / build file-svc (push) Failing after 57s
Build backend images / build gateway (push) Failing after 50s
Build backend images / build identity-svc (push) Failing after 58s
Build backend images / build notification-svc (push) Failing after 48s
Build backend images / build render-svc (push) Failing after 53s
Build backend images / build studio-svc (push) Failing after 1m2s
r_height was hardcoded 1080. render-svc now derives r_height from the resolution
(ResolutionHeight) on job create; node-agent ffmpeg downscales to the tier height
(scale=-2:H). Quality picker now actually changes output size.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 04:35:14 +03:30
soroush.asadi c6766b18a1 docs(handoff): phase B done (B1+B2) — edit→render binding
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 01:26:15 +03:30
soroush.asadi 47a4ced973 feat(render B2): render binder writes user edits into AE before render
Build backend images / build content-svc (push) Failing after 52s
Build backend images / build file-svc (push) Failing after 56s
Build backend images / build gateway (push) Failing after 53s
Build backend images / build identity-svc (push) Failing after 1m29s
Build backend images / build notification-svc (push) Failing after 1m38s
Build backend images / build render-svc (push) Failing after 1m53s
Build backend images / build studio-svc (push) Failing after 56s
Edits previously never reached the MP4 (the node rendered template defaults). Now:
- render-svc claim includes the saved input values as bindings (GetRenderBindings →
  saved_scene_contents with non-empty value).
- node-agent: new binder.go emits a JSON bind-spec + downloads media locally, runs the
  pre-existing data-driven bind.jsx via afterfx (sets text layers' Source Text, replaces
  media footage), saves a bound.aep next to the template, then aerender renders THAT.
- 12-min timeout + fresh-AE + done-marker polling (mirrors scan). Non-fatal: on bind
  failure the job still renders template defaults.

Verified binding data flows (edited frl_c1t1/frl_c1t2 → claim bindings). Live MP4
verification needs the updated node-agent.exe re-run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 01:22:20 +03:30
soroush.asadi a69bc62724 feat(studio B1): persist input edits to content elements (render-binding foundation)
Build backend images / build content-svc (push) Failing after 1m3s
Build backend images / build file-svc (push) Failing after 1m5s
Build backend images / build gateway (push) Failing after 1m0s
Build backend images / build identity-svc (push) Failing after 1m8s
Build backend images / build notification-svc (push) Failing after 57s
Build backend images / build render-svc (push) Failing after 1m6s
Build backend images / build studio-svc (push) Failing after 1m3s
Studio edits previously went only to edit_state; the render binds saved_scene_contents,
so edits never reached the MP4. Add studio-svc PATCH /v1/saved-projects/{id}/contents
(update saved_scene_contents.value/value_file_id by content key, ExecuteSqlInterpolated
for null-safe params) + Next /api/projects/[id]/contents route + persistence hook pushes
edited values (from bridged c-<key> layers) alongside the scene_data save. Verified
text persists incl. UTF-8 Persian (9 chars/17 bytes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:53:17 +03:30
soroush.asadi d4b1fbd9e6 docs(handoff): next-up = studio↔template binding epic, start phase B (edit→render)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:09:35 +03:30
soroush.asadi 4d32e77f9a fix(studio): show ALL template inputs (bridge V2 content-elements → layers)
The studio parser required scene.layers; a template-created project's scene_data
carries content-elements (scene.contents), so every scene parsed to null and the
editor fell back to the default 2-layer title/subtitle scene. Now parseScene bridges
contents → editable layers (Text→text, Media→image), so all of a scene's inputs
appear (e.g. c1 → 6: 4 text + 2 media). Scene name/duration also read V2 fields.

Remaining studio↔template epic (separate): edit→content-element→AE-render binding,
real AE scene-preview thumbnails, and FIX-mode (hide add-scene).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 23:57:44 +03:30
soroush.asadi 99f0e9eab1 fix(home): 'use template' card opens template detail page (was → /templates)
Homepage TemplateGallery card called the old Supabase createVideoProject which
failed → fell back to /templates. Now it routes to /templates/{slug} (detail),
where the user picks aspect/style and starts the project. Removed dead handler/imports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 23:44:23 +03:30
soroush.asadi 1aca734343 feat(admin): scene-inputs editor in /admin/projects scene list (reuse SceneInputsEditor)
The per-scene inputs (content elements) editor existed only on /admin/templates
(SceneColorEditor). /admin/projects → «صحنه‌ها» used the older ProjectScenes which
had no inputs panel, so admins couldn't see/edit a scene's inputs there. Export
SceneInputsEditor and add an «ورودی‌ها» expander per scene row in ProjectScenes
(GET/POST/PUT/DELETE /v1/scene-elements, 15 element types). Verified c1 → 6 inputs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 23:33:06 +03:30
soroush.asadi 8b716a173c fix(images): allow MinIO host in next/image remotePatterns (broken uploads)
next.config had no images.remotePatterns, so the optimizer rejected every remote
URL with HTTP 400 → all MinIO-hosted images (avatars, template art) showed broken.
Add remotePatterns derived from NEXT_PUBLIC_MINIO_URL + dev hosts (172.28.144.1/
localhost/minio :9000). Verified /_next/image → 200 image/jpeg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 23:19:39 +03:30
soroush.asadi 36d70332f0 feat(nav): visible Admin Panel link in navbar for admins
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 23:10:51 +03:30
soroush.asadi d7a74daa96 feat(render): 5 quality tiers (360p–4K) + ETA on render page; 24h session
- Render page: resolution picker now 360p/540p/720p/1080p/4K; live ETA
  ('تقریباً … باقی مانده') computed from progress rate; preview+progress bar already wired.
- render-schemas: resolution enum + RESOLUTION_DIMENSIONS add 360p/540p.
- render-jobs.mapQuality: 5-tier → render_quality (Low/Medium/High/Full).
- Session: Jwt__AccessTokenMinutes=1440 (24h) via compose so logins persist
  (refresh middleware + 30d refresh token back it up).

(Real per-tier output height still pending: render-svc r_height is hardcoded 1080 →
node ffmpeg scale — next.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 23:04:32 +03:30
soroush.asadi ad8796a25d feat(admin): edit any user's full profile (PATCH/POST /v1/users/{id} admin + UI modal)
Build backend images / build content-svc (push) Failing after 1m47s
Build backend images / build file-svc (push) Failing after 5m54s
Build backend images / build gateway (push) Failing after 2m8s
Build backend images / build identity-svc (push) Failing after 3m32s
Build backend images / build notification-svc (push) Failing after 12s
Build backend images / build render-svc (push) Failing after 10m27s
Build backend images / build studio-svc (push) Failing after 10s
Identity: admin-only PATCH /v1/users/{id} (reuses UpdateMeAsync) + POST {id}/avatar.
Admin Users panel: «پروفایل» modal to view/edit name/slogan/about/company/website/
country/national-code/birthdate/gender/avatar for any user. Verified admin→other-user edit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 22:36:23 +03:30
soroush.asadi 6ee211fb35 feat(studio): copy repeaters, characters/controllers, color presets into editable project
Build backend images / build content-svc (push) Failing after 1m46s
Build backend images / build file-svc (push) Failing after 2m32s
Build backend images / build gateway (push) Failing after 1m18s
Build backend images / build identity-svc (push) Failing after 1m2s
Build backend images / build notification-svc (push) Failing after 2m59s
Build backend images / build render-svc (push) Failing after 6m12s
Build backend images / build studio-svc (push) Failing after 4m14s
Extends CopyTemplateGraphAsync: repeater children flatten into saved_scene_contents
(repeater_item_key/index via repeater_items); scene characters+controllers and color
presets+items copied, correlated by (new scene, original-id/sort) since studio tables
lack original-id columns. studio character.key is a uuid → store original char id.
No regression on templates without these (copy 0 rows). All enum cols cast ::text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 22:14:28 +03:30
soroush.asadi f8631fbbc4 fix(admin): auto-promote uploaded AEP to the render bucket on attach
Uploading an .aep/.zip in the template editor only set content.projects.aep_file_url
(a user-uploads reference) — it never copied the file to templates/{id}/ where the
render node-agent's claim looks. Result: uploaded templates weren't renderable.

attachAep now also POSTs /v1/template-bundles/{project_id} {source_url} after saving
the reference, which server-side-copies the file into templates/{id}/(bundle.zip|
template.aep). Uploading a template now makes it immediately renderable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:53:54 +03:30
soroush.asadi 076c2e577f fix(render): resolve template id for render jobs + mock-fallback when no .aep
Build backend images / build content-svc (push) Failing after 1m30s
Build backend images / build file-svc (push) Failing after 1m23s
Build backend images / build gateway (push) Failing after 5m47s
Build backend images / build identity-svc (push) Failing after 1m23s
Build backend images / build notification-svc (push) Failing after 1m51s
Build backend images / build render-svc (push) Failing after 1m23s
Build backend images / build studio-svc (push) Failing after 1m23s
THE bug behind "AEPFilePath is required for real AE render": CreateJob inserted
original_project_id = saved_project_id (VALUES $3,$3), so the claim looked for the
render bundle at templates/{saved_project_id}/ — which never exists. The bundle
lives at templates/{TEMPLATE_id}/. Now original_project_id is resolved from
studio.saved_projects.original_project_id (the template the project was built from).
(Direct-SQL test renders masked this by setting the template id explicitly.)

Also harden the node-agent: Run() falls back to mock render when AEPFilePath is
empty even if AE is installed (previously hard-errored), so a missing/un-promoted
template degrades gracefully instead of failing the job.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:37:59 +03:30
soroush.asadi 62807f5f41 fix(node-agent): resilient output upload — 60s HTTP timeout + 4× retry on upload-URL
After a CPU-heavy AE render+transcode the orchestrator/DB can be briefly slow;
the 15s client timeout made the post-render output-upload-url call fail and the
finished MP4 was dropped (completed without export). Bumped client timeout to 60s
and retry the upload-URL call up to 4× with backoff so a finished render's output
is never lost to a transient stall.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:57:09 +03:30
soroush.asadi e59f07df4e fix(node-agent): transcode AE render to MP4 with ffmpeg (real renders deliver MP4)
aerender can't reliably write H.264 directly in modern AE — it renders the
project's output module (Lossless AVI/MOV) and ignores the .mp4 extension,
producing a multi-GB .avi the agent then failed to find/upload.

- findRenderedOutput(): locate the file aerender actually wrote (output.avi/.mov/.mp4)
- transcodeToMP4(): ffmpeg → H.264 yuv420p + AAC + faststart; drops the lossless
  intermediate. ffmpeg located via $FFMPEG_PATH, beside the agent exe, or PATH.
- Graceful fallback: if ffmpeg is missing/fails, upload the raw render so the job
  still delivers a (large but valid) file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 07:50:43 +03:30
2339 changed files with 925728 additions and 749 deletions
+89
View File
@@ -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 13s (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; 25f 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, 12px 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 12s; 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 (1k2k 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 1k2k.
- [ ] 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 (812). 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 — 612, 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 120155 / en 120158 chars. One natural keyword, no stuffing.
- **Keywords** (lives in `keywords`): how Iranians actually search — «قالب آماده», «ساخت/دانلود استوری»,
«تیزر تبلیغاتی», «اینترو لوگو» — include loanword + Persian spelling both ways (استوری/Story, اینترو/Intro).
1 primary + 23 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 68; **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).
- [ ] 612 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 + 23 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 700900) and `lineHeight: 1.41.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(80110)`, body ≈ `vmin(2840)`. 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 814f · word stagger 24f · standard reveal 1828f · hero entrance 2840f · **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) | 814 |
| Standard reveal | 1828 |
| Hero entrance | 2840 |
| Scene transition | 1220 |
| 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 ±13px, 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.050.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 6090% 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 23 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 814f · standard reveal 1828f · hero entrance 2840f · scene transition 1220f · 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 (1k2k 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>`, 1k2k 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 25f entrances; secondary motion (shadow/contents react); anticipation.
3. **Design system** — one type scale (45 sizes), one spacing rhythm, constrained palette (1 primary + 12 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, 12px 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** | 715s punchy; 3090s for depth (up to 90s) | First 23s 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≈200600 | 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** | 1530s engagement; 1118s 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** | 1535s | **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 515s; branded sting <3s | — | state payoff first, brand second |
| **YT end screen** | last 520s | — | leave clean plate (lower + right) for subscribe/next/playlist | reserve an end-card-safe zone |
| **All three** | drifting to **6090s** | authenticity > perfection, phone-feel > studio, natural light, cinematic grading | — | hook in first 12s; 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, 34 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 (1220f). 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 **1220f**; 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 23f 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`.
+121
View File
@@ -0,0 +1,121 @@
name: CI/CD
# Pushes to Gitea trigger this. GitHub (origin) stays a backup and does not deploy.
on:
push:
branches: [master]
pull_request:
branches: [master]
concurrency:
group: flatrender-cicd-${{ github.ref }}
cancel-in-progress: true
jobs:
# ── CI: fast frontend type-check before the (long) deploy build ───────────────
web-check:
name: "CI · Web (tsc)"
runs-on: ubuntu-latest
container:
image: mirror.soroushasadi.com/node:20-alpine
options: --add-host=gitea:host-gateway
steps:
- name: Checkout (tarball)
env:
TOKEN: ${{ github.token }}
SHA: ${{ github.sha }}
run: |
wget -q --header "Authorization: Bearer ${TOKEN}" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/archive/${SHA}.tar.gz" \
-O /tmp/repo.tar.gz
tar -xzf /tmp/repo.tar.gz --strip-components=1
- name: Install deps
run: |
npm ci --no-audit --no-fund \
--registry https://mirror.soroushasadi.com/repository/npm-group/ \
--fetch-retries=5 --fetch-retry-maxtimeout=120000
- name: TypeScript check
run: npx tsc --noEmit
# ── Deploy: build + bring up the whole compose stack on the server ────────────
deploy:
name: "Deploy · full stack"
runs-on: self-hosted
needs: [web-check]
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
timeout-minutes: 50
env:
# act runner host mode ships a minimal PATH — extend so docker/snap resolve.
PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin
# Lock the compose project name so volumes keep a STABLE prefix across deploys
# regardless of the runner's checkout directory (prevents orphaned-volume data loss).
COMPOSE_PROJECT_NAME: flatrender
COMPOSE_FILE: docker-compose.v2.yml
DOCKER_BUILDKIT: "1"
COMPOSE_DOCKER_CLI_BUILD: "1"
steps:
- name: Checkout
env:
TOKEN: ${{ github.token }}
REF: ${{ github.ref }}
run: |
git init
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git config http.extraheader "Authorization: Bearer ${TOKEN}"
git fetch --depth=1 origin "${REF}"
git checkout -f FETCH_HEAD
# Remove stale Docker-created bind-mount dirs from a previous run (e.g. an
# empty scripts/init-db.sh dir) so they don't shadow real files. -e keeps .env.
git clean -ffd -e .env || true
- name: Write .env (from ENV_FILE secret)
run: printf '%s' "$ENV_FILE" > .env
env:
ENV_FILE: ${{ secrets.ENV_FILE }}
- name: Backup database (if running)
run: |
if docker ps -a --format '{{.Names}}' | grep -q '^fr2-postgres$'; then
mkdir -p /opt/flatrender-backups
set -a; . ./.env 2>/dev/null || true; set +a
STAMP=$(date +%Y%m%d-%H%M%S)
echo "Dumping DB → /opt/flatrender-backups/flatrender-$STAMP.sql"
docker exec fr2-postgres pg_dump -U "${POSTGRES_USER:-postgres}" flatrender \
> "/opt/flatrender-backups/flatrender-$STAMP.sql" || echo "backup failed (non-fatal)"
# keep only the 14 most recent dumps
ls -1t /opt/flatrender-backups/flatrender-*.sql 2>/dev/null | tail -n +15 | xargs -r rm -f
else
echo "fr2-postgres not running yet — first deploy, no backup needed."
fi
- name: Build images
run: docker compose build --parallel
- name: Start stack
run: docker compose up -d --remove-orphans
- name: Wait for gateway healthy
run: |
for i in $(seq 1 30); do
S=$(docker inspect --format='{{.State.Health.Status}}' fr2-gateway 2>/dev/null || echo missing)
echo " [$i/30] gateway: $S"
[ "$S" = "healthy" ] && echo "OK gateway healthy" && break
[ "$i" = "30" ] && echo "TIMEOUT gateway" && docker compose logs --tail=60 gateway content-svc identity-svc && exit 1
sleep 6
done
- name: Wait for frontend healthy
run: |
for i in $(seq 1 24); do
S=$(docker inspect --format='{{.State.Health.Status}}' fr2-frontend 2>/dev/null || echo missing)
echo " [$i/24] frontend: $S"
[ "$S" = "healthy" ] && echo "OK frontend healthy" && break
[ "$i" = "24" ] && echo "TIMEOUT frontend" && docker compose logs --tail=60 frontend && exit 1
sleep 5
done
- name: Prune dangling images
if: success()
run: docker image prune -f
+11 -1
View File
@@ -1,7 +1,8 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # 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
node_modules/
/.pnp /.pnp
.pnp.js .pnp.js
.yarn/install-state.gz .yarn/install-state.gz
@@ -55,3 +56,12 @@ node-agent.exe
# node-agent local build + secrets # node-agent local build + secrets
services/node-agent/dist/ services/node-agent/dist/
agent.env 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/
+15 -12
View File
@@ -1,15 +1,18 @@
# ── Stage 1: install dependencies ──────────────────────────────────────────── # ── Stage 1: install dependencies ────────────────────────────────────────────
FROM node:20-alpine AS deps FROM mirror.soroushasadi.com/node:20-slim AS deps
RUN apk add --no-cache libc6-compat # 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 WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
# The Nexus npm proxy intermittently returns 500s / corrupted tarballs while it # npm installs through the self-hosted Nexus mirror (override with --build-arg
# back-fills its cache from upstream. Retry the whole install a few times — each # NPM_REGISTRY=... for a different mirror). The proxy intermittently returns 500s
# pass re-requests only what's still missing, so successive runs converge once # / corrupted tarballs while it back-fills from upstream, so retry the whole
# Nexus has cached every package. Bump npm's own retry budget too. # install a few times — each pass re-requests only what's still missing.
ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
RUN for i in 1 2 3 4 5; do \ RUN for i in 1 2 3 4 5; do \
npm ci --registry http://171.22.25.73:8081/repository/npm-group/ \ npm ci --registry "${NPM_REGISTRY}" \
--fetch-retries=5 --fetch-retry-factor=2 \ --fetch-retries=5 --fetch-retry-factor=2 \
--fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 && exit 0; \ --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 && exit 0; \
echo "npm ci attempt $i failed; retrying in 10s..."; sleep 10; \ echo "npm ci attempt $i failed; retrying in 10s..."; sleep 10; \
@@ -17,7 +20,7 @@ RUN for i in 1 2 3 4 5; do \
echo "npm ci failed after 5 attempts" && exit 1 echo "npm ci failed after 5 attempts" && exit 1
# ── Stage 2: build ─────────────────────────────────────────────────────────── # ── Stage 2: build ───────────────────────────────────────────────────────────
FROM node:20-alpine AS builder FROM mirror.soroushasadi.com/node:20-slim AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
@@ -50,15 +53,15 @@ ENV NODE_ENV=production
RUN npm run build RUN npm run build
# ── Stage 3: production runner ──────────────────────────────────────────────── # ── Stage 3: production runner ────────────────────────────────────────────────
FROM node:20-alpine AS runner FROM mirror.soroushasadi.com/node:20-slim AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
# Create a non-root user (security best practice) # Create a non-root user (security best practice). Debian uses groupadd/useradd.
RUN addgroup --system --gid 1001 nodejs \ RUN groupadd --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs && useradd --system --uid 1001 --gid nodejs nextjs
# Copy public assets # Copy public assets
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
@@ -0,0 +1,28 @@
-- =====================================================================
-- RENDER SCHEMA — scene snapshot jobs
-- Async "render one frame per scene from After Effects" jobs. A node claims a
-- queued snapshot, runs aerender for the scene's comp at a single frame, uploads
-- the still to object storage, and posts back the image URL. render-svc then
-- writes it onto content.scenes.snapshot_url (same DB, cross-schema) so the
-- studio scene bar + admin show a real thumbnail.
-- =====================================================================
SET search_path TO render, public;
CREATE TABLE IF NOT EXISTS snapshot_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL, -- content project (template variant)
scene_id UUID NOT NULL, -- content scene the snapshot belongs to
scene_key TEXT NOT NULL,
comp_name TEXT NOT NULL DEFAULT '', -- AE comp to render (scene key / render comp)
frame INT NOT NULL DEFAULT 0, -- frame to capture
status TEXT NOT NULL DEFAULT 'queued', -- queued | running | done | error
node_id UUID,
image_url TEXT,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_snapshot_jobs_status ON snapshot_jobs(status, created_at);
CREATE INDEX IF NOT EXISTS idx_snapshot_jobs_project ON snapshot_jobs(project_id, created_at DESC);
+111
View File
@@ -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;
+92
View File
@@ -0,0 +1,92 @@
# ─────────────────────────────────────────────────────────────────────────────
# FlatRender — PRODUCTION ENV_FILE template (server: 171.22.25.73, behind mirror-nginx)
#
# This is the content of the Gitea repo secret ENV_FILE.
# https://git.soroushasadi.com/soroushdes/flatrender/settings/secrets
# The deploy job writes this verbatim to `.env`, which docker compose reads.
#
# TLS + domain routing is handled by the existing central mirror-nginx (it owns
# 80/443). FlatRender does NOT run Caddy here — it publishes host ports and
# mirror-nginx reverse-proxies the domains to them (see deploy/README.md).
#
# Fill every <PLACEHOLDER>. Generate secrets with: openssl rand -hex 32
# Changing a NEXT_PUBLIC_* value requires a redeploy (baked into the frontend build).
# ─────────────────────────────────────────────────────────────────────────────
# ── Host-port binding ────────────────────────────────────────────────────────
# Internal services (postgres, render) stay on loopback. The three nginx-facing
# services publish on all interfaces so mirror-nginx can reach 171.22.25.73:PORT.
HOST_BIND=127.0.0.1
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
# ── Browser-facing URLs (served by mirror-nginx over HTTPS; baked into frontend) ─
NEXT_PUBLIC_SITE_URL=https://flatrender.ir
NEXT_PUBLIC_API_URL=https://api.flatrender.ir/v1
NEXT_PUBLIC_MINIO_URL=https://storage.flatrender.ir
NEXT_PUBLIC_TENANT_SLUG=flatrender
CORS_ORIGIN=https://flatrender.ir
# ── Core secrets ─────────────────────────────────────────────────────────────
JWT_SECRET=<openssl rand -hex 32>
SERVICE_TOKEN=<openssl rand -hex 32>
NODE_HMAC_SECRET=<openssl rand -hex 32>
JWT_ACCESS_MINUTES=1440
# ── Postgres ─────────────────────────────────────────────────────────────────
POSTGRES_USER=flatrender
POSTGRES_PASSWORD=<openssl rand -hex 24>
# ── MinIO (object storage) ───────────────────────────────────────────────────
MINIO_ACCESS_KEY=<openssl rand -hex 12>
MINIO_SECRET_KEY=<openssl rand -hex 24>
MINIO_BUCKET=flatrender-exports
MINIO_TEMPLATES_BUCKET=flatrender-templates
MINIO_UPLOAD_BUCKET=user-uploads
# render-svc signs presigned URLs for the public storage domain (HTTPS via nginx):
MINIO_HOST_ENDPOINT=storage.flatrender.ir
MINIO_HOST_USE_SSL=true
# ── Render farm ──────────────────────────────────────────────────────────────
# No AE node on the server → keep the dev worker OFF (it would mock-complete jobs).
# Disable rendering in Admin → فارم رندر → موتور رندر so users see an "unavailable" notice.
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
SNAPPAY_CLIENT_ID=
SNAPPAY_CLIENT_SECRET=
SNAPPAY_BASE_URL=https://api.snappay.ir
SNAPPAY_CALLBACK_URL=https://api.flatrender.ir/v1/payments/callback/snappay
TARA_API_KEY=
TARA_BASE_URL=https://api.tara.ir
TARA_CALLBACK_URL=https://api.flatrender.ir/v1/payments/callback/tara
+149
View File
@@ -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.
+107
View File
@@ -0,0 +1,107 @@
# Deploying FlatRender (Gitea CI/CD → 171.22.25.73, behind mirror-nginx)
Push to **Gitea** triggers `.gitea/workflows/ci-cd.yml`: a frontend `tsc` check, then a
self-hosted `deploy` job that builds the whole compose stack and brings it up. The
existing central **mirror-nginx** (owns 80/443, manual TLS certs) reverse-proxies the
three public domains to FlatRender's host ports — FlatRender does **not** run Caddy here.
GitHub (`origin`) stays a backup and never deploys.
Stack: gateway · identity · content · studio (.NET/Go) · file · render · notification
(Go) · Next.js frontend · Postgres · MinIO. All package installs route through
`mirror.soroushasadi.com` (Nexus).
```
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` + `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
licensed" page), NOT FlatRender.
2. **TLS cert** — ⚠️ mirror-nginx mounts cert dirs INDIVIDUALLY, so a fresh
`/etc/ssl/flatrender/` on the host is invisible inside the container. **Nest the cert
under an already-mounted dir** (the conf references this path):
```bash
mkdir -p /etc/ssl/soroushasadi/flatrender
cp <yourcert>/fullchain.pem /etc/ssl/soroushasadi/flatrender/
cp <yourcert>/privateKey.pem /etc/ssl/soroushasadi/flatrender/
```
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`.
⚠️ If you edited the conf with `sed -i` (which swaps the file inode), the running
container keeps the old inode → `docker restart mirror-nginx` instead (~3s blip).
Verify locally (bypasses DNS): `curl -sk --resolve flatrender.ir:443:127.0.0.1 https://flatrender.ir/ | head -c 60`
must show `<html lang="fa" dir="rtl">`. (Do this after the first deploy is up, or it 502s.)
4. **ENV_FILE secret** — at `…/soroushdes/flatrender/settings/secrets`, create `ENV_FILE`
from [`ENV_FILE.production.example`](./ENV_FILE.production.example) (already filled for
flatrender.ir; generate each secret with `openssl rand -hex 32`).
5. **Gitea Actions** enabled for this repo; act_runner has the `self-hosted:host` label
(the standard box already has this). daemon.json already mirrors Docker Hub via Nexus.
## Go live
```bash
git push gitea master # triggers CI + deploy
```
Watch `https://git.soroushasadi.com/soroushdes/flatrender/actions`. First run ~1525 min
(cold Nexus cache + all images build). When the deploy is green, add/reload the nginx
blocks (step 3) and visit `https://flatrender.ir`.
## Host ports (must be free on 171.22.25.73)
`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
- **Migrations** auto-run once via `deploy/postgres-initdb/00-init.sh` (mounted as the
whole `/docker-entrypoint-initdb.d` directory — a single-file bind mount left a stale
empty dir → "Is a directory") when the Postgres volume is first created. Later schema
changes are applied manually with `psql` (the volume persists). ⚠️ If a deploy ever
ran with a wrong/empty secret, the volume bakes the wrong password + may skip init →
`docker rm -f fr2-postgres && docker volume rm flatrender_pgdata` (only while there's
no real data) then re-run, so it re-inits with the current password + migrations.
- **Rendering** — no After Effects node on the server, so `RENDER_DEV_WORKER=false`.
Disable rendering in **Admin → فارم رندر → موتور رندر** so users see an "unavailable"
notice instead of jobs that never finish. Point real render nodes at the server later.
- **MinIO public URLs** — verify an uploaded image + a render download resolve over
`https://storage.flatrender.ir`. If not, recheck `MINIO_HOST_ENDPOINT` /
`MINIO_HOST_USE_SSL` / `NEXT_PUBLIC_MINIO_URL` in the secret and redeploy.
## Redeploy / rotate secrets
Edit `ENV_FILE` in Gitea (or push any commit) → the deploy re-runs. It backs up the DB to
`/opt/flatrender-backups/` before each deploy and never runs `docker compose down -v`.
Changing a `NEXT_PUBLIC_*` value only takes effect after the redeploy (baked at build).
+109
View File
@@ -0,0 +1,109 @@
# ==========================================================================
# FlatRender — flatrender.ir (add inside the http{} block of mirror-nginx)
#
# Routes the three public domains to the FlatRender host ports on 171.22.25.73
# (FRONTEND_PORT / GATEWAY_PORT / MINIO_PORT from the deploy ENV_FILE).
#
# The mirror-nginx config file lives on the HOST at /root/mirror-server/nginx/nginx.conf
# (bind-mounted read-only into the container). Add these blocks there.
#
# ⚠️ CERT-MOUNT TRAP: mirror-nginx mounts cert dirs INDIVIDUALLY
# (/etc/ssl/{soroushasadi,meezi,...}). A new /etc/ssl/soroushasadi/flatrender/ on the host is
# INVISIBLE inside the container → nginx -t "cannot load certificate". So NEST the
# flatrender cert under an already-mounted dir:
# mkdir -p /etc/ssl/soroushasadi/flatrender
# cp <cert>/fullchain.pem <cert>/privateKey.pem /etc/ssl/soroushasadi/flatrender/
# The cert must cover flatrender.ir + api. + storage. (wildcard *.flatrender.ir + apex,
# or a SAN cert).
#
# ⚠️ After editing nginx.conf with `sed -i` (which replaces the file inode), the
# running container keeps the OLD inode → you must RESTART, not just reload:
# docker restart mirror-nginx # ~3s blip for all sites
# A plain edit that keeps the inode + reload is fine:
# docker exec mirror-nginx nginx -t && docker exec mirror-nginx nginx -s reload
#
# Test routing locally (bypasses DNS):
# curl -sk --resolve flatrender.ir:443:127.0.0.1 https://flatrender.ir/ | head -c 60
# # must show: <!DOCTYPE html><html lang="fa" dir="rtl">
# ==========================================================================
server {
listen 80;
server_name flatrender.ir www.flatrender.ir api.flatrender.ir storage.flatrender.ir pay.flatrender.ir;
return 301 https://$host$request_uri;
}
# ── Site (Next.js frontend → FRONTEND_PORT) ───────────────────────────────
server {
listen 443 ssl; http2 on;
server_name flatrender.ir www.flatrender.ir;
client_max_body_size 25m;
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:1600;
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;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
# ── API gateway (→ GATEWAY_PORT) ──────────────────────────────────────────
server {
listen 443 ssl; http2 on;
server_name api.flatrender.ir;
client_max_body_size 512m; # large uploads routed through the gateway
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:1605;
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;
proxy_set_header Upgrade $http_upgrade; # render-progress WebSocket
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
}
}
# ── MinIO storage (→ MINIO_PORT) ──────────────────────────────────────────
server {
listen 443 ssl; http2 on;
server_name storage.flatrender.ir;
client_max_body_size 512m;
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:1610;
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;
}
}
# ── 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;
}
}
@@ -1,7 +1,8 @@
#!/bin/bash #!/bin/bash
# FlatRender V2 — run all schema migrations in order on first postgres init. # FlatRender V2 — run all schema migrations in order on first postgres init.
# Mounted at: /docker-entrypoint-initdb.d/00-init.sh # This whole directory is mounted at /docker-entrypoint-initdb.d (a DIRECTORY mount
# Migrations dir mounted at: /migrations (read-only) # is robust; a single-file bind mount can leave a stale empty dir in a reused CI
# workspace → "Is a directory"). Migrations dir mounted read-only at /migrations.
set -e set -e
MIGRATIONS_DIR="/migrations" MIGRATIONS_DIR="/migrations"
+112
View File
@@ -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 };
+93 -15
View File
@@ -14,7 +14,9 @@ services:
# ── Shared infrastructure ─────────────────────────────────────────────────── # ── Shared infrastructure ───────────────────────────────────────────────────
postgres: postgres:
image: postgres:16-alpine # Pull infra images through the Nexus mirror (Docker Hub blocks Iran IPs).
# Override INFRA_REGISTRY= (empty) to use plain Docker Hub names elsewhere.
image: ${INFRA_REGISTRY:-mirror.soroushasadi.com/}postgres:16-alpine
container_name: fr2-postgres container_name: fr2-postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -25,9 +27,14 @@ services:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
# migrations are run once by init-db.sh when the data volume is first created # migrations are run once by init-db.sh when the data volume is first created
- ./backend/db/migrations:/migrations:ro - ./backend/db/migrations:/migrations:ro
- ./scripts/init-db.sh:/docker-entrypoint-initdb.d/00-init.sh:ro # Directory mount (NOT a single file) — robust against stale CI-workspace dirs.
- ./deploy/postgres-initdb:/docker-entrypoint-initdb.d:ro
ports: ports:
- "5432:5432" # 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 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: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d flatrender"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d flatrender"]
interval: 5s interval: 5s
@@ -36,7 +43,12 @@ services:
start_period: 10s start_period: 10s
minio: minio:
image: minio/minio:latest # minio:latest is compiled for x86-64-v2 and crashes on baseline-CPU servers
# ("Fatal glibc error: CPU does not support x86-64-v2"). Use a `-cpuv1` build
# (same release, plain x86-64). Pulled from the Liara Docker mirror, which
# back-fills these tags (the soroushasadi mirror only has cached v2 builds).
# Dev overrides MINIO_REGISTRY= + MINIO_IMAGE_TAG=latest for plain Docker Hub.
image: ${MINIO_REGISTRY:-docker-mirror.liara.ir/}minio/minio:${MINIO_IMAGE_TAG:-RELEASE.2025-06-13T11-33-47Z-cpuv1}
container_name: fr2-minio container_name: fr2-minio
restart: unless-stopped restart: unless-stopped
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
@@ -46,14 +58,16 @@ services:
volumes: volumes:
- miniodata:/data - miniodata:/data
ports: ports:
- "9000:9000" - "${EDGE_BIND:-0.0.0.0}:${MINIO_PORT:-9000}:9000"
- "9001:9001" - "${EDGE_BIND:-0.0.0.0}:${MINIO_CONSOLE_PORT:-9001}:9001"
healthcheck: healthcheck:
test: ["CMD-SHELL", "mc ready local || exit 1"] # Liveness via curl (newer images) with an mc fallback (older images that
# still bundle the client). Covers minio:latest drift either way.
test: ["CMD-SHELL", "curl -sf http://localhost:9000/minio/health/live || mc ready local || exit 1"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 8
start_period: 10s start_period: 20s
# ── Identity Service (.NET 10) ────────────────────────────────────────────── # ── Identity Service (.NET 10) ──────────────────────────────────────────────
# Config keys: ConnectionStrings:DefaultConnection Jwt:Secret # Config keys: ConnectionStrings:DefaultConnection Jwt:Secret
@@ -72,10 +86,21 @@ services:
Jwt__Secret: "${JWT_SECRET}" Jwt__Secret: "${JWT_SECRET}"
Jwt__Issuer: "flatrender-identity" Jwt__Issuer: "flatrender-identity"
Jwt__Audience: "flatrender" Jwt__Audience: "flatrender"
Jwt__AccessTokenMinutes: "${JWT_ACCESS_MINUTES:-1440}"
ServiceToken: "${SERVICE_TOKEN:-internal-service-secret}" 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__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
ZarinPal__CallbackUrl: "${ZARINPAL_CALLBACK_URL:-http://localhost:8080/v1/payments/callback/zarinpal}" ZarinPal__CallbackUrl: "${ZARINPAL_CALLBACK_URL:-http://localhost:8080/v1/payments/callback/zarinpal}"
ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}" 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__SecretKey: "${STRIPE_SECRET_KEY:-}"
Stripe__WebhookSecret: "${STRIPE_WEBHOOK_SECRET:-}" Stripe__WebhookSecret: "${STRIPE_WEBHOOK_SECRET:-}"
SnapPay__ClientId: "${SNAPPAY_CLIENT_ID:-}" SnapPay__ClientId: "${SNAPPAY_CLIENT_ID:-}"
@@ -181,7 +206,7 @@ services:
container_name: fr2-render container_name: fr2-render
restart: unless-stopped restart: unless-stopped
ports: ports:
- "5010:8080" # exposed so a LOCAL (host) node-agent can reach /v1/internal/* - "${HOST_BIND:-0.0.0.0}:5010:8080" # exposed so a LOCAL (host) node-agent can reach /v1/internal/*
environment: environment:
DATABASE_URL: "postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/flatrender?search_path=render,public" DATABASE_URL: "postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/flatrender?search_path=render,public"
JWT_SECRET: "${JWT_SECRET}" JWT_SECRET: "${JWT_SECRET}"
@@ -190,8 +215,14 @@ services:
MINIO_ENDPOINT: "${MINIO_HOST_ENDPOINT:-172.28.144.1:9000}" MINIO_ENDPOINT: "${MINIO_HOST_ENDPOINT:-172.28.144.1:9000}"
MINIO_ACCESS_KEY: "${MINIO_ACCESS_KEY:-minioadmin}" MINIO_ACCESS_KEY: "${MINIO_ACCESS_KEY:-minioadmin}"
MINIO_SECRET_KEY: "${MINIO_SECRET_KEY:-minioadmin}" MINIO_SECRET_KEY: "${MINIO_SECRET_KEY:-minioadmin}"
MINIO_USE_SSL: "false" # SSL on when MINIO_HOST_ENDPOINT is an HTTPS storage domain (prod via Caddy).
MINIO_USE_SSL: "${MINIO_HOST_USE_SSL:-false}"
MINIO_BUCKET: "${MINIO_BUCKET:-flatrender-exports}" MINIO_BUCKET: "${MINIO_BUCKET:-flatrender-exports}"
# Scene snapshots upload to this public-read bucket; PUBLIC_URL is the
# browser-reachable base for the stored snapshot_url (defaults to the host
# MinIO endpoint above when unset).
MINIO_UPLOAD_BUCKET: "${MINIO_UPLOAD_BUCKET:-user-uploads}"
MINIO_PUBLIC_URL: "${NEXT_PUBLIC_MINIO_URL:-http://172.28.144.1:9000}"
NOTIFICATION_URL: "http://notification-svc:8080" NOTIFICATION_URL: "http://notification-svc:8080"
IDENTITY_URL: "http://identity-svc:8080" IDENTITY_URL: "http://identity-svc:8080"
SERVICE_TOKEN: "${SERVICE_TOKEN:-internal-service-secret}" SERVICE_TOKEN: "${SERVICE_TOKEN:-internal-service-secret}"
@@ -199,6 +230,9 @@ services:
# Dev: process Queued jobs in-process (progress + preview → Done) without a # Dev: process Queued jobs in-process (progress + preview → Done) without a
# Windows AE node. Set "false" in production where real render nodes claim jobs. # Windows AE node. Set "false" in production where real render nodes claim jobs.
RENDER_DEV_WORKER: "${RENDER_DEV_WORKER:-true}" RENDER_DEV_WORKER: "${RENDER_DEV_WORKER:-true}"
# Dev: fulfil scene-snapshot jobs with a generated placeholder image (no AE).
# Keep "false" in production — real nodes render the actual AE frame.
RENDER_DEV_SNAPSHOTS: "${RENDER_DEV_SNAPSHOTS:-false}"
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -233,6 +267,43 @@ services:
retries: 5 retries: 5
start_period: 10s 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) ──────────────────────────────────────────────────────── # ── API Gateway (Go) ────────────────────────────────────────────────────────
gateway: gateway:
@@ -241,7 +312,8 @@ services:
container_name: fr2-gateway container_name: fr2-gateway
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${GATEWAY_PORT:-8080}:8080" # EDGE_BIND/port face the reverse proxy (mirror-nginx → 171.22.25.73:PORT).
- "${EDGE_BIND:-0.0.0.0}:${GATEWAY_PORT:-8080}:8080"
environment: environment:
JWT_SECRET: "${JWT_SECRET}" JWT_SECRET: "${JWT_SECRET}"
IDENTITY_URL: "http://identity-svc:8080" IDENTITY_URL: "http://identity-svc:8080"
@@ -288,18 +360,20 @@ services:
container_name: fr2-frontend container_name: fr2-frontend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3000:3000" - "${EDGE_BIND:-0.0.0.0}:${FRONTEND_PORT:-3000}:3000"
environment: environment:
NODE_ENV: production NODE_ENV: production
PORT: "3000" PORT: "3000"
HOSTNAME: "0.0.0.0" HOSTNAME: "0.0.0.0"
# Server-side: Next route handlers reach the gateway over the internal network. # Server-side: Next route handlers reach the gateway over the internal network.
API_GATEWAY_URL: "http://gateway:8080" 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: depends_on:
gateway: gateway:
condition: service_healthy condition: service_healthy
healthcheck: 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 interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@@ -315,9 +389,13 @@ services:
# For local dev (no real domain), comment out this block and access # For local dev (no real domain), comment out this block and access
# services directly on their host ports (:3000, :8088, :9000). # services directly on their host ports (:3000, :8088, :9000).
caddy: caddy:
image: caddy:2-alpine image: ${INFRA_REGISTRY:-mirror.soroushasadi.com/}caddy:2-alpine
container_name: fr2-caddy container_name: fr2-caddy
restart: unless-stopped restart: unless-stopped
# Opt-in only: `docker compose --profile edge up`. NOT started by default —
# on a server with an existing reverse proxy (mirror-nginx owns 80/443),
# FlatRender publishes host ports and the proxy routes the domains to them.
profiles: ["edge"]
ports: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"
+30 -4
View File
@@ -39,11 +39,37 @@ docker compose -f docker-compose.v2.yml --env-file .env.v2 up -d <svc>
- **Template detail page** wired to real content (`fetchProject(slug)`; was hardcoded demo catalog → 404'd). - **Template detail page** wired to real content (`fetchProject(slug)`; was hardcoded demo catalog → 404'd).
- **"Use template" works end-to-end:** `StudioService.CreateProjectAsync` deep-copies the content template scene graph (scenes + content elements + scene colors + shared colors) into the editable studio project via one atomic cross-schema SQL copy (enum cols cast `::text`; temp `_scene_map`). `/api/projects` resolves container slug → published variant project. **Aspect-ratio picker** (16:9/1:1/9:16) on the detail page drives which variant is copied. - **"Use template" works end-to-end:** `StudioService.CreateProjectAsync` deep-copies the content template scene graph (scenes + content elements + scene colors + shared colors) into the editable studio project via one atomic cross-schema SQL copy (enum cols cast `::text`; temp `_scene_map`). `/api/projects` resolves container slug → published variant project. **Aspect-ratio picker** (16:9/1:1/9:16) on the detail page drives which variant is copied.
## Known follow-ups (not done) ## ⏭️ NEXT UP — Studio↔Template binding EPIC (agreed priority)
- Scene-graph copy skips **repeater children, characters, color-presets** (scenes + fields + colors ARE copied). **Phase B DONE (commits a69bc62 B1, 47a4ced B2).** Edit→render binding works:
- Admin can't edit *other* users' full profiles yet (self-edit works for everyone). - **B1 ✅** studio input edits persist to `saved_scene_contents` via studio-svc
- Test the studio editor itself once inside (large Konva surface — not exhaustively exercised). `PATCH /v1/saved-projects/{id}/contents` (Next `/api/projects/[id]/contents`); the
persistence hook pushes edited values (bridged `c-<key>` layers) on every save.
- **B2 ✅** render-svc claim now includes `bindings` (GetRenderBindings = saved_scene_contents
with non-empty value); node-agent `binder.go` emits a JSON bind-spec + downloads media, runs
the data-driven `bind.jsx` via afterfx (sets Source Text, replaces footage) → saves `bound.aep`
→ aerender renders THAT.
- **VERIFY (needs node-agent re-run):** re-run the updated `node-agent.exe`, edit a text input in
the studio (wait for "saved"), render, confirm the MP4 shows the edited text. Colours
(saved_shared_colors → spec.colors) + footage-item media (vs layer-source) are follow-ups.
- **Done since:** per-tier render height (#36), FIX-hides-add-scene (#42), admin/renders
pagination + user-name links + output (#41), Persian/Jalali date pickers (#40).
- **Still open in B:** colours binding (saved_shared_colors → spec.colors), footage-item
media (vs layer-source), deeper per-input controllers in the admin scene-inputs editor.
- Next epic phase: **A** admin preset stories (premade videos — model `preset_stories`/`preset_scenes`
exists, no endpoints/UI; detail "ویدیوهای ساخته‌شده" is placeholder), and **C** AE single-frame
scene snapshots (`scenes.snapshot_url` empty → node `aerender -s 0 -e 0`).
Also smaller, still open: per-tier render **height** (render-svc r_height hardcoded 1080 + node
ffmpeg scale), **FIX hides add-scene** (mode not plumbed into studio store), **Persian/Jalali
date pickers** in admin, **admin/renders** pagination + video/output + user-name → profile link,
deeper per-input controllers in the admin scene-inputs editor.
## Done follow-ups (this session)
- Scene-graph copy now includes repeater children, characters/controllers, color-presets.
- Admin CAN edit any user's full profile (Users → «پروفایل»).
- Studio now shows ALL template inputs (contents→layers bridge).
## Debugging client-side behavior ## Debugging client-side behavior
+123 -7
View File
@@ -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." "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": { "pricing": {
"heading": "Choose your FlatRender plan", "heading": "Pay by the second, not by the video",
"monthly": "Monthly", "monthly": "Monthly",
"annual": "Annual", "annual": "Annual",
"saveBadge": "Save up to {percent}%", "saveBadge": "Save up to {percent}%",
"subscribe": "Subscribe", "subscribe": "Subscribe",
"freeBannerTitle": "Free plan", "freeBannerTitle": "Free plan",
"freeBannerDesc": "Free forever, no credit card required", "freeBannerDesc": "Free forever, no credit card required",
"perMonth": "/ mo", "perMonth": "monthly",
"billedAnnually": "billed annually", "billedAnnually": "billed annually",
"compareTitle": "Compare all plans", "compareTitle": "Compare all plans",
"allFeatures": "All features", "allFeatures": "All features",
@@ -161,7 +161,45 @@
"proName": "Pro", "proName": "Pro",
"proDesc": "Become a pro and unlock more powerful video, design and website editing tools for commercial use.", "proDesc": "Become a pro and unlock more powerful video, design and website editing tools for commercial use.",
"businessName": "Business", "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": { "footer": {
"brandName": "FlatRender", "brandName": "FlatRender",
@@ -187,6 +225,59 @@
"socialLinkedIn": "LinkedIn", "socialLinkedIn": "LinkedIn",
"socialYouTube": "YouTube" "socialYouTube": "YouTube"
}, },
"blog": {
"metaTitle": "FlatRender Blog",
"metaDescription": "Articles, news and guides on making videos and images.",
"pageTitle": "Blog",
"pageDescription": "The latest articles, news and ideas on creating videos and images.",
"readMore": "Read more",
"views": "views",
"empty": "No articles have been published yet."
},
"learn": {
"metaTitle": "Learn FlatRender",
"metaDescription": "Step-by-step tutorials for making professional videos and images.",
"pageTitle": "Learn",
"pageDescription": "Step-by-step tutorials and practical guides for getting the most out of FlatRender.",
"readMore": "View tutorial",
"views": "views",
"empty": "No tutorials have been published yet."
},
"aboutPage": {
"title": "About Us",
"lead": "FlatRender makes professional video and image creation simple for everyone.",
"body": "FlatRender is an online platform for creating videos and images with ready-made templates and smart tools.\n\nOur mission is to let anyone create professional content in minutes — no design or motion-graphics expertise required."
},
"contactPage": {
"title": "Contact Us",
"lead": "We'd love to hear from you.",
"body": "Reach out for support, partnerships, or any questions.\n\nEmail: support@flatrender.com"
},
"careersPage": {
"title": "Careers",
"lead": "Join the FlatRender team.",
"body": "We're always looking for talented, passionate people.\n\nTo learn about open positions, send your resume to jobs@flatrender.com."
},
"privacyPage": {
"title": "Privacy Policy",
"lead": "How we store and use your information.",
"body": "Your privacy matters to us. This page explains how we collect, use and protect your information.\n\nThis is placeholder text and should be completed by an administrator before final publication."
},
"termsPage": {
"title": "Terms of Service",
"lead": "The rules for using FlatRender.",
"body": "By using FlatRender you agree to the following terms.\n\nThis is placeholder text and should be completed by an administrator before final publication."
},
"cookiesPage": {
"title": "Cookie Policy",
"lead": "How we use cookies.",
"body": "We use cookies to improve your experience on the site.\n\nThis is placeholder text and should be completed by an administrator before final publication."
},
"helpPage": {
"title": "Help Center",
"lead": "Answers to common questions and usage guides.",
"body": "Welcome to the FlatRender Help Center.\n\nFor more questions, check the Learn section or contact support."
},
"auth": { "auth": {
"signIn": "Sign In", "signIn": "Sign In",
"signUp": "Sign Up", "signUp": "Sign Up",
@@ -316,6 +407,7 @@
"auto": { "auto": {
"appAdminLayout": { "appAdminLayout": {
"brand": "FlatRender", "brand": "FlatRender",
"renderEngine": "Render Engine",
"nodes": "Nodes", "nodes": "Nodes",
"renderQueue": "Render Queue", "renderQueue": "Render Queue",
"backToDashboard": "← Back to Dashboard", "backToDashboard": "← Back to Dashboard",
@@ -323,13 +415,17 @@
"categories": "Categories", "categories": "Categories",
"tags": "Tags", "tags": "Tags",
"fonts": "Fonts", "fonts": "Fonts",
"homePage": "Home Page",
"blogs": "Blog", "blogs": "Blog",
"learn": "Tutorials",
"pages": "Pages",
"slides": "Slides", "slides": "Slides",
"users": "Users", "users": "Users",
"plans": "Plans", "plans": "Plans",
"templates": "Templates", "templates": "Templates",
"media": "Media", "media": "Media",
"discounts": "Discounts", "discounts": "Discounts",
"payments": "Payments",
"siteSettings": "Settings", "siteSettings": "Settings",
"messaging": "Messaging", "messaging": "Messaging",
"marketing": "Marketing", "marketing": "Marketing",
@@ -617,7 +713,8 @@
"mostPopular": "Most Popular" "mostPopular": "Most Popular"
}, },
"componentsTemplatesTemplateDetailExamples": { "componentsTemplatesTemplateDetailExamples": {
"heading": "Videos created using this template" "heading": "Videos created using this template",
"usePreset": "Use this example"
}, },
"componentsTemplatesTemplateDetailInfo": { "componentsTemplatesTemplateDetailInfo": {
"sceneCount": "{count} scenes", "sceneCount": "{count} scenes",
@@ -857,6 +954,7 @@
"toolShape": "Shape", "toolShape": "Shape",
"toolDraw": "Draw", "toolDraw": "Draw",
"toolAi": "AI", "toolAi": "AI",
"panels": "Adjust",
"shapeRectangle": "Rectangle", "shapeRectangle": "Rectangle",
"shapeCircle": "Circle", "shapeCircle": "Circle",
"shapeLine": "Line", "shapeLine": "Line",
@@ -1050,8 +1148,13 @@
"description": "Generate voiceovers from your script directly in the studio." "description": "Generate voiceovers from your script directly in the studio."
}, },
"componentsStudioSidebarColorsCustomTab": { "componentsStudioSidebarColorsCustomTab": {
"mainColor": "Main Color", "mainColor": "Background",
"additionalColor": "Additional Color", "additionalColor": "Accent",
"secondaryColor": "Secondary",
"textColor": "Text",
"themePresets": "Themes",
"applyThemePreset": "Apply {name} theme",
"applyTheme": "Apply theme",
"applyToAllScenes": "Apply to all scenes" "applyToAllScenes": "Apply to all scenes"
}, },
"componentsStudioSidebarColorsPalettesTab": { "componentsStudioSidebarColorsPalettesTab": {
@@ -1086,6 +1189,14 @@
"replaceImage": "Replace image", "replaceImage": "Replace image",
"uploadImage": "Upload 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": { "componentsStudioSidebarTransitionsSidebarContent": {
"heading": "Transitions", "heading": "Transitions",
"randomTransition": "Random Transition", "randomTransition": "Random Transition",
@@ -1126,7 +1237,10 @@
"deleteScene": "Delete {name}", "deleteScene": "Delete {name}",
"resizeSceneDuration": "Resize {name} duration", "resizeSceneDuration": "Resize {name} duration",
"sceneNameLabel": "Scene name", "sceneNameLabel": "Scene name",
"doubleClickToRename": "Double-click to rename" "doubleClickToRename": "Double-click to rename",
"reorderScene": "Reorder {name}",
"durationLabel": "Scene duration (seconds)",
"secondsUnit": "s"
}, },
"componentsStudioTimelineSceneThumbnailStrip": { "componentsStudioTimelineSceneThumbnailStrip": {
"browseScenes": "Browse scenes", "browseScenes": "Browse scenes",
@@ -1166,6 +1280,8 @@
"transitions": "Transitions", "transitions": "Transitions",
"font": "Font", "font": "Font",
"myWatermark": "My Watermark", "myWatermark": "My Watermark",
"timeline": "Timeline",
"preview": "Preview",
"toolsNavLabel": "Studio tools", "toolsNavLabel": "Studio tools",
"guideMe": "Guide me", "guideMe": "Guide me",
"guideComingSoon": "👋 Guide coming soon!", "guideComingSoon": "👋 Guide coming soon!",
+123 -7
View File
@@ -144,14 +144,14 @@
"a7": "بله. هر زمان از تنظیمات حساب لغو کنید. دسترسی تا پایان دوره صورت‌حساب باقی می‌ماند و می‌توانید به پلن رایگان برگردید بدون اینکه پروژه‌هایتان از دست بروند." "a7": "بله. هر زمان از تنظیمات حساب لغو کنید. دسترسی تا پایان دوره صورت‌حساب باقی می‌ماند و می‌توانید به پلن رایگان برگردید بدون اینکه پروژه‌هایتان از دست بروند."
}, },
"pricing": { "pricing": {
"heading": لن فلت‌رندر خود را انتخاب کنید", "heading": رداخت بر اساس ثانیه، نه تعداد ویدیو",
"monthly": "ماهانه", "monthly": "ماهانه",
"annual": "سالانه", "annual": "سالانه",
"saveBadge": "تا {percent}٪ صرفه‌جویی", "saveBadge": "تا {percent}٪ صرفه‌جویی",
"subscribe": "اشتراک", "subscribe": "اشتراک",
"freeBannerTitle": "پلن رایگان", "freeBannerTitle": "پلن رایگان",
"freeBannerDesc": "برای همیشه رایگان، بدون نیاز به کارت اعتباری", "freeBannerDesc": "برای همیشه رایگان، بدون نیاز به کارت اعتباری",
"perMonth": "/ ماه", "perMonth": "ماهانه",
"billedAnnually": "پرداخت سالانه", "billedAnnually": "پرداخت سالانه",
"compareTitle": "مقایسه همه پلن‌ها", "compareTitle": "مقایسه همه پلن‌ها",
"allFeatures": "همه امکانات", "allFeatures": "همه امکانات",
@@ -161,7 +161,45 @@
"proName": "Pro", "proName": "Pro",
"proDesc": "حرفه‌ای شوید و ابزارهای قدرتمندتر ویدیو، طراحی و وب‌سایت را برای استفاده تجاری باز کنید.", "proDesc": "حرفه‌ای شوید و ابزارهای قدرتمندتر ویدیو، طراحی و وب‌سایت را برای استفاده تجاری باز کنید.",
"businessName": "Business", "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": { "footer": {
"brandName": "فلت‌رندر", "brandName": "فلت‌رندر",
@@ -187,6 +225,59 @@
"socialLinkedIn": "لینکدین", "socialLinkedIn": "لینکدین",
"socialYouTube": "یوتیوب" "socialYouTube": "یوتیوب"
}, },
"blog": {
"metaTitle": "وبلاگ فلت‌رندر",
"metaDescription": "مقاله‌ها، خبرها و راهنماهای ساخت ویدیو و تصویر.",
"pageTitle": "وبلاگ",
"pageDescription": "تازه‌ترین مقاله‌ها، خبرها و ایده‌ها دربارهٔ ساخت ویدیو و تصویر.",
"readMore": "ادامهٔ مطلب",
"views": "بازدید",
"empty": "هنوز مقاله‌ای منتشر نشده است."
},
"learn": {
"metaTitle": "آموزش فلت‌رندر",
"metaDescription": "آموزش‌های گام‌به‌گام برای ساخت ویدیو و تصویر حرفه‌ای.",
"pageTitle": "آموزش",
"pageDescription": "آموزش‌های گام‌به‌گام و راهنماهای کاربردی برای استفاده از فلت‌رندر.",
"readMore": "مشاهدهٔ آموزش",
"views": "بازدید",
"empty": "هنوز آموزشی منتشر نشده است."
},
"aboutPage": {
"title": "دربارهٔ ما",
"lead": "فلت‌رندر، ساخت ویدیو و تصویر حرفه‌ای را برای همه ساده می‌کند.",
"body": "فلت‌رندر یک پلتفرم آنلاین برای ساخت ویدیو و تصویر با کمک قالب‌های آماده و ابزارهای هوشمند است.\n\nمأموریت ما این است که هر کسی، بدون نیاز به دانش تخصصی طراحی یا موشن‌گرافیک، بتواند در چند دقیقه محتوای حرفه‌ای بسازد."
},
"contactPage": {
"title": "تماس با ما",
"lead": "خوشحال می‌شویم از شما بشنویم.",
"body": "برای پشتیبانی، همکاری یا هر پرسشی با ما در تماس باشید.\n\nایمیل: support@flatrender.com"
},
"careersPage": {
"title": "فرصت‌های شغلی",
"lead": "به تیم فلت‌رندر بپیوندید.",
"body": "ما همیشه به دنبال افراد بااستعداد و علاقه‌مند هستیم.\n\nبرای آگاهی از موقعیت‌های شغلی، رزومهٔ خود را به jobs@flatrender.com ارسال کنید."
},
"privacyPage": {
"title": "حریم خصوصی",
"lead": "نحوهٔ نگهداری و استفادهٔ ما از اطلاعات شما.",
"body": "حفظ حریم خصوصی شما برای ما اهمیت دارد. این صفحه نحوهٔ گردآوری، استفاده و محافظت از اطلاعات شما را توضیح می‌دهد.\n\nاین متن نمونه است و باید پیش از انتشار نهایی توسط مدیر تکمیل شود."
},
"termsPage": {
"title": "شرایط استفاده",
"lead": "قوانین استفاده از خدمات فلت‌رندر.",
"body": "با استفاده از فلت‌رندر، شرایط زیر را می‌پذیرید.\n\nاین متن نمونه است و باید پیش از انتشار نهایی توسط مدیر تکمیل شود."
},
"cookiesPage": {
"title": "سیاست کوکی",
"lead": "نحوهٔ استفادهٔ ما از کوکی‌ها.",
"body": "ما از کوکی‌ها برای بهبود تجربهٔ شما در سایت استفاده می‌کنیم.\n\nاین متن نمونه است و باید پیش از انتشار نهایی توسط مدیر تکمیل شود."
},
"helpPage": {
"title": "مرکز راهنما",
"lead": "پاسخ پرسش‌های پرتکرار و راهنمای استفاده.",
"body": "به مرکز راهنمای فلت‌رندر خوش آمدید.\n\nبرای پرسش‌های بیشتر، بخش آموزش را ببینید یا با پشتیبانی تماس بگیرید."
},
"auth": { "auth": {
"signIn": "ورود", "signIn": "ورود",
"signUp": "ثبت‌نام", "signUp": "ثبت‌نام",
@@ -316,6 +407,7 @@
"auto": { "auto": {
"appAdminLayout": { "appAdminLayout": {
"brand": "فلت‌رندر", "brand": "فلت‌رندر",
"renderEngine": "موتور رندر",
"nodes": "نودها", "nodes": "نودها",
"renderQueue": "صف رندر", "renderQueue": "صف رندر",
"backToDashboard": "← بازگشت به داشبورد", "backToDashboard": "← بازگشت به داشبورد",
@@ -323,13 +415,17 @@
"categories": "دسته‌بندی‌ها", "categories": "دسته‌بندی‌ها",
"tags": "برچسب‌ها", "tags": "برچسب‌ها",
"fonts": "فونت‌ها", "fonts": "فونت‌ها",
"homePage": "صفحهٔ اصلی",
"blogs": "بلاگ", "blogs": "بلاگ",
"learn": "آموزش‌ها",
"pages": "برگه‌ها",
"slides": "اسلایدها", "slides": "اسلایدها",
"users": "کاربران", "users": "کاربران",
"plans": "پلن‌ها", "plans": "پلن‌ها",
"templates": "قالب‌ها", "templates": "قالب‌ها",
"media": "رسانه", "media": "رسانه",
"discounts": "تخفیف‌ها", "discounts": "تخفیف‌ها",
"payments": "درگاه پرداخت",
"siteSettings": "تنظیمات سایت", "siteSettings": "تنظیمات سایت",
"messaging": "پیام‌رسانی", "messaging": "پیام‌رسانی",
"marketing": "بازاریابی", "marketing": "بازاریابی",
@@ -617,7 +713,8 @@
"mostPopular": "محبوب‌ترین" "mostPopular": "محبوب‌ترین"
}, },
"componentsTemplatesTemplateDetailExamples": { "componentsTemplatesTemplateDetailExamples": {
"heading": "ویدیوهای ساخته‌شده با این قالب" "heading": "ویدیوهای ساخته‌شده با این قالب",
"usePreset": "استفاده از این نمونه"
}, },
"componentsTemplatesTemplateDetailInfo": { "componentsTemplatesTemplateDetailInfo": {
"sceneCount": "{count} صحنه", "sceneCount": "{count} صحنه",
@@ -857,6 +954,7 @@
"toolShape": "شکل", "toolShape": "شکل",
"toolDraw": "ترسیم", "toolDraw": "ترسیم",
"toolAi": "هوش مصنوعی", "toolAi": "هوش مصنوعی",
"panels": "تنظیمات",
"shapeRectangle": "مستطیل", "shapeRectangle": "مستطیل",
"shapeCircle": "دایره", "shapeCircle": "دایره",
"shapeLine": "خط", "shapeLine": "خط",
@@ -1050,8 +1148,13 @@
"description": "صداگذاری را مستقیماً از روی متن خود در استودیو بسازید." "description": "صداگذاری را مستقیماً از روی متن خود در استودیو بسازید."
}, },
"componentsStudioSidebarColorsCustomTab": { "componentsStudioSidebarColorsCustomTab": {
"mainColor": "رنگ اصلی", "mainColor": "پس‌زمینه",
"additionalColor": "رنگ مکمل", "additionalColor": "رنگ اصلی",
"secondaryColor": "رنگ دوم",
"textColor": "رنگ متن",
"themePresets": "تم‌ها",
"applyThemePreset": "اعمال تم {name}",
"applyTheme": "اعمال تم",
"applyToAllScenes": "اعمال به همه صحنه‌ها" "applyToAllScenes": "اعمال به همه صحنه‌ها"
}, },
"componentsStudioSidebarColorsPalettesTab": { "componentsStudioSidebarColorsPalettesTab": {
@@ -1086,6 +1189,14 @@
"replaceImage": "جایگزینی تصویر", "replaceImage": "جایگزینی تصویر",
"uploadImage": "بارگذاری تصویر" "uploadImage": "بارگذاری تصویر"
}, },
"componentsStudioSidebarBlockFieldForm": {
"panelTitle": "ویرایش صحنه",
"emptyState": "این صحنه فیلد قابل‌ویرایشی ندارد.",
"fieldFallback": "فیلد {index}",
"textPlaceholder": "اینجا بنویسید…",
"replaceImage": "جایگزینی تصویر",
"uploadImage": "بارگذاری تصویر"
},
"componentsStudioSidebarTransitionsSidebarContent": { "componentsStudioSidebarTransitionsSidebarContent": {
"heading": "ترانزیشن‌ها", "heading": "ترانزیشن‌ها",
"randomTransition": "ترانزیشن تصادفی", "randomTransition": "ترانزیشن تصادفی",
@@ -1126,7 +1237,10 @@
"deleteScene": "حذف {name}", "deleteScene": "حذف {name}",
"resizeSceneDuration": "تغییر مدت زمان {name}", "resizeSceneDuration": "تغییر مدت زمان {name}",
"sceneNameLabel": "نام صحنه", "sceneNameLabel": "نام صحنه",
"doubleClickToRename": "برای تغییر نام، دوبار کلیک کنید" "doubleClickToRename": "برای تغییر نام، دوبار کلیک کنید",
"reorderScene": "جابه‌جایی {name}",
"durationLabel": "مدت صحنه (ثانیه)",
"secondsUnit": "ث"
}, },
"componentsStudioTimelineSceneThumbnailStrip": { "componentsStudioTimelineSceneThumbnailStrip": {
"browseScenes": "مرور صحنه‌ها", "browseScenes": "مرور صحنه‌ها",
@@ -1166,6 +1280,8 @@
"transitions": "گذارها", "transitions": "گذارها",
"font": "فونت", "font": "فونت",
"myWatermark": "واترمارک من", "myWatermark": "واترمارک من",
"timeline": "خط زمان",
"preview": "پیش‌نمایش",
"toolsNavLabel": "ابزارهای استودیو", "toolsNavLabel": "ابزارهای استودیو",
"guideMe": "راهنمایی‌ام کن", "guideMe": "راهنمایی‌ام کن",
"guideComingSoon": "👋 راهنما به‌زودی ارائه می‌شود!", "guideComingSoon": "👋 راهنما به‌زودی ارائه می‌شود!",
+33
View File
@@ -2,6 +2,37 @@ import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
/**
* Hosts the Next.js image optimizer is allowed to fetch from. Without these, every
* remote <Image> (MinIO uploads/avatars/template art) returns HTTP 400. Derived from
* NEXT_PUBLIC_MINIO_URL (baked at build) plus the dev MinIO hosts.
*/
function imageRemotePatterns() {
const patterns = [];
const seen = new Set();
const add = (protocol, hostname, port) => {
if (!hostname) return;
const key = `${protocol}//${hostname}:${port}`;
if (seen.has(key)) return;
seen.add(key);
patterns.push({ protocol, hostname, port: port || "", pathname: "/**" });
};
const url = process.env.NEXT_PUBLIC_MINIO_URL;
if (url) {
try {
const u = new URL(url);
add(u.protocol.replace(":", ""), u.hostname, u.port);
} catch {
/* ignore malformed env */
}
}
// dev / docker fallbacks
add("http", "172.28.144.1", "9000");
add("http", "localhost", "9000");
add("http", "minio", "9000");
return patterns;
}
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: "standalone", output: "standalone",
@@ -25,6 +56,8 @@ const nextConfig = {
return config; return config;
}, },
images: { images: {
// Allow the image optimizer to fetch MinIO uploads (avatars, template art, …).
remotePatterns: imageRemotePatterns(),
// Placeholder art is now a same-origin SVG from /api/placeholder (offline-safe). // Placeholder art is now a same-origin SVG from /api/placeholder (offline-safe).
// dangerouslyAllowSVG only ever serves our own generated gradients — never user // dangerouslyAllowSVG only ever serves our own generated gradients — never user
// uploads — and the CSP + attachment disposition neutralise any script content. // uploads — and the CSP + attachment disposition neutralise any script content.
BIN
View File
Binary file not shown.
+123
View File
@@ -36,10 +36,13 @@
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"next": "14.2.35", "next": "14.2.35",
"next-intl": "^4.12.0", "next-intl": "^4.12.0",
"plyr": "^3.7.8",
"plyr-react": "^5.3.0",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "^7.76.0", "react-hook-form": "^7.76.0",
"react-konva": "^18.2.16", "react-konva": "^18.2.16",
"react-multi-date-picker": "^4.5.2",
"react-rnd": "^10.5.3", "react-rnd": "^10.5.3",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"use-image": "^1.1.4", "use-image": "^1.1.4",
@@ -4001,6 +4004,17 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/core-util-is": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -4041,6 +4055,12 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "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": { "node_modules/damerau-levenshtein": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -6269,6 +6289,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -7004,6 +7030,44 @@
"node": ">= 6" "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": { "node_modules/po-parser": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz", "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
@@ -7263,6 +7327,12 @@
], ],
"license": "MIT" "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": { "node_modules/re-resizable": {
"version": "6.11.2", "version": "6.11.2",
"resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.11.2.tgz", "resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.11.2.tgz",
@@ -7285,6 +7355,29 @@
"node": ">=0.10.0" "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",
"integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==",
"license": "MIT"
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -7312,6 +7405,16 @@
"react-dom": ">= 16.3.0" "react-dom": ">= 16.3.0"
} }
}, },
"node_modules/react-element-popper": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/react-element-popper/-/react-element-popper-2.1.7.tgz",
"integrity": "sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-hook-form": { "node_modules/react-hook-form": {
"version": "7.76.0", "version": "7.76.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.76.0.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.76.0.tgz",
@@ -7365,6 +7468,20 @@
"react-dom": ">=18.0.0" "react-dom": ">=18.0.0"
} }
}, },
"node_modules/react-multi-date-picker": {
"version": "4.5.2",
"resolved": "https://registry.npmjs.org/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz",
"integrity": "sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==",
"license": "MIT",
"dependencies": {
"react-date-object": "^2.1.8",
"react-element-popper": "^2.1.6"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-reconciler": { "node_modules/react-reconciler": {
"version": "0.29.2", "version": "0.29.2",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz",
@@ -8667,6 +8784,12 @@
"punycode": "^2.1.0" "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": { "node_modules/use-callback-ref": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
+3
View File
@@ -37,10 +37,13 @@
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"next": "14.2.35", "next": "14.2.35",
"next-intl": "^4.12.0", "next-intl": "^4.12.0",
"plyr": "^3.7.8",
"plyr-react": "^5.3.0",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "^7.76.0", "react-hook-form": "^7.76.0",
"react-konva": "^18.2.16", "react-konva": "^18.2.16",
"react-multi-date-picker": "^4.5.2",
"react-rnd": "^10.5.3", "react-rnd": "^10.5.3",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"use-image": "^1.1.4", "use-image": "^1.1.4",
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 962 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 660 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More