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
@@ -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;