diff --git a/services/remotion/docs/TEMPLATE_BRIEF.md b/services/remotion/docs/TEMPLATE_BRIEF.md index 6167b6a..6d44f7f 100644 --- a/services/remotion/docs/TEMPLATE_BRIEF.md +++ b/services/remotion/docs/TEMPLATE_BRIEF.md @@ -52,6 +52,32 @@ Each round ≈ one `AskUserQuestion` call (≤4 questions), mostly multiple-choi --- +## The reference round — lock the look (runs with Round 2) + +Before **any** building, lock the art direction against real visuals. Two modes, used together: + +- **You provide** — I ask: *"Got references for this? Paste them (+ one line of why)."* I extract + the DNA into [`references/TASTE_PROFILE.md`](../references/TASTE_PROFILE.md), so the build + inherits *your* taste, not my defaults. +- **I suggest** — for the chosen type I propose **2–4 named style directions** AND **mock each up + as a cheap still / SVG** (no external images needed), so you pick from real visuals — not words. + I can also **web-search** live examples / asset packs / current trends and **link** them for you + to open and react to. + +**Output:** a locked **direction** (style + palette + motifs + reference notes) written into the +Spec's `art` block and appended to the taste board. Then the gate continues: build → **cheap +still → you approve** → full render. + +**Honest constraints:** +- I can web-search and *link* real references, but rendering arbitrary external images inline is + unreliable from this environment — so the dependable "suggest" engine is **me mocking the + directions myself** (as today's storyboards/characters were). Web search supplements, doesn't + replace it. +- **Footage:** I suggest CC0 sources + search/link, but footage is **vendored-CC0** (manual fetch) + or the **editable-backdrop** architecture — we can't AI-generate it (no GPU). Flag this per brief. + +--- + ## Template Spec (the output) The Q&A produces this structure, saved to `services/remotion/briefs/.md`: diff --git a/services/remotion/references/README.md b/services/remotion/references/README.md new file mode 100644 index 0000000..f2cbcd8 --- /dev/null +++ b/services/remotion/references/README.md @@ -0,0 +1,34 @@ +# references/ — the taste-capture drop zone + +Drop design references here so they become **codified taste**, not one-off chat images. + +- `TASTE_PROFILE.md` — the living design contract. I distill the recurring **design DNA** + from everything in this folder and consult it on every build. +- `characters/` — drop **character pack files** here (PNG cutouts, SVG, `.blend`). I vendor + the usable ones into the asset library (honoring the license firewall) and build templates + *with* them — rather than redrawing from scratch. +- `boards/` — optional moodboards / style references grouped by topic. + +When you add something, a one-line "why I like it" (the line, the palette, the mood, the +attitude) sharpens the profile far more than the image alone. + +## The daily loop (our ritual) + +Paste taste images (or a "I love / I hate this") any day. Each drop runs the loop: + +1. **You paste** — references + one line of *why* (style / palette / mood / attitude). +2. **I extract** the design DNA → update `TASTE_PROFILE.md` (the board grows, the through-line + sharpens) + a memory so it survives across sessions. +3. **I apply** it → upgrade the shared **kits** (backgrounds, type scale, props, composition) + and the `flat-artist` skill so the *actual output* moves toward your taste. +4. **I prove** it → a cheap still / before-after so you can see the move; you approve or reject, + which sharpens the profile again. + +Two modes so it's sustainable: +- **Calibration days** (most days) — just steps 1–2. Cheap. Absorb the DNA, grow the board. +- **Apply passes** (periodic) — steps 3–4. Roll the accumulated taste into the kits/blocks so + the whole catalogue jumps a level at once. + +What actually compounds: not my raw drawing (that's fixed), but the **system** — the codified +profile, the reusable kits, the prop/character libraries, the skill rules. That gets richer every +day, so the output keeps improving even though the base model doesn't. diff --git a/services/remotion/references/TASTE_PROFILE.md b/services/remotion/references/TASTE_PROFILE.md new file mode 100644 index 0000000..1875691 --- /dev/null +++ b/services/remotion/references/TASTE_PROFILE.md @@ -0,0 +1,86 @@ +# Taste Profile — character & illustration + +> The living design contract. References go in, recurring **design DNA** comes out, and +> every build consults this file. The vaguer the input, the weaker the steer; the more +> concrete (actual files, exact palettes, "I like X because Y"), the sharper. +> +> How it persists: this doc + the `feedback-character-design-taste` memory + the +> `flat-artist` skill. It compounds — every approve/reject sharpens it. + +--- + +## Reference board + +### 2026-06-24 — first character drop (4 refs) + +**R1 — 3D clay / toy characters** ("ANONYMOUZ", *Blender + PNG files*) +Rounded soft-clay 3D, **minimal or featureless faces** (sunglasses, simple noses), strongly +**diverse + inclusive** (skin tones, ages, a wheelchair user, body types), everyday activities +(camera, gym, skate, gaming, shopping), **bright casual** clothing. The popular Gumroad/UI8 +"3D casual character" look. + +**R2 — bold flat vector** (winter / sports pack) +**Chunky confident shapes**, **saturated primaries** (red / blue / yellow / navy) on light-blue +panels, **dynamic athletic poses**, minimal faces (often hidden by goggles/helmets), thick forms. +Alegria-adjacent but **bolder and more saturated**. + +**R3 — modern thick-outline cartoon** (sunglasses kid, sunset) +**Thick black outlines**, flat cel fills, **warm gradient backgrounds** (orange→pink), chill/cool +mood, simple expressive features, light environmental detail (wires/poles). Contemporary "cool" +cartoon. + +**R4 — NFT / streetwear bold cartoon** (bearded, teal skin, surreal companions) +**Thick black outlines**, **hyper-saturated flat fills**, surreal/edgy characters with attitude, +solid color backgrounds. Doodles / Cool-Cats-adjacent. + +--- + +## Core taste signal (the through-line) + +Across all four, consistently: + +- **Bold & saturated** — strong, confident color. **Not** muted, pastel, or washed-out. +- **Thick outlines** (in the 2D refs) — clean, heavy black line. +- **Minimal / stylized faces** — features simplified, abstracted, or hidden (shades, goggles). +- **Clean confident shapes** — rounded, deliberate forms (3D clay or flat). +- **Attitude & personality** — cool, expressive, characterful. **Not** corporate-bland. +- **Modern / on-trend** — every ref is a *current* style (3D clay, bold flat, NFT cartoon). +- **Diverse & inclusive** — especially R1. + +## Anti-patterns (what to AVOID — the look that failed before) + +- Muted / pastel / desaturated palettes. +- Soft, thin, timid line work; vague forms. +- Over-detailed, "trying to be realistic" faces → reads amateur ("the 5-year-old"). +- Corporate flat-design blandness with no personality. + +--- + +## Sourcing strategy (honest path to this quality) + +These references are **purchasable / artist asset packs**, not styles I can reliably hand-redraw +at this bar. The reliable path: + +1. **Vendor the actual packs** the user owns. R1 even ships **PNG files** → those cutouts drop + straight into the 2.5D engine as character layers (fastest win). Honor the license firewall + (`docs/ASSET_LIBRARY.md`, `public/illustrations/assets.json`). +2. **Build motion / composition / templates around vendored characters** — that's our strength. + **Don't** redraw characters from scratch (that's the hand-code ceiling). +3. For any character I *do* author, match the through-line: **thick outline + saturated fill + + minimal face + confident shape.** + +## Feasibility per style (in our engine) + +| Style | Path | +|---|---| +| R1 3D clay | **Blender** for the source (it literally ships `.blend`); **PNG cutouts vendor directly** into 2.5D today. Three.js can *approach* soft-clay (rounded geo + soft mat) but the polished look = Blender. | +| R2 bold flat | **Matchable as SVG** — thick shapes + saturated fills; best **vendored** as a pack for consistency/volume. | +| R3 thick-outline cartoon | **Matchable as SVG** (heavy stroke + flat cel + gradient bg). Volume → vendor. | +| R4 NFT bold cartoon | **Matchable as SVG**; surreal characters are hard to author at scale → **vendor**. | + +--- + +## Status / open questions +- [ ] Which style is the **primary** direction (or several as tiers)? +- [ ] Does the user **own the files** for these packs (to vendor), or are they inspiration only? +- [ ] Drop pack files into `references/characters/` → I vendor + ledger them. diff --git a/services/remotion/src/Root.tsx b/services/remotion/src/Root.tsx index 66eabac..288a4ec 100644 --- a/services/remotion/src/Root.tsx +++ b/services/remotion/src/Root.tsx @@ -2,11 +2,15 @@ import { Composition } from "remotion"; import { ASPECTS } from "./lib/aspect"; import { TEMPLATES } from "./templates"; import { Three3DTest } from "./compositions/Three3DTest"; +import { YaldaSofreh3D } from "./compositions/YaldaSofreh3D"; +import { HeroDemo } from "./compositions/HeroDemo"; +import { IGPromoDirections, igPromoSchema } from "./compositions/IGPromoDirections"; +import { IGProfileMock } from "./compositions/IGProfileMock"; import { AssetSheet } from "./compositions/AssetSheet"; import { StoryScenes, STORY_SCENES_DURATION } from "./compositions/StoryScenes"; import { FlexStory, flexStorySchema, flexStoryDefaults, calcFlexStoryMetadata } from "./compositions/FlexStory"; import { LogoMotion3D, logoMotion3DSchema, logoMotion3DDefaults } from "./compositions/LogoMotion3D"; -import { CHARACTER_JOURNEY } from "./scenes/presets"; +import { CHARACTER_JOURNEY, INSTAGRAM_PROMO } from "./scenes/presets"; import { IlluminatedCircles, illuminatedCirclesSchema, @@ -133,6 +137,51 @@ export const RemotionRoot: React.FC = () => { height={720} /> + {/* Design-quality before/after demo (rich bg + big type + bold object) */} + {ASPECTS.map((a) => ( + + ))} + + {/* Instagram profile mock — realistic light-theme page (gate still) */} + + + {/* Instagram promo — reference round (3 style directions to pick from) */} + + + {/* Low-poly Yalda sofreh — reference-match challenge */} + + {/* Dev preview: vendored CC0 character library (not a customer template) */} { /> ))} + {/* InstagramPromo — "follow our channel" template (realistic IG-light page). */} + {ASPECTS.map((a) => ( + + ))} + {/* Branded templates — each registered in all three aspects. A template may supply a dedicated component per aspect (componentsByAspect) when its design differs structurally; otherwise the shared `component` adapts diff --git a/services/remotion/src/compositions/FlexStory.tsx b/services/remotion/src/compositions/FlexStory.tsx index 7850a5e..d1fb633 100644 --- a/services/remotion/src/compositions/FlexStory.tsx +++ b/services/remotion/src/compositions/FlexStory.tsx @@ -26,6 +26,7 @@ export const flexStorySchema = z.object({ music: z.string().optional(), // path/url of the music bed; "" = silent musicVolume: z.number().optional(), sfx: z.boolean().optional(), // transition whoosh + outro chime + finish: z.boolean().optional(), // cinematic grade + FinishPass (default on; off = clean/light) ...colorSchema, }); type Props = z.infer; @@ -67,6 +68,7 @@ export const FlexStory: React.FC = (props) => { const music = props.music === undefined ? "audio/music-ambient.mp3" : props.music; const musicVolume = props.musicVolume ?? 0.6; const sfx = props.sfx ?? true; + const finish = props.finish ?? true; // Precompute each scene's start frame + duration (shared by visuals + SFX). const starts: number[] = []; @@ -79,7 +81,7 @@ export const FlexStory: React.FC = (props) => { }); return ( - + {music ? ); }; diff --git a/services/remotion/src/compositions/HeroDemo.tsx b/services/remotion/src/compositions/HeroDemo.tsx new file mode 100644 index 0000000..25d2f9c --- /dev/null +++ b/services/remotion/src/compositions/HeroDemo.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion"; +import { useLayout } from "../lib/aspect"; +import { FONT } from "../lib/fonts"; +import { hexToRgba, mixHex } from "../lib/anim"; +import { Grain, Vignette } from "../scenes/chrome"; + +/** + * HeroDemo — the "after" for the design-quality fix: a DESIGNED hero, not text on + * a void. Three deliberate fixes vs the calm default: + * 1. Background = layered depth (gradient + big soft glows + a bold patterned panel) + * with real atmosphere, instead of 2 ghost blobs. + * 2. Type = a proper video scale — title huge, subtitle big + high-contrast. + * 3. A bold composed focal OBJECT (the play disc + orbit + mini cards), thick-outline + * saturated, arranged by thirds — so the frame reads as a composition. + */ +const C = { kicker: "فلت‌رندر", title: "ویدیوهای حرفه‌ای بسازید", subtitle: "قالب‌های آمادهٔ فارسی، رندر ابری در چند دقیقه" }; +const COL = { accent: "#ff5a3c", secondary: "#7c5cff", bg: "#11132a", text: "#ffffff" }; + +export const HeroDemo: React.FC = () => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const L = useLayout(); + const ink = mixHex(COL.bg, "#000010", 0.55); + const stroke = mixHex(COL.bg, "#05060f", 0.6); + + const titleSp = spring({ frame: frame - 4, fps, config: { damping: 18, stiffness: 110 } }); + const titleY = interpolate(titleSp, [0, 1], [L.vmin(50), 0]); + const subOp = interpolate(frame, [16, 34], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + const objSp = spring({ frame: frame - 2, fps, config: { damping: 14, stiffness: 90 } }); + const float = Math.sin(frame / 26) * L.vmin(10); + + // RTL editorial split: bold object on the LEFT, right-aligned text on the RIGHT. + const objSize = L.pick(L.vmin(470), L.vmin(620), L.vmin(680)); + const objLeft = L.pick(L.vmin(120), L.vmin(190), L.vmin(150)); + const objTop = L.pick(L.vmin(305), L.vmin(150), L.vmin(1100)); + + return ( + + {/* 1 — background depth: big soft glows + a faint bold dot-grid panel */} +
+
+ + + {/* 2 — bold composed focal object: glow + orbit + play disc + two mini template cards */} +
+
+ + + + + + + + + {/* mini "template" cards floating on the disc */} +
+
+
+
+
+
+ + {/* 3 — type: right-aligned RTL editorial block, big + high-contrast */} + +
+ + {C.kicker} +
+
+ ویدیوهای حرفه‌ای بسازید +
+
+ {C.subtitle} +
+
+ + + + + + ); +}; diff --git a/services/remotion/src/compositions/IGProfileMock.tsx b/services/remotion/src/compositions/IGProfileMock.tsx new file mode 100644 index 0000000..dd9bb8c --- /dev/null +++ b/services/remotion/src/compositions/IGProfileMock.tsx @@ -0,0 +1,117 @@ +import React from "react"; +import { AbsoluteFill } from "remotion"; +import { FONT } from "../lib/fonts"; +import { hexToRgba } from "../lib/anim"; + +/** + * IGProfileMock — an authentic Instagram profile page (LIGHT theme) inside a phone, + * as the centrepiece of a "follow our page" promo. Real IG chrome: status bar, the + * username header, avatar + stats, bio, Follow/Message buttons, story highlights, + * the grid tabs and the posts grid. Everything here is an editable field later. + */ +const IGLOGO = "linear-gradient(45deg,#f09433,#e6683c,#dc2743,#cc2366,#bc1888)"; +const BLUE = "#0095f6"; +const HANDLE = "flat.studio"; +const NAME = "استودیو فلت"; +const CAT = "هنر و طراحی"; +const BIO = ["هر روز یک طرح تازه ✨", "آموزش، قالب و الهام برای طراحان", "سفارش و دانلود 👇"]; +const LINK = "flat.studio/shop"; +const HILITES = ["جدید", "قالب‌ها", "آموزش", "نمونه‌کار"]; +const POSTS = ["#ff5a3c", "#7c5cff", "#16b5a0", "#ffb23c", "#ef5da8", "#3aa0ff", "#ff7a59", "#4cd4b0", "#a06bff", "#ff5a3c", "#3aa0ff", "#ffb23c"]; + +const IgCamera: React.FC<{ s: number }> = ({ s }) => ( + + + + +); + +const Stat: React.FC<{ n: string; l: string }> = ({ n, l }) => ( +
+
{n}
+
{l}
+
+); + +export const IGProfileMock: React.FC = () => { + const S = 770; // phone screen inner width + const cell = (S - 12) / 3; + return ( + +
+
+ + {/* promo header: IG logo + headline */} +
+
+ +
Instagram
+
+
صفحهٔ ما را دنبال کنید
+
+ + {/* phone */} +
+
+ {/* status bar */} +
+ ۹:۴۱▮▮▮ WiFi ۸۴٪ +
+ {/* username header */} +
+
+ 🔒{HANDLE} +
+
+
+ {/* profile: avatar + stats */} +
+
+
🎨
+
+
+ +
+
+ {/* name + bio */} +
+
{NAME}
+
{CAT}
+ {BIO.map((b, i) =>
{b}
)} +
{LINK}
+
+ {/* buttons */} +
+
دنبال کردن
+
پیام
+
👤
+
+ {/* highlights */} +
+ {HILITES.map((h, i) => ( +
+
{["✨", "🗂️", "🎓", "🖼️"][i]}
+
{h}
+
+ ))} +
+ {/* tabs */} +
+ {["▦", "▶", "𓏬"].map((t, i) => ( +
{t}
+ ))} +
+ {/* posts grid */} +
+ {POSTS.map((c, i) => ( +
+
+ {i % 4 === 0 ? : i % 4 === 1 ? : null} +
+ ))} +
+
+
+ + ); +}; diff --git a/services/remotion/src/compositions/IGPromoDirections.tsx b/services/remotion/src/compositions/IGPromoDirections.tsx new file mode 100644 index 0000000..8d6e387 --- /dev/null +++ b/services/remotion/src/compositions/IGPromoDirections.tsx @@ -0,0 +1,127 @@ +import React from "react"; +import { z } from "zod"; +import { AbsoluteFill, useVideoConfig } from "remotion"; +import { FONT } from "../lib/fonts"; +import { hexToRgba } from "../lib/anim"; +import { Grain, Vignette } from "../scenes/chrome"; + +/** + * IGPromoDirections — the reference round for an Instagram channel promo. Three + * named style directions rendered as real stills so the look can be PICKED before + * we build the full template. 9:16 (Instagram-native). + * A — Device / phone-first (clean, trustworthy product look) + * B — Bold kinetic (energetic, on-trend, matches the bold taste) + * C — Premium glass (elegant, agency / dark) + */ +export const igPromoSchema = z.object({ variant: z.enum(["A", "B", "C"]) }); + +const IG = "linear-gradient(45deg,#f09433,#e6683c,#dc2743,#cc2366,#bc1888)"; +const C = { handle: "@flat.studio", name: "استودیو فلت", tagline: "هر روز یک طرح تازه", followers: "۲۴٫۸ هزار", cta: "دنبال کنید" }; +const GRID = ["#ff5a3c", "#7c5cff", "#16b5a0", "#ffb23c", "#ef5da8", "#3aa0ff", "#ff7a59", "#4cd4b0", "#a06bff"]; + +const StatCol: React.FC<{ n: string; l: string; dark?: boolean }> = ({ n, l, dark }) => ( +
+
{n}
+
{l}
+
+); + +const FollowBtn: React.FC<{ w?: number; big?: boolean }> = ({ w = 360, big }) => ( +
+ {C.cta} +
+); + +const Avatar: React.FC<{ size: number }> = ({ size }) => ( +
+
📷
+
+); + +const Grid: React.FC<{ cell: number; gap: number; radius?: number }> = ({ cell, gap, radius = 14 }) => ( +
+ {GRID.map((c, i) => ( +
+
+
+ ))} +
+); + +// ── A — phone-first ────────────────────────────────────────────────────────── +const VariantA: React.FC = () => ( + +
+
+
+
اینستاگرام
+
ما را دنبال کنید
+
+
+
+ +
{C.name}
+
{C.handle}
+
+ +
+ +
+
+
+ + +); + +// ── B — bold kinetic ───────────────────────────────────────────────────────── +const Card: React.FC<{ x: number; y: number; rot: number; c: string; s?: number }> = ({ x, y, rot, c, s = 230 }) => ( +
+
+
+
+
+); +const VariantB: React.FC = () => ( + +
+
+ + + +
+
{C.tagline}
+
{C.name}
+
{C.handle}
+
♥ {C.followers} دنبال‌کننده
+
+
+ +
+); + +// ── C — premium glass ──────────────────────────────────────────────────────── +const VariantC: React.FC = () => ( + +
+
+
+
به ما بپیوندید
+
+ +
{C.name}
+
{C.handle}
+
{C.tagline}
+
+ +
+
+
+
+ + +); + +export const IGPromoDirections: React.FC> = ({ variant }) => { + useVideoConfig(); + return variant === "A" ? : variant === "B" ? : ; +}; diff --git a/services/remotion/src/compositions/YaldaSofreh3D.tsx b/services/remotion/src/compositions/YaldaSofreh3D.tsx new file mode 100644 index 0000000..c2e0e5e --- /dev/null +++ b/services/remotion/src/compositions/YaldaSofreh3D.tsx @@ -0,0 +1,150 @@ +import React, { useMemo } from "react"; +import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion"; +import { ThreeCanvas } from "@remotion/three"; +import { useThree } from "@react-three/fiber"; +import { Environment, Lightformer } from "@react-three/drei"; +import { EffectComposer, Bloom, Vignette } from "@react-three/postprocessing"; +import * as THREE from "three"; + +// Challenge: match the low-poly isometric Yalda sofreh reference. Flat-shaded +// low-poly geometry in @remotion/three, isometric-ish camera, warm candle light + +// bloom, on a deep-red paisley ground. + +const Rig: React.FC = () => { + const { camera } = useThree(); + camera.position.set(7.5, 6.2, 7.5); + camera.lookAt(0, 0.7, 0); + camera.updateProjectionMatrix(); + return null; +}; + +const mat = (color: string, opts: Partial = {}) => ( + +); + +// a watermelon slice — extruded wedge (red flesh) + green rind edge + seeds +const Slice: React.FC<{ position: [number, number, number]; rotation: [number, number, number] }> = ({ position, rotation }) => { + const flesh = useMemo(() => { + const s = new THREE.Shape(); + s.moveTo(-0.7, 0); + s.lineTo(0.7, 0); + s.absarc(0, 0, 0.7, 0, Math.PI, false); + return new THREE.ExtrudeGeometry(s, { depth: 0.16, bevelEnabled: false }); + }, []); + return ( + + {mat("#e0322a")} + + + {mat("#2f7d3a")} + + {[[-0.3, 0.3], [0.3, 0.3], [0, 0.5], [-0.15, 0.15], [0.15, 0.15]].map(([x, y], i) => ( + + + {mat("#201018", { roughness: 0.4 })} + + ))} + + ); +}; + +const Pomegranate: React.FC<{ position: [number, number, number] }> = ({ position }) => ( + + + + {mat("#c0271f")} + + + + {mat("#8e2018")} + + +); + +const Candle: React.FC<{ position: [number, number, number] }> = ({ position }) => ( + + + + {mat("#f3ead2")} + + + + + + + +); + +export const YaldaSofreh3D: React.FC = () => { + const frame = useCurrentFrame(); + const { width, height } = useVideoConfig(); + const spin = frame * 0.0; + return ( + + + + + + + + + + + + {/* paisley-red ground */} + + + + + + {/* low table + cloth */} + + + {mat("#7a4a28")} + + {[[-2.3, 1.8], [2.3, 1.8], [-2.3, -1.8], [2.3, -1.8]].map(([x, z], i) => ( + + + {mat("#5e3820")} + + ))} + + + {mat("#1f5fa6")} + + + {/* mirror */} + + {mat("#8a5a2e")} + + + + {/* watermelon (low-poly) + slices */} + + + {mat("#1f6b2e")} + + + + + + + + + + + {/* Hafez book */} + + {mat("#6b3f1c")} + + + + + + + + + + + ); +}; diff --git a/services/remotion/src/scenes/blocks/IGFeed.tsx b/services/remotion/src/scenes/blocks/IGFeed.tsx new file mode 100644 index 0000000..3f4677b --- /dev/null +++ b/services/remotion/src/scenes/blocks/IGFeed.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { AbsoluteFill, Img, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion"; +import { FONT } from "../../lib/fonts"; +import { hexToRgba } from "../../lib/anim"; +import { IgGlows } from "./igkit"; +import type { BlockProps, SceneBlock } from "../types"; + +const PH = ["#ff5a3c", "#7c5cff", "#16b5a0", "#ffb23c", "#ef5da8", "#3aa0ff"]; +const resolve = (u: string) => (/^https?:\/\//.test(u) ? u : staticFile(u)); +const isImg = (u: string) => !!u && (/^https?:\/\//.test(u) || u.includes("/")); + +const IGFeed: React.FC = ({ data, colors, L, durationInFrames }) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const out = interpolate(frame, [durationInFrames - 10, durationInFrames], [1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + const capSp = spring({ frame, fps, config: { damping: 16, stiffness: 110 } }); + const posts = [data.post1, data.post2, data.post3, data.post4, data.post5, data.post6]; + const cell = L.pick(L.vmin(300), L.vmin(300), L.vmin(300)); + const gap = L.vmin(20); + + return ( + + + +
+ {data.caption} +
+
+ {posts.map((p, i) => { + const pop = spring({ frame: frame - 6 - i * 4, fps, config: { damping: 14, stiffness: 130 } }); + return ( +
+ {isImg(p) ? :
} +
+ ); + })} +
+ + + ); +}; + +export const IGFeedBlock: SceneBlock = { + id: "IGFeed", + label: "نمایش محتوا (شبکهٔ پست‌ها)", + component: IGFeed, + fields: [ + { key: "caption", label: "عنوان بخش", type: "text", default: "محتوای ما را ببینید" }, + { key: "post1", label: "پست ۱", type: "image", default: "" }, + { key: "post2", label: "پست ۲", type: "image", default: "" }, + { key: "post3", label: "پست ۳", type: "image", default: "" }, + { key: "post4", label: "پست ۴", type: "image", default: "" }, + { key: "post5", label: "پست ۵", type: "image", default: "" }, + { key: "post6", label: "پست ۶", type: "image", default: "" }, + ], + defaultDurationSec: 4, + minDurationSec: 2, + maxDurationSec: 8, +}; diff --git a/services/remotion/src/scenes/blocks/IGFollowCTA.tsx b/services/remotion/src/scenes/blocks/IGFollowCTA.tsx new file mode 100644 index 0000000..b9e5067 --- /dev/null +++ b/services/remotion/src/scenes/blocks/IGFollowCTA.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion"; +import { FONT } from "../../lib/fonts"; +import { hexToRgba } from "../../lib/anim"; +import { IgGlows, IgCamera, IG_GRAD } from "./igkit"; +import type { BlockProps, SceneBlock } from "../types"; + +const IGFollowCTA: React.FC = ({ data, colors, L, durationInFrames }) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const out = interpolate(frame, [durationInFrames - 10, durationInFrames], [1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + const headSp = spring({ frame, fps, config: { damping: 16, stiffness: 110 } }); + const handleSp = spring({ frame: frame - 8, fps, config: { damping: 15, stiffness: 110 } }); + const btnSp = spring({ frame: frame - 16, fps, config: { damping: 13, stiffness: 120 } }); + // tap the button ~70% through: a quick press blip, then flip to "followed". + const tapAt = Math.round(durationInFrames * 0.62); + const press = interpolate(frame, [tapAt - 3, tapAt, tapAt + 4], [1, 0.92, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + const followed = frame >= tapAt; + const footOp = interpolate(frame, [tapAt + 6, tapAt + 18], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + + return ( + + + +
+
+ {data.headline} +
+
+ {data.handle} +
+
+
+ {followed ? <>✓ {data.followedLabel} : <>+ {data.buttonLabel}} +
+
+
+ {data.footer} +
+
+
+ ); +}; + +export const IGFollowCTABlock: SceneBlock = { + id: "IGFollowCTA", + label: "فراخوان دنبال‌کردن", + component: IGFollowCTA, + fields: [ + { key: "headline", label: "تیتر فراخوان", type: "text", default: "همین حالا دنبال کنید" }, + { key: "handle", label: "آیدی صفحه", type: "text", default: "@flat.studio" }, + { key: "buttonLabel", label: "متن دکمه", type: "text", default: "دنبال کردن" }, + { key: "followedLabel", label: "متن بعد از دنبال", type: "text", default: "دنبال شد" }, + { key: "footer", label: "زیرنویس", type: "text", default: "لینک در بایو 👆" }, + ], + defaultDurationSec: 3, + minDurationSec: 2, + maxDurationSec: 5, +}; diff --git a/services/remotion/src/scenes/blocks/IGIntro.tsx b/services/remotion/src/scenes/blocks/IGIntro.tsx new file mode 100644 index 0000000..4397205 --- /dev/null +++ b/services/remotion/src/scenes/blocks/IGIntro.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion"; +import { FONT } from "../../lib/fonts"; +import { hexToRgba } from "../../lib/anim"; +import { IgGlows, IgWordmark } from "./igkit"; +import type { BlockProps, SceneBlock } from "../types"; + +const IGIntro: React.FC = ({ data, colors, L, durationInFrames }) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const out = interpolate(frame, [durationInFrames - 10, durationInFrames], [1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + const logo = spring({ frame, fps, config: { damping: 13, stiffness: 110 } }); + const headSp = spring({ frame: frame - 8, fps, config: { damping: 16, stiffness: 110 } }); + const subOp = interpolate(frame, [20, 36], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + + return ( + + + +
+
+ {data.badge} +
+
+ {data.headline} +
+
+ {data.subtitle} +
+
+
+ ); +}; + +export const IGIntroBlock: SceneBlock = { + id: "IGIntro", + label: "شروع (لوگوی اینستاگرام)", + component: IGIntro, + fields: [ + { key: "badge", label: "نشان", type: "text", default: "اینستاگرام" }, + { key: "headline", label: "تیتر", type: "text", default: "صفحهٔ ما را دنبال کنید" }, + { key: "subtitle", label: "زیرعنوان", type: "text", default: "هر روز یک طرح تازه", multiline: true }, + ], + defaultDurationSec: 3, + minDurationSec: 2, + maxDurationSec: 5, +}; diff --git a/services/remotion/src/scenes/blocks/IGProfile.tsx b/services/remotion/src/scenes/blocks/IGProfile.tsx new file mode 100644 index 0000000..2936dd8 --- /dev/null +++ b/services/remotion/src/scenes/blocks/IGProfile.tsx @@ -0,0 +1,141 @@ +import React from "react"; +import { AbsoluteFill, Img, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion"; +import { FONT } from "../../lib/fonts"; +import { hexToRgba } from "../../lib/anim"; +import { IgGlows, IgWordmark, IG_GRAD, IG_BLUE } from "./igkit"; +import type { BlockProps, SceneBlock } from "../types"; + +const PH = ["#ff5a3c", "#7c5cff", "#16b5a0", "#ffb23c", "#ef5da8", "#3aa0ff", "#ff7a59", "#4cd4b0", "#a06bff"]; +const resolve = (u: string) => (/^https?:\/\//.test(u) ? u : staticFile(u)); +const isImg = (u: string) => !!u && (/^https?:\/\//.test(u) || u.includes("/")); + +const IGProfile: React.FC = ({ data, colors, L, durationInFrames }) => { + const frame = useCurrentFrame(); + const { fps, width, height } = useVideoConfig(); + const out = interpolate(frame, [durationInFrames - 10, durationInFrames], [1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + + // phone sized to fit the frame; everything inside scales off the screen width. + const phoneH = height * L.pick(0.78, 0.8, 0.74); + const phoneW = Math.min(phoneH * 0.49, width * 0.86); + const bezel = phoneW * 0.046; + const screenW = phoneW - bezel * 2; + const u = screenW / 770; + const px = (n: number) => n * u; + const cell = (screenW - px(12)) / 3; + + const rise = spring({ frame, fps, config: { damping: 18, stiffness: 90 } }); + const phoneY = interpolate(rise, [0, 1], [height * 0.06, 0]); + const ring = 1 + Math.sin(frame / 12) * 0.025; + + return ( + + + + {/* promo header */} +
+ +
{data.headline}
+
+ + {/* phone */} +
+
+ {/* status bar */} +
+ ۹:۴۱▮▮▮ WiFi ۸۴٪ +
+ {/* username header */} +
+
+ 🔒{data.handle} +
+
+
+ {/* avatar + stats */} +
+
+
+ {isImg(data.avatar) ? : "🎨"} +
+
+
+ {[[data.posts, "پست"], [data.followers, "دنبال‌کننده"], [data.following, "دنبال‌شده"]].map(([n, l], i) => ( +
+
{n}
+
{l}
+
+ ))} +
+
+ {/* name + bio */} +
+
{data.name}
+
{data.category}
+ {[data.bio1, data.bio2, data.bio3].filter(Boolean).map((b, i) =>
{b}
)} +
{data.link}
+
+ {/* buttons */} +
+
{data.followLabel}
+
{data.messageLabel}
+
👤
+
+ {/* highlights */} +
+ {[data.hi1, data.hi2, data.hi3, data.hi4].map((h, i) => ( +
+
{["✨", "🗂️", "🎓", "🖼️"][i]}
+
{h}
+
+ ))} +
+ {/* tabs */} +
+ {["▦", "▶", "𓏬"].map((t, i) => ( +
{t}
+ ))} +
+ {/* posts grid */} +
+ {PH.map((c, i) => ( +
+
+ {i % 4 === 0 ? : null} +
+ ))} +
+
+
+ + + ); +}; + +export const IGProfileBlock: SceneBlock = { + id: "IGProfile", + label: "صفحهٔ اینستاگرام (واقعی)", + component: IGProfile, + fields: [ + { key: "headline", label: "تیتر بالای صفحه", type: "text", default: "صفحهٔ ما را دنبال کنید" }, + { key: "handle", label: "آیدی", type: "text", default: "flat.studio" }, + { key: "name", label: "نام صفحه", type: "text", default: "استودیو فلت" }, + { key: "category", label: "دسته‌بندی", type: "text", default: "هنر و طراحی" }, + { key: "bio1", label: "بیو — خط ۱", type: "text", default: "هر روز یک طرح تازه ✨" }, + { key: "bio2", label: "بیو — خط ۲", type: "text", default: "آموزش، قالب و الهام برای طراحان" }, + { key: "bio3", label: "بیو — خط ۳", type: "text", default: "سفارش و دانلود 👇" }, + { key: "link", label: "لینک", type: "text", default: "flat.studio/shop" }, + { key: "posts", label: "تعداد پست", type: "text", default: "۳۲۰" }, + { key: "followers", label: "دنبال‌کننده", type: "text", default: "۲۴٫۸ هزار" }, + { key: "following", label: "دنبال‌شده", type: "text", default: "۱۸۰" }, + { key: "hi1", label: "هایلایت ۱", type: "text", default: "جدید" }, + { key: "hi2", label: "هایلایت ۲", type: "text", default: "قالب‌ها" }, + { key: "hi3", label: "هایلایت ۳", type: "text", default: "آموزش" }, + { key: "hi4", label: "هایلایت ۴", type: "text", default: "نمونه‌کار" }, + { key: "followLabel", label: "متن دکمهٔ دنبال", type: "text", default: "دنبال کردن" }, + { key: "messageLabel", label: "متن دکمهٔ پیام", type: "text", default: "پیام" }, + { key: "avatar", label: "تصویر پروفایل", type: "image", default: "" }, + ], + defaultDurationSec: 5, + minDurationSec: 3, + maxDurationSec: 8, +}; diff --git a/services/remotion/src/scenes/blocks/IGStats.tsx b/services/remotion/src/scenes/blocks/IGStats.tsx new file mode 100644 index 0000000..ffed6fb --- /dev/null +++ b/services/remotion/src/scenes/blocks/IGStats.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion"; +import { FONT } from "../../lib/fonts"; +import { hexToRgba } from "../../lib/anim"; +import { IgGlows, IG_GRAD } from "./igkit"; +import type { BlockProps, SceneBlock } from "../types"; + +const faToEn = (s: string) => s.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/٫/g, ".").replace(/[٬,]/g, ""); +const enToFa = (s: string) => s.replace(/[0-9]/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[+d]).replace(/\./g, "٫"); +/** Split a leading number (Persian or latin) from its suffix, animate it up. */ +const animStat = (target: string, t: number): string => { + const m = target.match(/^[\s]*([\d۰-۹.,٬٫]+)(.*)$/); + if (!m) return target; + const num = parseFloat(faToEn(m[1])); + if (!isFinite(num)) return target; + const v = num * t; + const txt = num % 1 !== 0 ? v.toFixed(1) : Math.round(v).toLocaleString("en-US"); + return enToFa(txt) + m[2]; +}; + +const Big: React.FC<{ v: string; l: string; L: BlockProps["L"]; t: number; accent: string }> = ({ v, l, L, t, accent }) => ( +
+
{animStat(v, t)}
+
{l}
+
+); + +const IGStats: React.FC = ({ data, colors, L, durationInFrames }) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const out = interpolate(frame, [durationInFrames - 10, durationInFrames], [1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + const t = interpolate(spring({ frame, fps, config: { damping: 20, stiffness: 60 } }), [0, 1], [0, 1]); + const side = (i: number) => spring({ frame: frame - 14 - i * 6, fps, config: { damping: 16, stiffness: 110 } }); + const proofOp = interpolate(frame, [26, 42], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + const dark = colors.textColor; + + return ( + + + + +
+ {[[data.stat2Value, data.stat2Label, 0], [data.stat3Value, data.stat3Label, 1]].map(([v, l, i]) => ( +
+
{animStat(v as string, t)}
+
{l as string}
+
+ ))} +
+
+ {data.proofLine} +
+
+
+ ); +}; + +export const IGStatsBlock: SceneBlock = { + id: "IGStats", + label: "اعتمادسازی (آمار صفحه)", + component: IGStats, + fields: [ + { key: "bigValue", label: "عدد اصلی", type: "text", default: "۲۴۸۰۰" }, + { key: "bigLabel", label: "برچسب عدد اصلی", type: "text", default: "دنبال‌کننده" }, + { key: "stat2Value", label: "آمار ۲ — مقدار", type: "text", default: "۱۲۰۰۰۰۰" }, + { key: "stat2Label", label: "آمار ۲ — برچسب", type: "text", default: "پسند" }, + { key: "stat3Value", label: "آمار ۳ — مقدار", type: "text", default: "۳۲۰" }, + { key: "stat3Label", label: "آمار ۳ — برچسب", type: "text", default: "پست" }, + { key: "proofLine", label: "جملهٔ اعتماد", type: "text", default: "به جمع هزاران دنبال‌کنندهٔ ما بپیوندید", multiline: true }, + ], + defaultDurationSec: 3, + minDurationSec: 2, + maxDurationSec: 6, +}; diff --git a/services/remotion/src/scenes/blocks/igkit.tsx b/services/remotion/src/scenes/blocks/igkit.tsx new file mode 100644 index 0000000..c4bf497 --- /dev/null +++ b/services/remotion/src/scenes/blocks/igkit.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import type { Layout } from "../../lib/aspect"; +import { hexToRgba } from "../../lib/anim"; + +/** Shared Instagram primitives used across the IG-promo scene blocks. */ +export const IG_GRAD = "linear-gradient(45deg,#f09433,#e6683c,#dc2743,#cc2366,#bc1888)"; +export const IG_BLUE = "#0095f6"; + +/** The IG camera glyph in the brand gradient. */ +export const IgCamera: React.FC<{ s: number; id?: string }> = ({ s, id = "iggrad" }) => ( + + + + + + + + + +); + +/** Camera glyph + "Instagram" wordmark (gradient). */ +export const IgWordmark: React.FC<{ L: Layout; id?: string }> = ({ L, id }) => ( +
+ +
Instagram
+
+); + +/** Soft IG-tinted glows for a clean light backdrop. */ +export const IgGlows: React.FC = () => ( + <> +
+
+ +); + +/** Convert "۲۴٫۸ هزار" style strings straight through; helper kept for parity. */ +export const faNum = (s: string) => s; diff --git a/services/remotion/src/scenes/presets.ts b/services/remotion/src/scenes/presets.ts index aa50084..9b98b59 100644 --- a/services/remotion/src/scenes/presets.ts +++ b/services/remotion/src/scenes/presets.ts @@ -21,3 +21,28 @@ const journeyScenes: SceneInstance[] = [ ]; export const CHARACTER_JOURNEY = { scenes: journeyScenes, ...warm }; + +/** + * InstagramPromo — a "follow our channel" promo built on the realistic light-theme + * IG profile. Flexible: add/duplicate/delete/reorder (e.g. more content scenes). + * finish:false keeps the IG-light look clean (no brand grade). + */ +const igScenes: SceneInstance[] = [ + { blockId: "IGIntro", durationSec: 3, props: { badge: "اینستاگرام", headline: "صفحهٔ ما را دنبال کنید", subtitle: "هر روز یک طرح تازه" } }, + { blockId: "IGProfile", durationSec: 5, props: { headline: "صفحهٔ ما را دنبال کنید", handle: "flat.studio", name: "استودیو فلت", category: "هنر و طراحی", bio1: "هر روز یک طرح تازه ✨", bio2: "آموزش، قالب و الهام برای طراحان", bio3: "سفارش و دانلود 👇", link: "flat.studio/shop", posts: "۳۲۰", followers: "۲۴٫۸ هزار", following: "۱۸۰", hi1: "جدید", hi2: "قالب‌ها", hi3: "آموزش", hi4: "نمونه‌کار", followLabel: "دنبال کردن", messageLabel: "پیام", avatar: "" } }, + { blockId: "IGFeed", durationSec: 4, props: { caption: "محتوای ما را ببینید", post1: "", post2: "", post3: "", post4: "", post5: "", post6: "" } }, + { blockId: "IGStats", durationSec: 3, props: { bigValue: "۲۴۸۰۰", bigLabel: "دنبال‌کننده", stat2Value: "۱۲۰۰۰۰۰", stat2Label: "پسند", stat3Value: "۳۲۰", stat3Label: "پست", proofLine: "به جمع هزاران دنبال‌کنندهٔ ما بپیوندید" } }, + { blockId: "IGFollowCTA", durationSec: 3, props: { headline: "همین حالا دنبال کنید", handle: "@flat.studio", buttonLabel: "دنبال کردن", followedLabel: "دنبال شد", footer: "لینک در بایو 👆" } }, +]; + +export const INSTAGRAM_PROMO = { + scenes: igScenes, + accentColor: "#dc2743", + secondaryColor: "#7c5cff", + backgroundColor: "#f7f4fa", + textColor: "#15151a", + music: "audio/music-ambient.mp3", + musicVolume: 0.6, + sfx: true, + finish: false, +}; diff --git a/services/remotion/src/scenes/registry.ts b/services/remotion/src/scenes/registry.ts index a9dc438..2082f03 100644 --- a/services/remotion/src/scenes/registry.ts +++ b/services/remotion/src/scenes/registry.ts @@ -12,6 +12,11 @@ import { StompBlock } from "./blocks/Stomp"; import { DeviceMockupBlock } from "./blocks/DeviceMockup"; import { ProductShowcaseBlock } from "./blocks/ProductShowcase"; import { PaperCutBlock } from "./blocks/PaperCut"; +import { IGIntroBlock } from "./blocks/IGIntro"; +import { IGProfileBlock } from "./blocks/IGProfile"; +import { IGFeedBlock } from "./blocks/IGFeed"; +import { IGStatsBlock } from "./blocks/IGStats"; +import { IGFollowCTABlock } from "./blocks/IGFollowCTA"; /** * The scene-block registry. A FlexStory template is an ordered list of these @@ -32,6 +37,11 @@ export const SCENE_BLOCKS: Record = { [StompBlock.id]: StompBlock, [DeviceMockupBlock.id]: DeviceMockupBlock, [PaperCutBlock.id]: PaperCutBlock, + [IGIntroBlock.id]: IGIntroBlock, + [IGProfileBlock.id]: IGProfileBlock, + [IGFeedBlock.id]: IGFeedBlock, + [IGStatsBlock.id]: IGStatsBlock, + [IGFollowCTABlock.id]: IGFollowCTABlock, }; export const BLOCK_LIST = Object.values(SCENE_BLOCKS);