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