b1a51cb01b
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>
120 lines
5.2 KiB
TypeScript
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) };
|
|
};
|