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:
@@ -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/<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).
|
||||||
@@ -3,6 +3,8 @@ import { ASPECTS } from "./lib/aspect";
|
|||||||
import { TEMPLATES } from "./templates";
|
import { TEMPLATES } from "./templates";
|
||||||
import { Three3DTest } from "./compositions/Three3DTest";
|
import { Three3DTest } from "./compositions/Three3DTest";
|
||||||
import { AssetSheet } from "./compositions/AssetSheet";
|
import { AssetSheet } from "./compositions/AssetSheet";
|
||||||
|
import { StoryScenes, STORY_SCENES_DURATION } from "./compositions/StoryScenes";
|
||||||
|
import { FlexStory, flexStorySchema, flexStoryDefaults, calcFlexStoryMetadata } from "./compositions/FlexStory";
|
||||||
import {
|
import {
|
||||||
IlluminatedCircles,
|
IlluminatedCircles,
|
||||||
illuminatedCirclesSchema,
|
illuminatedCirclesSchema,
|
||||||
@@ -124,6 +126,36 @@ export const RemotionRoot: React.FC = () => {
|
|||||||
height={1080}
|
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
|
{/* Branded templates — each registered in all three aspects. A template may
|
||||||
supply a dedicated component per aspect (componentsByAspect) when its
|
supply a dedicated component per aspect (componentsByAspect) when its
|
||||||
design differs structurally; otherwise the shared `component` adapts
|
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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
@@ -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));
|
||||||
Reference in New Issue
Block a user