Files
flatrender/services/remotion/src/compositions/FlexStory.tsx
T
soroush.asadi b1a51cb01b feat(remotion): shared FinishPass cinematic grade (quality floor) + @remotion/lottie
The single highest-ROI quality lift — one finish applied at the FlexStory level
lifts all 12 blocks at once, no per-block change:
- GRADE_FILTER: a headless-safe colour grade (contrast/saturation/lift) applied as
  a CSS `filter` on the content root — backdrop-filter does NOT render in headless
  Chrome, so the grade lives on the content, not an overlay.
- FinishPass: split-tone (cool-shadows multiply + warm-highlights screen) + a soft
  brand duotone + top light-bloom, layered over each scene.
- Installed @remotion/lottie@4.0.290 (artist-made animations — next lever).

Verified: visible richer/graded look on CharacterScene + Slideshow, subtle enough
to suit the muted palette, consistent across blocks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 23:35:08 +03:30

120 lines
5.2 KiB
TypeScript

import React from "react";
import { AbsoluteFill, Audio, Sequence, staticFile, 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";
import { FinishPass, GRADE_FILTER } from "../scenes/chrome";
/**
* 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()),
})
),
// 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
...colorSchema,
});
type Props = z.infer<typeof flexStorySchema>;
const FPS = 30;
const resolveAudio = (u: string) => (/^https?:\/\//.test(u) ? u : staticFile(u));
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",
music: "audio/music-ambient.mp3",
musicVolume: 0.6,
sfx: true,
};
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);
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;
});
return (
<AbsoluteFill style={{ backgroundColor: colors.backgroundColor, fontFamily: FONT, filter: GRADE_FILTER }}>
{music ? <Audio src={resolveAudio(music)} loop volume={musicVolume} /> : null}
{scenes.map((sc, i) => {
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]} />
</Sequence>
);
})}
{/* 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}
{/* Cinematic finish over every scene — the shared quality floor. */}
<FinishPass colors={colors} />
</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) };
};