2026-06-23 07:45:57 +03:30
import React from "react" ;
2026-06-23 17:31:19 +03:30
import { AbsoluteFill , Audio , Sequence , staticFile , useVideoConfig } from "remotion" ;
2026-06-23 07:45:57 +03:30
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" ;
2026-06-24 23:35:08 +03:30
import { FinishPass , GRADE_FILTER } from "../scenes/chrome" ;
2026-06-23 07:45:57 +03:30
/**
* 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 ( ) ) ,
} )
) ,
2026-06-23 17:31:19 +03:30
// Audio (optional so the existing render binding doesn't need to send them).
music : z.string ( ) . optional ( ) , // path/url of the music bed; "" = silent
musicVolume : z.number ( ) . optional ( ) ,
sfx : z.boolean ( ) . optional ( ) , // transition whoosh + outro chime
2026-06-23 07:45:57 +03:30
. . . colorSchema ,
} ) ;
type Props = z . infer < typeof flexStorySchema > ;
const FPS = 30 ;
2026-06-23 17:31:19 +03:30
const resolveAudio = ( u : string ) = > ( /^https?:\/\// . test ( u ) ? u : staticFile ( u ) ) ;
2026-06-23 07:45:57 +03: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" ,
2026-06-23 17:31:19 +03:30
music : "audio/music-ambient.mp3" ,
musicVolume : 0.6 ,
sfx : true ,
2026-06-23 07:45:57 +03:30
} ;
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 ) ;
2026-06-23 17:31:19 +03:30
const music = props . music === undefined ? "audio/music-ambient.mp3" : props . music ;
const musicVolume = props . musicVolume ? ? 0.6 ;
const sfx = props . sfx ? ? true ;
// Precompute each scene's start frame + duration (shared by visuals + SFX).
const starts : number [ ] = [ ] ;
let acc = 0 ;
const durations = scenes . map ( ( sc ) = > {
const dur = Math . round ( clampDuration ( sc . durationSec , getBlock ( sc . blockId ) ! ) * fps ) ;
starts . push ( acc ) ;
acc += dur ;
return dur ;
} ) ;
2026-06-23 07:45:57 +03:30
return (
2026-06-24 23:35:08 +03:30
< AbsoluteFill style = { { backgroundColor : colors.backgroundColor , fontFamily : FONT , filter : GRADE_FILTER } } >
2026-06-23 17:31:19 +03:30
{ music ? < Audio src = { resolveAudio ( music ) } loop volume = { musicVolume } / > : null }
2026-06-23 07:45:57 +03:30
{ scenes . map ( ( sc , i ) = > {
2026-06-23 17:31:19 +03:30
const Comp = getBlock ( sc . blockId ) ! . component ;
return (
< Sequence key = { i } from = { starts [ i ] } durationInFrames = { durations [ i ] } >
< Comp data = { withDefaults ( getBlock ( sc . blockId ) ! , sc . props || { } ) } colors = { colors } L = { L } index = { i } total = { scenes . length } durationInFrames = { durations [ i ] } / >
2026-06-23 07:45:57 +03:30
< / Sequence >
) ;
} ) }
2026-06-23 17:31:19 +03:30
{ /* Transition SFX: whoosh at each scene start, a chime on the final scene. */ }
{ sfx
? scenes . map ( ( _ , i ) = > (
< Sequence key = { ` sfx ${ i } ` } from = { starts [ i ] } >
< Audio
src = { staticFile ( i === scenes . length - 1 ? "audio/sfx-chime.mp3" : "audio/sfx-whoosh.mp3" ) }
volume = { 0.5 }
/ >
< / Sequence >
) )
: null }
2026-06-24 23:35:08 +03:30
{ /* Cinematic finish over every scene — the shared quality floor. */ }
< FinishPass colors = { colors } / >
2026-06-23 07:45:57 +03:30
< / 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 ) } ;
} ;