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,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,
|
||||
};
|
||||
Reference in New Issue
Block a user