diff --git a/services/remotion/docs/TEMPLATE_BRIEF.md b/services/remotion/docs/TEMPLATE_BRIEF.md new file mode 100644 index 0000000..6167b6a --- /dev/null +++ b/services/remotion/docs/TEMPLATE_BRIEF.md @@ -0,0 +1,90 @@ +# FlatRender — Template Brief & Creator Flow + +The single, repeatable entry point for creating a template. A **guided Q&A** captures +requirements into a structured **Template Spec**; the Spec drives the build pipeline +(see `flat-artist` skill + `ASSET_LIBRARY.md`). The Spec is the stable contract — today a +human fills it via Q&A and the Flat Artist hand-builds; later an AI prompt or an end-user +form can emit the same Spec into the same pipeline (internal-first, extensible). + +> **Engine model (confirmed):** a template is a **scene list**. Each scene is a reusable, +> typed **scene block** with its own content + **editable duration** (within a min/max +> range). A template ships in one of two structure modes: +> - **fixed** — a static set/order of scenes (user edits content + duration, can't add/remove/reorder). +> - **flexible** — user can **add / duplicate / delete / reorder** scenes and add as many as needed; empty scenes auto-skip. +> Every template renders in all three aspects (16:9 / 1:1 / 9:16), re-flowed not letterboxed, Persian-first. + +--- + +## The guided Q&A (5 rounds) + +Each round ≈ one `AskUserQuestion` call (≤4 questions), mostly multiple-choice with a +"recommended default" so it's fast. Run them in order. + +### Round 1 — Purpose & format +- **Type / use-case** — explainer/story · promo/sale · logo reveal · social/channel intro · greeting/seasonal · product/app showcase · quote · event invite · slideshow · countdown · testimonial · (any Renderforest-style type). +- **Aspect priority** — all three equally (default) · 9:16-first · 16:9-first. +- **Structure mode** — flexible (add/duplicate/delete/reorder) · fixed (static order). +- **Scene count & per-scene duration** — default scene count + the editable **duration range** per scene (e.g. default 3s, min 1s, max 6s). + +### Round 2 — Art direction +- **Visual style** — refined flat 2.5D (Remotion+Three) · 3D cinematic · minimal-luxury · kinetic typography. +- **Palette & mood** — sophisticated muted · dark premium + glow · warm editorial · vibrant · brand blue/violet · custom hex. +- **Characters** — CC0 Open-Peeps busts (vendored) · none · abstract · Humaaans full-body (needs manual fetch). +- **Environment/setting** — room/office · outdoor · abstract/studio · per-scene varied. + +### Round 3 — Content +- **Per-scene structure** — story beats · single hero message · feature list · before/after. +- **Copy** — provide now · Persian placeholders to refine. +- **Brand assets** — logo upload field? · image/screen upload field? · none. +- **Hook** — what happens in the first 1–2s (the scroll-stopper). + +### Round 4 — Motion / audio / finishing +- **Motion intensity & pacing** — calm · energetic · cinematic. +- **Audio** — music + SFX (beat-synced?) · silent. +- **Finishing** — grain · vignette · glow defaults (on/off). +- **Reference** — any link/inspiration to match. + +### Round 5 — Editability & taxonomy +- **Editable fields** — which text keys, colors, images, and whether scene-count is user-editable. +- **Taxonomy** — category · tags · slug. +- **Naming** — fa + en name/description. +- **Tier** — free · premium. + +--- + +## Template Spec (the output) + +The Q&A produces this structure, saved to `services/remotion/briefs/.md`: + +```jsonc +{ + "id": "CharacterPromo", // PascalCase comp id + "slug": "fr-character-promo", + "type": "promo", // Round 1 + "name_fa": "…", "name_en": "…", + "desc_fa": "…", "desc_en": "…", + "aspectPriority": "all", + "structure": "flexible", // "flexible" | "fixed" + "scene": { "defaultCount": 4, "durationDefaultSec": 3, "durationMinSec": 1, "durationMaxSec": 6 }, + "art": { "style": "flat-2.5d", "palette": "muted", "characters": "openpeeps", "environment": "room" }, + "blocks": [ // ordered scene blocks (defaults; flexible = user edits the list) + { "type": "intro", "role": "hook" }, + { "type": "feature", "role": "point" }, + { "type": "feature", "role": "point" }, + { "type": "cta", "role": "outro" } + ], + "content": { /* per-block editable keys -> Persian default values */ }, + "colors": { "accentColor": "#…", "secondaryColor": "#…", "backgroundColor": "#…", "textColor": "#…" }, + "assets": { "characters": ["dicebear/openpeeps-04"], "logoUpload": false, "imageUpload": false }, + "motion": { "intensity": "energetic", "audio": "music+sfx" }, + "finishing": { "grain": true, "vignette": true, "glow": false }, + "editable": { "text": true, "colors": true, "images": false, "sceneCount": true }, + "taxonomy": { "category": "…", "tags": ["…"], "tier": "premium" } +} +``` + +This Spec maps directly onto the build pipeline: `blocks` → kit scene-block components; +`content`/`colors` → Zod props (globally-unique keys) + the studio editable fields; +`scene.*` → per-scene duration range + structure mode; seeded via +`scripts/seed_remotion_templates.py` (`content.scenes` rows carry `default_duration_sec` +and `sort`/order). diff --git a/services/remotion/src/Root.tsx b/services/remotion/src/Root.tsx index 107989b..85e0a74 100644 --- a/services/remotion/src/Root.tsx +++ b/services/remotion/src/Root.tsx @@ -3,6 +3,8 @@ import { ASPECTS } from "./lib/aspect"; import { TEMPLATES } from "./templates"; import { Three3DTest } from "./compositions/Three3DTest"; import { AssetSheet } from "./compositions/AssetSheet"; +import { StoryScenes, STORY_SCENES_DURATION } from "./compositions/StoryScenes"; +import { FlexStory, flexStorySchema, flexStoryDefaults, calcFlexStoryMetadata } from "./compositions/FlexStory"; import { IlluminatedCircles, illuminatedCirclesSchema, @@ -124,6 +126,36 @@ export const RemotionRoot: React.FC = () => { height={1080} /> + {/* 2.5D story scenes proof (Three.js room + flat CC0 characters) — dev preview */} + {ASPECTS.map((a) => ( + + ))} + + {/* FlexStory — the scene engine: an ordered list of editable scene blocks, + duration computed dynamically from the per-scene durations. */} + {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 new file mode 100644 index 0000000..cd03b5f --- /dev/null +++ b/services/remotion/src/compositions/FlexStory.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { AbsoluteFill, Sequence, useVideoConfig } from "remotion"; +import { z } from "zod"; +import { colorSchema } from "../lib/branding"; +import { FONT } from "../lib/fonts"; +import { useLayout } from "../lib/aspect"; +import { getBlock } from "../scenes/registry"; +import { withDefaults, clampDuration } from "../scenes/types"; + +/** + * FlexStory — the scene sequencer. A template is `scenes: SceneInstance[]`; this + * composition stacks each block in a at its own (clamped) duration and + * computes the total length dynamically via calculateMetadata. This is the engine + * that turns add/duplicate/delete/reorder + per-scene duration into a real render. + */ +export const flexStorySchema = z.object({ + scenes: z.array( + z.object({ + blockId: z.string(), + durationSec: z.number(), + props: z.record(z.string()), + }) + ), + ...colorSchema, +}); +type Props = z.infer; + +const FPS = 30; + +export const flexStoryDefaults: Props = { + scenes: [ + { blockId: "TitleCard", durationSec: 4, props: { kicker: "فلت‌رندر", title: "موتور صحنه‌ای", subtitle: "هر قالب، فهرستی از صحنه‌های قابل‌ویرایش است" } }, + { blockId: "CharacterScene", durationSec: 3, props: { title: "یک ایده", caption: "همه‌چیز با یک جرقهٔ کوچک شروع شد", character: "illustrations/dicebear/openpeeps-04.svg", prop: "cup" } }, + { blockId: "ImageCaption", durationSec: 4, props: { title: "نمایش تصویر", caption: "تصویر یا اسکرین‌شات خود را اینجا قرار دهید", imageUrl: "" } }, + { blockId: "KineticQuote", durationSec: 5, props: { quote: "ساختن ویدیوی حرفه‌ای دیگر سخت نیست.", author: "فلت‌رندر" } }, + { blockId: "Slideshow", durationSec: 6, props: { title: "چرا فلت‌رندر؟", slide1: "سریع", slide2: "ارزان", slide3: "حرفه‌ای", slide4: "" } }, + { blockId: "OutroCTA", durationSec: 4, props: { brandText: "فلت‌رندر", tagline: "همین حالا داستان خود را بسازید", cta: "شروع کنید" } }, + ], + accentColor: "#cf8a76", + secondaryColor: "#6f9d96", + backgroundColor: "#ece4d6", + textColor: "#2b3a55", +}; + +const activeScenes = (props: Props) => + (props.scenes?.length ? props.scenes : flexStoryDefaults.scenes).filter((s) => getBlock(s.blockId)); + +export const FlexStory: React.FC = (props) => { + const { fps } = useVideoConfig(); + const L = useLayout(); + const colors = { + accentColor: props.accentColor, + secondaryColor: props.secondaryColor, + backgroundColor: props.backgroundColor, + textColor: props.textColor, + }; + const scenes = activeScenes(props); + let from = 0; + return ( + + {scenes.map((sc, i) => { + const block = getBlock(sc.blockId)!; + const dur = Math.round(clampDuration(sc.durationSec, block) * fps); + const Comp = block.component; + const node = ( + + + + ); + from += dur; + return node; + })} + + ); +}; + +/** Composition length = Σ per-scene durations (so add/delete/duration all flow). */ +export const calcFlexStoryMetadata = ({ props }: { props: Props }) => { + const total = activeScenes(props).reduce((acc, s) => { + const b = getBlock(s.blockId)!; + return acc + Math.round(clampDuration(s.durationSec, b) * FPS); + }, 0); + return { durationInFrames: Math.max(1, total) }; +}; diff --git a/services/remotion/src/compositions/StoryScenes.tsx b/services/remotion/src/compositions/StoryScenes.tsx new file mode 100644 index 0000000..2d0bd22 --- /dev/null +++ b/services/remotion/src/compositions/StoryScenes.tsx @@ -0,0 +1,232 @@ +import React from "react"; +import { + AbsoluteFill, + Img, + Sequence, + staticFile, + interpolate, + spring, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import { ThreeCanvas } from "@remotion/three"; +import { useThree } from "@react-three/fiber"; +import { FONT } from "../lib/fonts"; +import { useLayout } from "../lib/aspect"; +import { hexToRgba, mixHex, rand } from "../lib/anim"; + +// ───────────────────────────────────────────────────────────────────────────── +// StoryScenes — a 2.5D storytelling proof. Genuine Three.js room (back wall, +// window, floor, plant, soft blobs, drifting parallax camera, 3D particles) with +// the vendored CC0 Open-Peeps character composited in front, plus a flat +// foreground desk/prop. Fixes the "naked scene": sky/room → furniture → character +// → grain. Dev/preview comp (registered standalone in Root, NOT a seeded template). +// ───────────────────────────────────────────────────────────────────────────── + +type Mood = "calm" | "struggle" | "discover" | "work" | "win"; +type Prop = "cup" | "laptop" | "plant" | "none"; + +interface Scene { + character: string; // file under public/illustrations/dicebear/ + title: string; + caption: string; + wall: string; + floor: string; + accent: string; + glow: string; + prop: Prop; + mood: Mood; +} + +// The story (5 beats, Persian). Muted palette shifts with the emotional mood. +const STORY: Scene[] = [ + { character: "openpeeps-04", title: "یک ایده", caption: "همه‌چیز با یک جرقهٔ کوچک شروع شد", wall: "#e7dccb", floor: "#d6c4a8", accent: "#cf8a76", glow: "#f6e7c8", prop: "cup", mood: "calm" }, + { character: "openpeeps-11", title: "اما سخت بود", caption: "ساختن یک ویدیوی حرفه‌ای پیچیده به‌نظر می‌رسید", wall: "#ccd2dd", floor: "#b7bdc9", accent: "#7d8ba6", glow: "#dfe5ef", prop: "none", mood: "struggle" }, + { character: "openpeeps-21", title: "تا اینکه…", caption: "با فلت‌رندر آشنا شدم", wall: "#dde7e3", floor: "#cad6cf", accent: "#6f9d96", glow: "#d8f0ea", prop: "laptop", mood: "discover" }, + { character: "openpeeps-16", title: "فقط چند کلیک", caption: "قالب را انتخاب کن، متن و رنگ را عوض کن", wall: "#e6e1d4", floor: "#d4ccb7", accent: "#cf8a76", glow: "#f3ead4", prop: "laptop", mood: "work" }, + { character: "openpeeps-27", title: "و حالا…", caption: "داستان خودم را می‌سازم", wall: "#f1e6cf", floor: "#e3d5b8", accent: "#e0a86a", glow: "#fff0cf", prop: "plant", mood: "win" }, +]; + +const SCENE_SECONDS = 3; + +// ── Drifting parallax camera (frame-driven; no useFrame) ───────────────────── +const CameraDrift: React.FC<{ frame: number; fps: number }> = ({ frame, fps }) => { + const { camera } = useThree(); + const t = frame / fps; + camera.position.x = Math.sin(t * 0.5) * 0.32; + camera.position.y = 0.18 + Math.cos(t * 0.42) * 0.13; + camera.position.z = 5; + camera.lookAt(0, 0.15, 0); + camera.updateProjectionMatrix(); + return null; +}; + +// ── The 3D room (flat-shaded planes at depth) ──────────────────────────────── +const Room: React.FC<{ scene: Scene; frame: number }> = ({ scene, frame }) => { + const { wall, floor, accent, glow } = scene; + const wallTop = mixHex(wall, "#ffffff", 0.4); + return ( + + {/* back wall (soft vertical gradient via two stacked planes) */} + + + {/* soft sun/blobs for warmth & depth */} + + + {/* window */} + + + + + {/* floor (perspective ground) */} + + {/* plant in the corner */} + + + + + + + {/* framed picture on the wall */} + + + {/* floating dust particles */} + {Array.from({ length: 10 }).map((_, i) => { + const x = (rand(i) - 0.5) * 10; + const y = (rand(i + 3) - 0.3) * 5 + Math.sin((frame + i * 24) / 38) * 0.3; + const z = -1 - rand(i + 6) * 1.4; + return ( + + + + + ); + })} + + ); +}; + +// ── Flat foreground desk + prop (HTML/SVG, sits IN FRONT of the character) ──── +// NOTE: mixHex() returns an "rgb(...)" string — only ever pass HEX into it, never +// a previous mixHex result (that yields NaN → black). All mixes here use hex inputs. +const Desk: React.FC<{ L: ReturnType; scene: Scene; width: number; height: number; deskTop: number }> = ({ L, scene, width, height, deskTop }) => { + const wood = mixHex(scene.wall, "#9c7351", 0.5); + const woodD = mixHex(scene.wall, "#5c4231", 0.62); + const px = (f: number) => width * (L.isWide ? f : f + 0.16); // props shift toward centre off-wide + return ( + + {/* desk front (to the frame bottom) + top surface */} + + + {/* props rest on the desk top (y≈0), drawn upward */} + {scene.prop === "laptop" && ( + + + + + + )} + {scene.prop === "cup" && ( + + + + + + )} + {scene.prop === "plant" && ( + + + + + + + )} + + ); +}; + +// ── One full scene ─────────────────────────────────────────────────────────── +const SceneStage: React.FC<{ scene: Scene; index: number; total: number; durFrames: number }> = ({ scene, index, total, durFrames }) => { + const L = useLayout(); + const frame = useCurrentFrame(); + const { fps, width, height } = useVideoConfig(); + + const inP = spring({ frame, fps, config: { damping: 20, stiffness: 90 } }); + const outP = interpolate(frame, [durFrames - 12, durFrames], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + const opacity = Math.min(interpolate(frame, [0, 10], [0, 1], { extrapolateRight: "clamp" }), 1 - outP); + const slide = interpolate(inP, [0, 1], [L.vmin(50), 0]) + interpolate(outP, [0, 1], [0, -L.vmin(70)]); + + // character placement — the figure sits BEHIND the desk (lower part hidden) + const charH = L.pick(L.vmin(540), L.vmin(520), L.vmin(560)); + const charX = L.pick(width * 0.34, width * 0.5, width * 0.5); + const deskTop = L.pick(height * 0.7, height * 0.66, height * 0.6); + const charBottom = (height - deskTop) - L.vmin(140); + const bob = Math.sin(frame / 22) * L.vmin(5); + + const fa = (n: number) => String(n).padStart(2, "0").replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[+d]); + const titleSp = spring({ frame: frame - 6, fps, config: { damping: 16, stiffness: 110 } }); + const titleY = interpolate(titleSp, [0, 1], [L.vmin(30), 0]); + const capOp = interpolate(frame, [16, 32], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + const kickOp = interpolate(frame, [2, 16], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + + return ( + + {/* 3D room */} + + + + + + + + {/* character (flat CC0 art, composited in front of the room) */} + + + + + {/* foreground desk + prop */} + + + {/* text */} +
+
+
+
{fa(index + 1)} / {fa(total)}
+
+
{scene.title}
+
{scene.caption}
+
+ + {/* progress */} +
+ {Array.from({ length: total }).map((_, k) => ( +
+ ))} +
+ + {/* finishing: vignette + grain */} + + + + ); +}; + +export const StoryScenes: React.FC = () => { + const { fps } = useVideoConfig(); + const durFrames = Math.round(SCENE_SECONDS * fps); + const trans = 14; + return ( + + {STORY.map((scene, i) => ( + + + + ))} + + ); +}; + +export const STORY_SCENES_DURATION = STORY.length * SCENE_SECONDS; diff --git a/services/remotion/src/scenes/blocks/CharacterScene.tsx b/services/remotion/src/scenes/blocks/CharacterScene.tsx new file mode 100644 index 0000000..30bdf5d --- /dev/null +++ b/services/remotion/src/scenes/blocks/CharacterScene.tsx @@ -0,0 +1,146 @@ +import React from "react"; +import { AbsoluteFill, Img, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion"; +import { ThreeCanvas } from "@remotion/three"; +import { useThree } from "@react-three/fiber"; +import { FONT } from "../../lib/fonts"; +import { hexToRgba, mixHex, rand } from "../../lib/anim"; +import { Grain, Vignette, ProgressDots, Kicker, useSceneTransition } from "../chrome"; +import type { BlockProps, SceneBlock } from "../types"; + +const CamDrift: React.FC<{ frame: number; fps: number }> = ({ frame, fps }) => { + const { camera } = useThree(); + const t = frame / fps; + camera.position.x = Math.sin(t * 0.45) * 0.3; + camera.position.y = 0.16 + Math.cos(t * 0.4) * 0.12; + camera.position.z = 5; + camera.lookAt(0, 0.12, 0); + camera.updateProjectionMatrix(); + return null; +}; + +const Room: React.FC<{ colors: BlockProps["colors"]; frame: number }> = ({ colors, frame }) => { + const wall = colors.backgroundColor; + const accent = colors.accentColor; + const glow = mixHex(colors.backgroundColor, "#ffffff", 0.5); + return ( + + + + {/* window */} + + + + + {/* framed picture */} + + + {/* floor */} + + {/* plant */} + + + + + + + {Array.from({ length: 9 }).map((_, i) => { + const x = (rand(i) - 0.5) * 10; + const y = (rand(i + 3) - 0.3) * 5 + Math.sin((frame + i * 24) / 38) * 0.3; + const z = -1 - rand(i + 6) * 1.4; + return ; + })} + + ); +}; + +const Desk: React.FC<{ colors: BlockProps["colors"]; L: BlockProps["L"]; width: number; height: number; deskTop: number; prop: string }> = ({ colors, L, width, height, deskTop, prop }) => { + const wood = mixHex(colors.backgroundColor, "#9c7351", 0.5); + const woodD = mixHex(colors.backgroundColor, "#5c4231", 0.62); + const px = width * (L.isWide ? 0.28 : 0.42); + return ( + + + + {prop === "laptop" && ( + + + + + + )} + {prop === "cup" && ( + + + + + )} + {prop === "plant" && ( + + + + + + + )} + + ); +}; + +const CharacterScene: React.FC = ({ data, colors, L, index, total, durationInFrames }) => { + const frame = useCurrentFrame(); + const { fps, width, height } = useVideoConfig(); + const { opacity, slide } = useSceneTransition(durationInFrames, L); + const charH = L.pick(L.vmin(540), L.vmin(520), L.vmin(560)); + const charX = L.pick(width * 0.34, width * 0.5, width * 0.5); + const deskTop = L.pick(height * 0.7, height * 0.66, height * 0.6); + const charBottom = height - deskTop - L.vmin(140); + const bob = Math.sin(frame / 22) * L.vmin(5); + const charSp = spring({ frame: frame - 2, fps, config: { damping: 18, stiffness: 90 } }); + const charIn = interpolate(charSp, [0, 1], [L.vmin(40), 0]); + const titleSp = spring({ frame: frame - 8, fps, config: { damping: 16, stiffness: 110 } }); + const titleY = interpolate(titleSp, [0, 1], [L.vmin(30), 0]); + const capOp = interpolate(frame, [18, 34], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + const src = /^https?:\/\//.test(data.character) ? data.character : staticFile(data.character || "illustrations/dicebear/openpeeps-04.svg"); + + return ( + + + + + + + + + + + + + + +
+ +
{data.title}
+
{data.caption}
+
+ + + + +
+ ); +}; + +export const CharacterSceneBlock: SceneBlock = { + id: "CharacterScene", + label: "صحنهٔ شخصیت", + component: CharacterScene, + fields: [ + { key: "title", label: "عنوان", type: "text", default: "عنوان صحنه" }, + { key: "caption", label: "متن", type: "text", default: "توضیح این بخش از داستان", multiline: true }, + { key: "character", label: "شخصیت", type: "image", default: "illustrations/dicebear/openpeeps-04.svg" }, + { key: "prop", label: "وسیله (cup/laptop/plant)", type: "text", default: "cup" }, + ], + defaultDurationSec: 3, + minDurationSec: 1, + maxDurationSec: 6, +}; diff --git a/services/remotion/src/scenes/blocks/ImageCaption.tsx b/services/remotion/src/scenes/blocks/ImageCaption.tsx new file mode 100644 index 0000000..b4feb89 --- /dev/null +++ b/services/remotion/src/scenes/blocks/ImageCaption.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { AbsoluteFill, Img, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion"; +import { FONT } from "../../lib/fonts"; +import { hexToRgba, mixHex } from "../../lib/anim"; +import { ThreeBackdrop, Grain, Vignette, ProgressDots, Kicker, useSceneTransition } from "../chrome"; +import type { BlockProps, SceneBlock } from "../types"; + +const resolveSrc = (u: string) => (/^https?:\/\//.test(u) ? u : staticFile(u)); + +const ImageCaption: React.FC = ({ data, colors, L, index, total, durationInFrames }) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const { opacity, slide } = useSceneTransition(durationInFrames, L); + const cardSp = spring({ frame: frame - 2, fps, config: { damping: 18, stiffness: 90 } }); + const cardScale = interpolate(cardSp, [0, 1], [0.9, 1]); + const float = Math.sin(frame / 26) * L.vmin(8); + const textSp = spring({ frame: frame - 10, fps, config: { damping: 18, stiffness: 110 } }); + const textX = interpolate(textSp, [0, 1], [L.vmin(40), 0]); + + const cardW = L.pick(L.vmin(820), L.vmin(720), L.vmin(820)); + const cardH = L.pick(L.vmin(520), L.vmin(470), L.vmin(560)); + + const Card = ( +
+ {data.imageUrl ? ( + + ) : ( +
+ 🖼 +
+ )} +
+ ); + + const Text = ( +
+ +
{data.title}
+
{data.caption}
+
+ ); + + return ( + + + + {Card} + {Text} + + + + + + ); +}; + +export const ImageCaptionBlock: SceneBlock = { + id: "ImageCaption", + label: "تصویر و توضیح", + component: ImageCaption, + fields: [ + { key: "imageUrl", label: "تصویر", type: "image", default: "" }, + { key: "title", label: "عنوان", type: "text", default: "ویژگی شما" }, + { key: "caption", label: "توضیح", type: "text", default: "توضیح کوتاه دربارهٔ این تصویر یا ویژگی", multiline: true }, + ], + defaultDurationSec: 4, + minDurationSec: 2, + maxDurationSec: 8, +}; diff --git a/services/remotion/src/scenes/blocks/KineticQuote.tsx b/services/remotion/src/scenes/blocks/KineticQuote.tsx new file mode 100644 index 0000000..313698c --- /dev/null +++ b/services/remotion/src/scenes/blocks/KineticQuote.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion"; +import { FONT } from "../../lib/fonts"; +import { hexToRgba } from "../../lib/anim"; +import { ThreeBackdrop, Grain, Vignette, ProgressDots, Kicker, useSceneTransition } from "../chrome"; +import type { BlockProps, SceneBlock } from "../types"; + +const KineticQuote: React.FC = ({ data, colors, L, index, total, durationInFrames }) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const { opacity, slide } = useSceneTransition(durationInFrames, L); + const words = (data.quote || "").split(" "); + const authorOp = interpolate(frame, [words.length * 3 + 10, words.length * 3 + 26], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + + return ( + + + + + {/* big quotation mark */} +
+
+ {words.map((w, i) => { + const sp = spring({ frame: frame - 8 - i * 3, fps, config: { damping: 18, stiffness: 120 } }); + return ( + + {w} + + ); + })} +
+
+ + {data.author} +
+
+ + + +
+ ); +}; + +export const KineticQuoteBlock: SceneBlock = { + id: "KineticQuote", + label: "نقل‌قول متحرک", + component: KineticQuote, + fields: [ + { key: "quote", label: "نقل‌قول", type: "text", default: "موفقیت، مجموع تلاش‌های کوچکِ هر روز است.", multiline: true }, + { key: "author", label: "گوینده", type: "text", default: "فلت‌رندر" }, + ], + defaultDurationSec: 5, + minDurationSec: 3, + maxDurationSec: 9, +}; diff --git a/services/remotion/src/scenes/blocks/OutroCTA.tsx b/services/remotion/src/scenes/blocks/OutroCTA.tsx new file mode 100644 index 0000000..7da49b6 --- /dev/null +++ b/services/remotion/src/scenes/blocks/OutroCTA.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion"; +import { FONT } from "../../lib/fonts"; +import { hexToRgba, mixHex } from "../../lib/anim"; +import { ThreeBackdrop, Grain, Vignette, useSceneTransition } from "../chrome"; +import type { BlockProps, SceneBlock } from "../types"; + +const OutroCTA: React.FC = ({ data, colors, L, durationInFrames }) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const { opacity } = useSceneTransition(durationInFrames, L); + const brandSp = spring({ frame: frame - 2, fps, config: { damping: 14, stiffness: 120 } }); + const brandScale = interpolate(brandSp, [0, 1], [0.7, 1]); + const tagOp = interpolate(frame, [14, 30], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + const ctaSp = spring({ frame: frame - 22, fps, config: { damping: 12, stiffness: 140 } }); + const ctaScale = interpolate(ctaSp, [0, 1], [0.6, 1]); + + return ( + + + +
+ {data.brandText} +
+
+ {data.tagline} +
+
+ {data.cta} +
+
+ + +
+ ); +}; + +export const OutroCTABlock: SceneBlock = { + id: "OutroCTA", + label: "پایان و دعوت به اقدام", + component: OutroCTA, + fields: [ + { key: "brandText", label: "نام برند", type: "text", default: "فلت‌رندر" }, + { key: "tagline", label: "شعار", type: "text", default: "همین حالا داستان خود را بسازید" }, + { key: "cta", label: "دکمه", type: "text", default: "شروع کنید" }, + ], + defaultDurationSec: 4, + minDurationSec: 2, + maxDurationSec: 8, +}; diff --git a/services/remotion/src/scenes/blocks/Slideshow.tsx b/services/remotion/src/scenes/blocks/Slideshow.tsx new file mode 100644 index 0000000..0ce7a4c --- /dev/null +++ b/services/remotion/src/scenes/blocks/Slideshow.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion"; +import { FONT } from "../../lib/fonts"; +import { hexToRgba } from "../../lib/anim"; +import { ThreeBackdrop, Grain, Vignette, ProgressDots, Kicker, useSceneTransition } from "../chrome"; +import type { BlockProps, SceneBlock } from "../types"; + +const Slideshow: React.FC = ({ data, colors, L, index, total, durationInFrames }) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const { opacity, slide } = useSceneTransition(durationInFrames, L); + const items = [data.slide1, data.slide2, data.slide3, data.slide4].filter((s) => s && s.trim()); + // distribute reveals across the available time (after the title settles) + const start = 14; + const span = Math.max(18, (durationInFrames - start - 14) / Math.max(1, items.length)); + + return ( + + + + +
+ {data.title} +
+
+ {items.map((s, i) => { + const sp = spring({ frame: frame - (start + i * span), fps, config: { damping: 18, stiffness: 110 } }); + const x = interpolate(sp, [0, 1], [L.vmin(50), 0]); + return ( +
+ + {String(i + 1).replace(/[0-9]/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[+d])} + + {s} +
+ ); + })} +
+
+ + + +
+ ); +}; + +export const SlideshowBlock: SceneBlock = { + id: "Slideshow", + label: "اسلایدشو (فهرست)", + component: Slideshow, + fields: [ + { key: "title", label: "عنوان", type: "text", default: "چرا فلت‌رندر؟" }, + { key: "slide1", label: "مورد ۱", type: "text", default: "ساخت ویدیو در چند دقیقه" }, + { key: "slide2", label: "مورد ۲", type: "text", default: "بدون نیاز به دانش فنی" }, + { key: "slide3", label: "مورد ۳", type: "text", default: "خروجی با کیفیت حرفه‌ای" }, + { key: "slide4", label: "مورد ۴", type: "text", default: "" }, + ], + defaultDurationSec: 6, + minDurationSec: 3, + maxDurationSec: 12, +}; diff --git a/services/remotion/src/scenes/blocks/TitleCard.tsx b/services/remotion/src/scenes/blocks/TitleCard.tsx new file mode 100644 index 0000000..437914f --- /dev/null +++ b/services/remotion/src/scenes/blocks/TitleCard.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion"; +import { FONT } from "../../lib/fonts"; +import { hexToRgba } from "../../lib/anim"; +import { ThreeBackdrop, Grain, Vignette, ProgressDots, useSceneTransition } from "../chrome"; +import type { BlockProps, SceneBlock } from "../types"; + +const TitleCard: React.FC = ({ data, colors, L, index, total, durationInFrames }) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const { opacity, slide } = useSceneTransition(durationInFrames, L); + const titleSp = spring({ frame: frame - 4, fps, config: { damping: 16, stiffness: 110 } }); + const titleY = interpolate(titleSp, [0, 1], [L.vmin(44), 0]); + const subOp = interpolate(frame, [16, 34], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + const kickOp = interpolate(frame, [4, 18], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + + return ( + + + + {data.kicker ? ( +
+ + {data.kicker} +
+ ) : null} +
+ {data.title} +
+
+ {data.subtitle} +
+
+ + + +
+ ); +}; + +export const TitleCardBlock: SceneBlock = { + id: "TitleCard", + label: "عنوان آغازین", + component: TitleCard, + fields: [ + { key: "kicker", label: "پیش‌متن", type: "text", default: "تقدیم می‌کند" }, + { key: "title", label: "عنوان", type: "text", default: "عنوان شما" }, + { key: "subtitle", label: "زیرعنوان", type: "text", default: "توضیح کوتاه در یک جمله", multiline: true }, + ], + defaultDurationSec: 4, + minDurationSec: 2, + maxDurationSec: 8, +}; diff --git a/services/remotion/src/scenes/chrome.tsx b/services/remotion/src/scenes/chrome.tsx new file mode 100644 index 0000000..a62c387 --- /dev/null +++ b/services/remotion/src/scenes/chrome.tsx @@ -0,0 +1,113 @@ +import React from "react"; +import { AbsoluteFill, interpolate, useCurrentFrame, useVideoConfig } from "remotion"; +import { ThreeCanvas } from "@remotion/three"; +import { useThree } from "@react-three/fiber"; +import { hexToRgba, mixHex, rand } from "../lib/anim"; +import { Confetti3D } from "../lib/three-kit"; +import type { Layout } from "../lib/aspect"; +import type { SceneColors } from "./types"; + +const fa = (n: number) => String(n).padStart(2, "0").replace(/[0-9]/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[+d]); + +// ── Drifting parallax camera (frame-driven; no useFrame) ───────────────────── +const Drift: React.FC<{ frame: number; fps: number }> = ({ frame, fps }) => { + const { camera } = useThree(); + const t = frame / fps; + camera.position.x = Math.sin(t * 0.45) * 0.3; + camera.position.y = 0.12 + Math.cos(t * 0.4) * 0.12; + camera.position.z = 5; + camera.lookAt(0, 0, 0); + camera.updateProjectionMatrix(); + return null; +}; + +/** + * Shared 3D backdrop — soft flat planes + blobs at depth + drifting particles + * under a parallax camera. Genuine @remotion/three so every block is 2.5D, while + * staying calm enough to sit behind any content. + */ +export const ThreeBackdrop: React.FC<{ colors: SceneColors; hue?: string; bare?: boolean; confetti?: boolean }> = ({ colors, hue, bare, confetti }) => { + const frame = useCurrentFrame(); + const { width, height, fps } = useVideoConfig(); + const accent = hue ?? colors.accentColor; + const wallTop = mixHex(colors.backgroundColor, "#ffffff", 0.45); + return ( + + + + + {/* soft vertical wash */} + + + {!bare && ( + <> + + + + )} + {/* floating particles */} + {Array.from({ length: 9 }).map((_, i) => { + const x = (rand(i) - 0.5) * 10; + const y = (rand(i + 3) - 0.3) * 5 + Math.sin((frame + i * 24) / 38) * 0.3; + const z = -1 - rand(i + 6) * 1.4; + return ( + + + + + ); + })} + {confetti && } + + ); +}; + +export const Grain: React.FC = () => ( + +); + +export const Vignette: React.FC = () => ( + +); + +export const ProgressDots: React.FC<{ index: number; total: number; colors: SceneColors; L: Layout }> = ({ index, total, colors, L }) => ( +
+ {Array.from({ length: total }).map((_, k) => ( +
+ ))} +
+); + +export const Kicker: React.FC<{ index: number; total: number; colors: SceneColors; L: Layout; slide?: number }> = ({ index, total, colors, L, slide = 0 }) => { + const frame = useCurrentFrame(); + const op = interpolate(frame, [2, 16], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + const lineW = interpolate(op, [0, 1], [0, L.vmin(40)]); + return ( +
+
+
+ {fa(index + 1)} / {fa(total)} +
+
+ ); +}; + +/** Enter/exit progress for a scene: 0→1 in, 1→0 out, plus a slide offset. */ +export function useSceneTransition(durationInFrames: number, L: Layout) { + const frame = useCurrentFrame(); + const inOp = interpolate(frame, [0, 10], [0, 1], { extrapolateRight: "clamp" }); + const outOp = interpolate(frame, [durationInFrames - 12, durationInFrames], [1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + const opacity = Math.min(inOp, outOp); + const slideIn = interpolate(frame, [0, 14], [L.vmin(50), 0], { extrapolateRight: "clamp" }); + const slideOut = interpolate(frame, [durationInFrames - 12, durationInFrames], [0, -L.vmin(70)], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + return { opacity, slide: slideIn + slideOut }; +} diff --git a/services/remotion/src/scenes/registry.ts b/services/remotion/src/scenes/registry.ts new file mode 100644 index 0000000..21d383a --- /dev/null +++ b/services/remotion/src/scenes/registry.ts @@ -0,0 +1,24 @@ +import type { SceneBlock } from "./types"; +import { TitleCardBlock } from "./blocks/TitleCard"; +import { CharacterSceneBlock } from "./blocks/CharacterScene"; +import { ImageCaptionBlock } from "./blocks/ImageCaption"; +import { KineticQuoteBlock } from "./blocks/KineticQuote"; +import { SlideshowBlock } from "./blocks/Slideshow"; +import { OutroCTABlock } from "./blocks/OutroCTA"; + +/** + * The scene-block registry. A FlexStory template is an ordered list of these + * blocks; new template types = new blocks. Each block declares its editable + * fields + duration range (so the studio can clamp per-block, not globally). + */ +export const SCENE_BLOCKS: Record = { + [TitleCardBlock.id]: TitleCardBlock, + [CharacterSceneBlock.id]: CharacterSceneBlock, + [ImageCaptionBlock.id]: ImageCaptionBlock, + [KineticQuoteBlock.id]: KineticQuoteBlock, + [SlideshowBlock.id]: SlideshowBlock, + [OutroCTABlock.id]: OutroCTABlock, +}; + +export const BLOCK_LIST = Object.values(SCENE_BLOCKS); +export const getBlock = (id: string): SceneBlock | undefined => SCENE_BLOCKS[id]; diff --git a/services/remotion/src/scenes/types.ts b/services/remotion/src/scenes/types.ts new file mode 100644 index 0000000..410eae8 --- /dev/null +++ b/services/remotion/src/scenes/types.ts @@ -0,0 +1,60 @@ +import type React from "react"; +import type { Layout } from "../lib/aspect"; + +/** The four brand colors every scene shares (matches lib/branding colorSchema). */ +export interface SceneColors { + accentColor: string; + secondaryColor: string; + backgroundColor: string; + textColor: string; +} + +/** One scene instance in a FlexStory timeline (what the studio/render sends). */ +export interface SceneInstance { + blockId: string; // key into SCENE_BLOCKS + durationSec: number; // user-editable, clamped to the block's min/max + props: Record; // per-scene content (text + image keys) +} + +/** Everything a block component receives to render one scene. */ +export interface BlockProps { + data: Record; // scene props merged over the block's field defaults + colors: SceneColors; + L: Layout; + index: number; // scene index in the story (0-based) + total: number; // total active scenes + durationInFrames: number; // this scene's length +} + +/** One editable field a block exposes (drives the studio inputs + seeding). */ +export interface BlockField { + key: string; + label: string; // Persian label + type: "text" | "image"; + default: string; + multiline?: boolean; +} + +/** A registered scene block: its component + editable fields + duration range. */ +export interface SceneBlock { + id: string; + label: string; // Persian name shown in the studio scene picker + component: React.FC; + fields: BlockField[]; + defaultDurationSec: number; + minDurationSec: number; + maxDurationSec: number; +} + +/** Merge a scene's props over the block's field defaults (empty → default). */ +export function withDefaults(block: SceneBlock, props: Record): Record { + const out: Record = {}; + for (const f of block.fields) { + const v = props?.[f.key]; + out[f.key] = v !== undefined && v !== "" ? v : f.default; + } + return out; +} + +export const clampDuration = (sec: number, block: SceneBlock): number => + Math.min(block.maxDurationSec, Math.max(block.minDurationSec, sec || block.defaultDurationSec));