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>
This commit is contained in:
soroush.asadi
2026-06-23 07:45:57 +03:30
parent fd364209e7
commit d830c56ea0
13 changed files with 1069 additions and 0 deletions
+90
View File
@@ -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 12s (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/<slug>.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).
+32
View File
@@ -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) => (
<Composition
key={`StoryScenes-${a.id}`}
id={`StoryScenes-${a.id}`}
component={StoryScenes}
durationInFrames={STORY_SCENES_DURATION * FPS}
fps={FPS}
width={a.width}
height={a.height}
/>
))}
{/* FlexStory — the scene engine: an ordered list of editable scene blocks,
duration computed dynamically from the per-scene durations. */}
{ASPECTS.map((a) => (
<Composition
key={`FlexStory-${a.id}`}
id={`FlexStory-${a.id}`}
component={FlexStory}
durationInFrames={26 * FPS}
fps={FPS}
width={a.width}
height={a.height}
schema={flexStorySchema}
defaultProps={flexStoryDefaults}
calculateMetadata={calcFlexStoryMetadata}
/>
))}
{/* 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
@@ -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 <Sequence> 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<typeof flexStorySchema>;
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> = (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 (
<AbsoluteFill style={{ backgroundColor: colors.backgroundColor, fontFamily: FONT }}>
{scenes.map((sc, i) => {
const block = getBlock(sc.blockId)!;
const dur = Math.round(clampDuration(sc.durationSec, block) * fps);
const Comp = block.component;
const node = (
<Sequence key={i} from={from} durationInFrames={dur}>
<Comp data={withDefaults(block, sc.props || {})} colors={colors} L={L} index={i} total={scenes.length} durationInFrames={dur} />
</Sequence>
);
from += dur;
return node;
})}
</AbsoluteFill>
);
};
/** 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) };
};
@@ -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 (
<group>
{/* back wall (soft vertical gradient via two stacked planes) */}
<mesh position={[0, 1.6, -3]}><planeGeometry args={[20, 8]} /><meshStandardMaterial color={wallTop} /></mesh>
<mesh position={[0, -2.2, -2.98]}><planeGeometry args={[20, 6]} /><meshStandardMaterial color={wall} /></mesh>
{/* soft sun/blobs for warmth & depth */}
<mesh position={[3.1, 1.7, -2.9]}><circleGeometry args={[1.5, 48]} /><meshBasicMaterial color={glow} transparent opacity={0.85} /></mesh>
<mesh position={[-3.6, 1.5, -2.85]}><circleGeometry args={[0.8, 48]} /><meshBasicMaterial color={mixHex(accent, "#ffffff", 0.5)} transparent opacity={0.22} /></mesh>
{/* window */}
<mesh position={[3.0, 1.2, -2.92]}><planeGeometry args={[2.3, 2.7]} /><meshStandardMaterial color={mixHex(wall, "#1f2937", 0.18)} /></mesh>
<mesh position={[3.0, 1.2, -2.9]}><planeGeometry args={[2.0, 2.4]} /><meshBasicMaterial color={glow} /></mesh>
<mesh position={[3.0, 1.2, -2.88]}><planeGeometry args={[0.08, 2.4]} /><meshBasicMaterial color={mixHex(wall, "#1f2937", 0.22)} /></mesh>
<mesh position={[3.0, 1.2, -2.88]}><planeGeometry args={[2.0, 0.08]} /><meshBasicMaterial color={mixHex(wall, "#1f2937", 0.22)} /></mesh>
{/* floor (perspective ground) */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -1.6, 0.5]}><planeGeometry args={[24, 14]} /><meshStandardMaterial color={floor} /></mesh>
{/* plant in the corner */}
<group position={[-3.6, -1.05, -1.4]}>
<mesh position={[0, -0.35, 0]}><cylinderGeometry args={[0.28, 0.22, 0.5, 24]} /><meshStandardMaterial color={mixHex(accent, "#8a5a44", 0.5)} /></mesh>
<mesh position={[0, 0.15, 0]}><circleGeometry args={[0.5, 32]} /><meshBasicMaterial color="#6f9d72" /></mesh>
<mesh position={[0.32, 0.35, 0.01]}><circleGeometry args={[0.34, 32]} /><meshBasicMaterial color="#5e8c63" /></mesh>
<mesh position={[-0.3, 0.32, 0.01]}><circleGeometry args={[0.3, 32]} /><meshBasicMaterial color="#7faa80" /></mesh>
</group>
{/* framed picture on the wall */}
<mesh position={[-2.4, 1.8, -2.9]}><planeGeometry args={[1.0, 1.3]} /><meshBasicMaterial color={mixHex(wall, "#1f2937", 0.2)} /></mesh>
<mesh position={[-2.4, 1.8, -2.89]}><planeGeometry args={[0.82, 1.1]} /><meshBasicMaterial color={mixHex(accent, "#ffffff", 0.35)} /></mesh>
{/* 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 (
<mesh key={i} position={[x, y, z]}>
<circleGeometry args={[0.05 + rand(i) * 0.06, 16]} />
<meshBasicMaterial color={accent} transparent opacity={0.22} />
</mesh>
);
})}
</group>
);
};
// ── 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<typeof useLayout>; 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 (
<svg width={width} height={height - deskTop} style={{ position: "absolute", left: 0, top: deskTop, overflow: "visible" }}>
{/* desk front (to the frame bottom) + top surface */}
<rect x={-40} y={L.vmin(26)} width={width + 80} height={height - deskTop} fill={woodD} />
<rect x={-40} y={0} width={width + 80} height={L.vmin(30)} rx={L.vmin(8)} fill={wood} />
{/* props rest on the desk top (y≈0), drawn upward */}
{scene.prop === "laptop" && (
<g transform={`translate(${px(0.3)} 0)`}>
<rect x={-L.vmin(58)} y={-L.vmin(72)} width={L.vmin(116)} height={L.vmin(76)} rx={L.vmin(8)} fill="#2c3650" />
<rect x={-L.vmin(51)} y={-L.vmin(66)} width={L.vmin(102)} height={L.vmin(63)} rx={L.vmin(5)} fill={mixHex(scene.glow, "#1d4ed8", 0.22)} />
<rect x={-L.vmin(70)} y={-L.vmin(4)} width={L.vmin(140)} height={L.vmin(12)} rx={L.vmin(6)} fill="#3a4664" />
</g>
)}
{scene.prop === "cup" && (
<g transform={`translate(${px(0.18)} 0)`}>
<rect x={-L.vmin(15)} y={-L.vmin(30)} width={L.vmin(30)} height={L.vmin(32)} rx={L.vmin(6)} fill={scene.accent} />
<path d={`M${L.vmin(15)} ${-L.vmin(24)} q ${L.vmin(15)} 3 0 ${L.vmin(18)}`} fill="none" stroke={scene.accent} strokeWidth={L.vmin(5)} />
<path d={`M${-L.vmin(6)} ${-L.vmin(40)} q ${L.vmin(6)} ${-L.vmin(8)} 0 ${-L.vmin(16)}`} fill="none" stroke={hexToRgba("#ffffff", 0.5)} strokeWidth={L.vmin(3)} />
</g>
)}
{scene.prop === "plant" && (
<g transform={`translate(${px(0.18)} 0)`}>
<rect x={-L.vmin(16)} y={-L.vmin(30)} width={L.vmin(32)} height={L.vmin(32)} rx={L.vmin(5)} fill={mixHex(scene.accent, "#8a5a44", 0.5)} />
<circle cx={0} cy={-L.vmin(44)} r={L.vmin(22)} fill="#6f9d72" />
<circle cx={-L.vmin(15)} cy={-L.vmin(34)} r={L.vmin(15)} fill="#5e8c63" />
<circle cx={L.vmin(15)} cy={-L.vmin(34)} r={L.vmin(15)} fill="#7faa80" />
</g>
)}
</svg>
);
};
// ── 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 (
<AbsoluteFill style={{ opacity, fontFamily: FONT, backgroundColor: scene.wall }}>
{/* 3D room */}
<ThreeCanvas width={width} height={height} camera={{ position: [0, 0.15, 5], fov: 42 }} style={{ position: "absolute", inset: 0 }}>
<CameraDrift frame={frame} fps={fps} />
<ambientLight intensity={0.92} />
<directionalLight position={[3, 5, 4]} intensity={0.5} color="#fff6ea" />
<Room scene={scene} frame={frame} />
</ThreeCanvas>
{/* character (flat CC0 art, composited in front of the room) */}
<AbsoluteFill>
<Img
src={staticFile(`illustrations/dicebear/${scene.character}.svg`)}
style={{ position: "absolute", left: charX - charH / 2 + slide * 0.4, bottom: charBottom + bob, width: charH, height: charH, objectFit: "contain", filter: "drop-shadow(0 18px 26px rgba(34,40,58,0.18))" }}
/>
</AbsoluteFill>
{/* foreground desk + prop */}
<Desk L={L} scene={scene} width={width} height={height} deskTop={deskTop} />
{/* text */}
<div style={{ position: "absolute", direction: "ltr", ...(L.isWide ? { right: width * 0.06, top: 0, bottom: 0, width: width * 0.4, display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "flex-end" } : { left: 0, right: 0, top: height * 0.08, alignItems: "center", display: "flex", flexDirection: "column", paddingInline: L.vmin(50) }) }}>
<div style={{ direction: "rtl", display: "flex", alignItems: "center", gap: L.vmin(12), opacity: kickOp, transform: `translateX(${slide}px)`, marginBottom: L.vmin(14) }}>
<div style={{ width: L.vmin(40), height: L.vmin(3), borderRadius: 999, background: scene.accent }} />
<div style={{ fontWeight: 800, fontSize: L.vmin(23), letterSpacing: 1, color: scene.accent }}>{fa(index + 1)} <span style={{ color: hexToRgba("#2b3a55", 0.4) }}>/ {fa(total)}</span></div>
</div>
<div style={{ direction: "rtl", textAlign: L.isWide ? "right" : "center", transform: `translate(${slide}px, ${titleY}px)`, fontWeight: 800, fontSize: L.pick(L.vmin(74), L.vmin(68), L.vmin(64)), color: "#2b3a55", lineHeight: 1.18, letterSpacing: -0.5 }}>{scene.title}</div>
<div style={{ direction: "rtl", textAlign: L.isWide ? "right" : "center", opacity: capOp, marginTop: L.vmin(16), fontWeight: 400, fontSize: L.pick(L.vmin(30), L.vmin(30), L.vmin(28)), lineHeight: 1.7, color: hexToRgba("#2b3a55", 0.66), maxWidth: L.isWide ? "100%" : width * 0.82 }}>{scene.caption}</div>
</div>
{/* progress */}
<div style={{ position: "absolute", bottom: L.vmin(44), left: 0, right: 0, display: "flex", justifyContent: "center", gap: L.vmin(8) }}>
{Array.from({ length: total }).map((_, k) => (
<div key={k} style={{ width: k === index ? L.vmin(28) : L.vmin(10), height: L.vmin(10), borderRadius: 999, background: k === index ? scene.accent : hexToRgba("#2b3a55", 0.18) }} />
))}
</div>
{/* finishing: vignette + grain */}
<AbsoluteFill style={{ pointerEvents: "none", background: "radial-gradient(125% 108% at 50% 42%, transparent 56%, rgba(30,38,58,0.16) 100%)" }} />
<AbsoluteFill style={{ pointerEvents: "none", opacity: 0.05, mixBlendMode: "overlay", backgroundImage: "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")", backgroundSize: "160px 160px" }} />
</AbsoluteFill>
);
};
export const StoryScenes: React.FC = () => {
const { fps } = useVideoConfig();
const durFrames = Math.round(SCENE_SECONDS * fps);
const trans = 14;
return (
<AbsoluteFill style={{ backgroundColor: "#ece4d6" }}>
{STORY.map((scene, i) => (
<Sequence key={i} from={i * durFrames} durationInFrames={durFrames + trans}>
<SceneStage scene={scene} index={i} total={STORY.length} durFrames={durFrames + trans} />
</Sequence>
))}
</AbsoluteFill>
);
};
export const STORY_SCENES_DURATION = STORY.length * SCENE_SECONDS;
@@ -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 (
<group>
<mesh position={[0, 1.7, -3]}><planeGeometry args={[22, 9]} /><meshStandardMaterial color={mixHex(wall, "#ffffff", 0.4)} /></mesh>
<mesh position={[0, -2.3, -2.98]}><planeGeometry args={[22, 6]} /><meshStandardMaterial color={wall} /></mesh>
{/* window */}
<mesh position={[3.1, 1.2, -2.92]}><planeGeometry args={[2.4, 2.8]} /><meshStandardMaterial color={mixHex(wall, "#1f2937", 0.18)} /></mesh>
<mesh position={[3.1, 1.2, -2.9]}><planeGeometry args={[2.1, 2.5]} /><meshBasicMaterial color={glow} /></mesh>
<mesh position={[3.1, 1.2, -2.88]}><planeGeometry args={[0.07, 2.5]} /><meshBasicMaterial color={mixHex(wall, "#1f2937", 0.22)} /></mesh>
<mesh position={[3.1, 1.2, -2.88]}><planeGeometry args={[2.1, 0.07]} /><meshBasicMaterial color={mixHex(wall, "#1f2937", 0.22)} /></mesh>
{/* framed picture */}
<mesh position={[-2.5, 1.8, -2.9]}><planeGeometry args={[1.0, 1.3]} /><meshBasicMaterial color={mixHex(wall, "#1f2937", 0.2)} /></mesh>
<mesh position={[-2.5, 1.8, -2.89]}><planeGeometry args={[0.82, 1.1]} /><meshBasicMaterial color={mixHex(accent, "#ffffff", 0.35)} /></mesh>
{/* floor */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -1.6, 0.5]}><planeGeometry args={[24, 14]} /><meshStandardMaterial color={mixHex(wall, "#000000", 0.08)} /></mesh>
{/* plant */}
<group position={[-3.6, -1.05, -1.4]}>
<mesh position={[0, -0.35, 0]}><cylinderGeometry args={[0.28, 0.22, 0.5, 24]} /><meshStandardMaterial color={mixHex(accent, "#8a5a44", 0.5)} /></mesh>
<mesh position={[0, 0.15, 0]}><circleGeometry args={[0.5, 32]} /><meshBasicMaterial color="#6f9d72" /></mesh>
<mesh position={[0.32, 0.35, 0.01]}><circleGeometry args={[0.34, 32]} /><meshBasicMaterial color="#5e8c63" /></mesh>
<mesh position={[-0.3, 0.32, 0.01]}><circleGeometry args={[0.3, 32]} /><meshBasicMaterial color="#7faa80" /></mesh>
</group>
{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 <mesh key={i} position={[x, y, z]}><circleGeometry args={[0.05 + rand(i) * 0.05, 16]} /><meshBasicMaterial color={accent} transparent opacity={0.22} /></mesh>;
})}
</group>
);
};
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 (
<svg width={width} height={height - deskTop} style={{ position: "absolute", left: 0, top: deskTop, overflow: "visible" }}>
<rect x={-40} y={L.vmin(26)} width={width + 80} height={height - deskTop} fill={woodD} />
<rect x={-40} y={0} width={width + 80} height={L.vmin(30)} rx={L.vmin(8)} fill={wood} />
{prop === "laptop" && (
<g transform={`translate(${px} 0)`}>
<rect x={-L.vmin(58)} y={-L.vmin(72)} width={L.vmin(116)} height={L.vmin(76)} rx={L.vmin(8)} fill="#2c3650" />
<rect x={-L.vmin(51)} y={-L.vmin(66)} width={L.vmin(102)} height={L.vmin(63)} rx={L.vmin(5)} fill={mixHex(colors.backgroundColor, "#1d4ed8", 0.3)} />
<rect x={-L.vmin(70)} y={-L.vmin(4)} width={L.vmin(140)} height={L.vmin(12)} rx={L.vmin(6)} fill="#3a4664" />
</g>
)}
{prop === "cup" && (
<g transform={`translate(${px} 0)`}>
<rect x={-L.vmin(15)} y={-L.vmin(30)} width={L.vmin(30)} height={L.vmin(32)} rx={L.vmin(6)} fill={colors.accentColor} />
<path d={`M${L.vmin(15)} ${-L.vmin(24)} q ${L.vmin(15)} 3 0 ${L.vmin(18)}`} fill="none" stroke={colors.accentColor} strokeWidth={L.vmin(5)} />
</g>
)}
{prop === "plant" && (
<g transform={`translate(${px} 0)`}>
<rect x={-L.vmin(16)} y={-L.vmin(30)} width={L.vmin(32)} height={L.vmin(32)} rx={L.vmin(5)} fill={mixHex(colors.accentColor, "#8a5a44", 0.5)} />
<circle cx={0} cy={-L.vmin(44)} r={L.vmin(22)} fill="#6f9d72" />
<circle cx={-L.vmin(15)} cy={-L.vmin(34)} r={L.vmin(15)} fill="#5e8c63" />
<circle cx={L.vmin(15)} cy={-L.vmin(34)} r={L.vmin(15)} fill="#7faa80" />
</g>
)}
</svg>
);
};
const CharacterScene: React.FC<BlockProps> = ({ 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 (
<AbsoluteFill style={{ opacity, fontFamily: FONT, backgroundColor: colors.backgroundColor }}>
<ThreeCanvas width={width} height={height} camera={{ position: [0, 0.16, 5], fov: 42 }} style={{ position: "absolute", inset: 0 }}>
<CamDrift frame={frame} fps={fps} />
<ambientLight intensity={0.95} />
<directionalLight position={[3, 5, 4]} intensity={0.45} color="#fff6ea" />
<Room colors={colors} frame={frame} />
</ThreeCanvas>
<AbsoluteFill>
<Img src={src} style={{ position: "absolute", left: charX - charH / 2, bottom: charBottom + bob - charIn, width: charH, height: charH, objectFit: "contain", filter: "drop-shadow(0 18px 26px rgba(34,40,58,0.18))" }} />
</AbsoluteFill>
<Desk colors={colors} L={L} width={width} height={height} deskTop={deskTop} prop={data.prop || "cup"} />
<div style={{ position: "absolute", direction: "ltr", ...(L.isWide ? { right: width * 0.065, top: 0, bottom: 0, width: width * 0.4, display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "flex-end" } : { left: 0, right: 0, top: height * 0.085, alignItems: "center", display: "flex", flexDirection: "column", paddingInline: L.vmin(50) }) }}>
<Kicker index={index} total={total} colors={colors} L={L} slide={slide} />
<div style={{ direction: "rtl", textAlign: L.isWide ? "right" : "center", transform: `translate(${slide}px, ${titleY}px)`, fontWeight: 800, fontSize: L.pick(L.vmin(74), L.vmin(68), L.vmin(64)), color: colors.textColor, lineHeight: 1.18, letterSpacing: -0.5 }}>{data.title}</div>
<div style={{ direction: "rtl", textAlign: L.isWide ? "right" : "center", opacity: capOp, marginTop: L.vmin(16), fontWeight: 400, fontSize: L.pick(L.vmin(31), L.vmin(31), L.vmin(29)), lineHeight: 1.7, color: hexToRgba(colors.textColor, 0.66), maxWidth: L.isWide ? "100%" : width * 0.82 }}>{data.caption}</div>
</div>
<ProgressDots index={index} total={total} colors={colors} L={L} />
<Vignette />
<Grain />
</AbsoluteFill>
);
};
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,
};
@@ -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<BlockProps> = ({ 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 = (
<div style={{ transform: `translateY(${float}px) scale(${cardScale}) rotate(-1.5deg)`, width: cardW, height: cardH, borderRadius: L.vmin(28), overflow: "hidden", boxShadow: `0 ${L.vmin(40)}px ${L.vmin(80)}px ${hexToRgba("#1f2937", 0.28)}`, border: `${L.vmin(10)}px solid #ffffff`, background: mixHex(colors.backgroundColor, "#ffffff", 0.5) }}>
{data.imageUrl ? (
<Img src={resolveSrc(data.imageUrl)} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
) : (
<div style={{ width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", background: `linear-gradient(135deg, ${mixHex(colors.accentColor, "#ffffff", 0.55)}, ${mixHex(colors.secondaryColor, "#ffffff", 0.55)})`, color: hexToRgba(colors.textColor, 0.4), fontSize: L.vmin(70) }}>
🖼
</div>
)}
</div>
);
const Text = (
<div style={{ direction: "rtl", textAlign: L.isWide ? "right" : "center", transform: `translateX(${textX}px)`, maxWidth: L.isWide ? L.vmin(620) : L.vmin(900) }}>
<Kicker index={index} total={total} colors={colors} L={L} slide={slide} />
<div style={{ fontWeight: 800, fontSize: L.pick(L.vmin(64), L.vmin(58), L.vmin(56)), color: colors.textColor, lineHeight: 1.2, letterSpacing: -0.5 }}>{data.title}</div>
<div style={{ marginTop: L.vmin(16), fontWeight: 400, fontSize: L.pick(L.vmin(31), L.vmin(30), L.vmin(29)), color: hexToRgba(colors.textColor, 0.64), lineHeight: 1.7 }}>{data.caption}</div>
</div>
);
return (
<AbsoluteFill style={{ opacity, fontFamily: FONT, backgroundColor: colors.backgroundColor }}>
<ThreeBackdrop colors={colors} />
<AbsoluteFill style={{ display: "flex", flexDirection: L.isWide ? "row-reverse" : "column", alignItems: "center", justifyContent: "center", gap: L.pick(L.vmin(70), L.vmin(40), L.vmin(48)), padding: L.vmin(70) }}>
{Card}
{Text}
</AbsoluteFill>
<ProgressDots index={index} total={total} colors={colors} L={L} />
<Vignette />
<Grain />
</AbsoluteFill>
);
};
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,
};
@@ -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<BlockProps> = ({ 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 (
<AbsoluteFill style={{ opacity, fontFamily: FONT, backgroundColor: colors.backgroundColor }}>
<ThreeBackdrop colors={colors} />
<AbsoluteFill style={{ display: "flex", alignItems: "center", justifyContent: "center", flexDirection: "column", padding: L.pick(L.vmin(110), L.vmin(80), L.vmin(70)) }}>
<Kicker index={index} total={total} colors={colors} L={L} slide={slide} />
{/* big quotation mark */}
<div style={{ fontFamily: "Georgia, serif", fontSize: L.vmin(120), lineHeight: 0.5, color: hexToRgba(colors.accentColor, 0.5), marginBottom: L.vmin(20) }}></div>
<div style={{ direction: "rtl", textAlign: "center", fontWeight: 800, fontSize: L.pick(L.vmin(62), L.vmin(58), L.vmin(54)), color: colors.textColor, lineHeight: 1.5, maxWidth: L.vmin(1150) }}>
{words.map((w, i) => {
const sp = spring({ frame: frame - 8 - i * 3, fps, config: { damping: 18, stiffness: 120 } });
return (
<span key={i} style={{ display: "inline-block", marginInline: L.vmin(6), opacity: sp, transform: `translateY(${interpolate(sp, [0, 1], [L.vmin(22), 0])}px)` }}>
{w}
</span>
);
})}
</div>
<div style={{ direction: "rtl", opacity: authorOp, marginTop: L.vmin(34), display: "flex", alignItems: "center", gap: L.vmin(12) }}>
<span style={{ width: L.vmin(34), height: L.vmin(3), borderRadius: 999, background: colors.accentColor }} />
<span style={{ fontWeight: 700, fontSize: L.vmin(30), color: hexToRgba(colors.textColor, 0.72) }}>{data.author}</span>
</div>
</AbsoluteFill>
<ProgressDots index={index} total={total} colors={colors} L={L} />
<Vignette />
<Grain />
</AbsoluteFill>
);
};
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,
};
@@ -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<BlockProps> = ({ 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 (
<AbsoluteFill style={{ opacity, fontFamily: FONT, backgroundColor: colors.backgroundColor }}>
<ThreeBackdrop colors={colors} confetti />
<AbsoluteFill style={{ display: "flex", alignItems: "center", justifyContent: "center", flexDirection: "column", padding: L.vmin(70) }}>
<div style={{ direction: "rtl", transform: `scale(${brandScale})`, fontWeight: 900, fontSize: L.pick(L.vmin(120), L.vmin(104), L.vmin(96)), color: colors.textColor, lineHeight: 1.05, letterSpacing: -1, textAlign: "center" }}>
{data.brandText}
</div>
<div style={{ direction: "rtl", opacity: tagOp, marginTop: L.vmin(18), fontWeight: 400, fontSize: L.pick(L.vmin(36), L.vmin(34), L.vmin(32)), color: hexToRgba(colors.textColor, 0.64), textAlign: "center", maxWidth: L.vmin(900) }}>
{data.tagline}
</div>
<div style={{ direction: "rtl", transform: `scale(${ctaScale})`, marginTop: L.vmin(40), borderRadius: 999, background: `linear-gradient(90deg, ${colors.accentColor}, ${mixHex(colors.accentColor, colors.secondaryColor, 0.6)})`, color: "#fff", fontWeight: 800, fontSize: L.pick(L.vmin(36), L.vmin(34), L.vmin(32)), padding: `${L.vmin(20)}px ${L.vmin(52)}px`, boxShadow: `0 ${L.vmin(20)}px ${L.vmin(44)}px ${hexToRgba(colors.accentColor, 0.4)}` }}>
{data.cta}
</div>
</AbsoluteFill>
<Vignette />
<Grain />
</AbsoluteFill>
);
};
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,
};
@@ -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<BlockProps> = ({ 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 (
<AbsoluteFill style={{ opacity, fontFamily: FONT, backgroundColor: colors.backgroundColor }}>
<ThreeBackdrop colors={colors} />
<AbsoluteFill style={{ display: "flex", alignItems: L.isWide ? "flex-start" : "center", justifyContent: "center", flexDirection: "column", padding: L.pick(L.vmin(120), L.vmin(80), L.vmin(70)) }}>
<Kicker index={index} total={total} colors={colors} L={L} slide={slide} />
<div style={{ direction: "rtl", textAlign: L.isWide ? "right" : "center", transform: `translateX(${slide}px)`, fontWeight: 800, fontSize: L.pick(L.vmin(72), L.vmin(64), L.vmin(60)), color: colors.textColor, lineHeight: 1.15, letterSpacing: -0.5, marginBottom: L.vmin(34) }}>
{data.title}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: L.vmin(16), width: "100%", alignItems: L.isWide ? "flex-start" : "center" }}>
{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 (
<div key={i} style={{ direction: "rtl", opacity: sp, transform: `translateX(${x}px)`, display: "flex", alignItems: "center", gap: L.vmin(16), borderRadius: L.vmin(18), background: hexToRgba(colors.textColor, 0.04), border: `1px solid ${hexToRgba(colors.textColor, 0.08)}`, padding: `${L.vmin(18)}px ${L.vmin(26)}px`, maxWidth: L.vmin(1000) }}>
<span style={{ flexShrink: 0, width: L.vmin(40), height: L.vmin(40), borderRadius: 999, background: colors.accentColor, color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: L.vmin(22) }}>
{String(i + 1).replace(/[0-9]/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[+d])}
</span>
<span style={{ fontWeight: 600, fontSize: L.pick(L.vmin(34), L.vmin(32), L.vmin(30)), color: hexToRgba(colors.textColor, 0.84) }}>{s}</span>
</div>
);
})}
</div>
</AbsoluteFill>
<ProgressDots index={index} total={total} colors={colors} L={L} />
<Vignette />
<Grain />
</AbsoluteFill>
);
};
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,
};
@@ -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<BlockProps> = ({ 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 (
<AbsoluteFill style={{ opacity, fontFamily: FONT, backgroundColor: colors.backgroundColor }}>
<ThreeBackdrop colors={colors} />
<AbsoluteFill style={{ display: "flex", alignItems: "center", justifyContent: "center", flexDirection: "column", padding: L.vmin(60) }}>
{data.kicker ? (
<div style={{ direction: "rtl", opacity: kickOp, transform: `translateY(${slide}px)`, marginBottom: L.vmin(20), display: "inline-flex", alignItems: "center", gap: L.vmin(10), borderRadius: 999, border: `1px solid ${hexToRgba(colors.accentColor, 0.4)}`, background: hexToRgba(colors.accentColor, 0.08), padding: `${L.vmin(8)}px ${L.vmin(20)}px`, fontWeight: 700, fontSize: L.vmin(24), color: colors.accentColor }}>
<span style={{ width: L.vmin(9), height: L.vmin(9), borderRadius: 999, background: colors.accentColor }} />
{data.kicker}
</div>
) : null}
<div style={{ direction: "rtl", textAlign: "center", transform: `translateY(${titleY}px)`, fontWeight: 800, fontSize: L.pick(L.vmin(118), L.vmin(100), L.vmin(92)), color: colors.textColor, lineHeight: 1.08, letterSpacing: -1, maxWidth: L.vmin(1100) }}>
{data.title}
</div>
<div style={{ direction: "rtl", textAlign: "center", opacity: subOp, marginTop: L.vmin(24), fontWeight: 400, fontSize: L.pick(L.vmin(37), L.vmin(35), L.vmin(33)), color: hexToRgba(colors.textColor, 0.64), lineHeight: 1.6, maxWidth: L.vmin(900) }}>
{data.subtitle}
</div>
</AbsoluteFill>
<ProgressDots index={index} total={total} colors={colors} L={L} />
<Vignette />
<Grain />
</AbsoluteFill>
);
};
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,
};
+113
View File
@@ -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 (
<ThreeCanvas width={width} height={height} camera={{ position: [0, 0.12, 5], fov: 42 }} style={{ position: "absolute", inset: 0 }}>
<Drift frame={frame} fps={fps} />
<ambientLight intensity={0.95} />
<directionalLight position={[3, 5, 4]} intensity={0.45} color="#fff6ea" />
{/* soft vertical wash */}
<mesh position={[0, 1.8, -3]}><planeGeometry args={[22, 9]} /><meshStandardMaterial color={wallTop} /></mesh>
<mesh position={[0, -2.4, -2.98]}><planeGeometry args={[22, 6]} /><meshStandardMaterial color={colors.backgroundColor} /></mesh>
{!bare && (
<>
<mesh position={[3.0, 1.6, -2.9]}><circleGeometry args={[1.7, 48]} /><meshBasicMaterial color={mixHex(accent, "#ffffff", 0.6)} transparent opacity={0.4} /></mesh>
<mesh position={[-3.4, -0.6, -2.85]}><circleGeometry args={[1.3, 48]} /><meshBasicMaterial color={mixHex(colors.secondaryColor, "#ffffff", 0.55)} transparent opacity={0.28} /></mesh>
</>
)}
{/* 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 (
<mesh key={i} position={[x, y, z]}>
<circleGeometry args={[0.04 + rand(i) * 0.05, 16]} />
<meshBasicMaterial color={accent} transparent opacity={0.2} />
</mesh>
);
})}
{confetti && <Confetti3D colors={[colors.accentColor, colors.secondaryColor, mixHex(colors.accentColor, "#ffffff", 0.4)]} count={40} />}
</ThreeCanvas>
);
};
export const Grain: React.FC = () => (
<AbsoluteFill
style={{
pointerEvents: "none",
opacity: 0.05,
mixBlendMode: "overlay",
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")",
backgroundSize: "160px 160px",
}}
/>
);
export const Vignette: React.FC = () => (
<AbsoluteFill style={{ pointerEvents: "none", background: "radial-gradient(125% 108% at 50% 42%, transparent 56%, rgba(30,38,58,0.16) 100%)" }} />
);
export const ProgressDots: React.FC<{ index: number; total: number; colors: SceneColors; L: Layout }> = ({ index, total, colors, L }) => (
<div style={{ position: "absolute", bottom: L.vmin(44), left: 0, right: 0, display: "flex", justifyContent: "center", gap: L.vmin(8) }}>
{Array.from({ length: total }).map((_, k) => (
<div key={k} style={{ width: k === index ? L.vmin(28) : L.vmin(10), height: L.vmin(10), borderRadius: 999, background: k === index ? colors.accentColor : hexToRgba(colors.textColor, 0.18) }} />
))}
</div>
);
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 (
<div style={{ direction: "rtl", display: "flex", alignItems: "center", gap: L.vmin(12), opacity: op, transform: `translateX(${slide}px)`, marginBottom: L.vmin(14) }}>
<div style={{ width: lineW, height: L.vmin(3), borderRadius: 999, background: colors.accentColor }} />
<div style={{ fontWeight: 800, fontSize: L.vmin(23), letterSpacing: 1, color: colors.accentColor }}>
{fa(index + 1)} <span style={{ color: hexToRgba(colors.textColor, 0.4) }}>/ {fa(total)}</span>
</div>
</div>
);
};
/** 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 };
}
+24
View File
@@ -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<string, SceneBlock> = {
[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];
+60
View File
@@ -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<string, string>; // per-scene content (text + image keys)
}
/** Everything a block component receives to render one scene. */
export interface BlockProps {
data: Record<string, string>; // 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<BlockProps>;
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<string, string>): Record<string, string> {
const out: Record<string, string> = {};
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));