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