d830c56ea0
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>
70 lines
3.7 KiB
TypeScript
70 lines
3.7 KiB
TypeScript
import React from "react";
|
|
import { AbsoluteFill, Img, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
|
import { FONT } from "../../lib/fonts";
|
|
import { hexToRgba, mixHex } from "../../lib/anim";
|
|
import { ThreeBackdrop, Grain, Vignette, ProgressDots, Kicker, useSceneTransition } from "../chrome";
|
|
import type { BlockProps, SceneBlock } from "../types";
|
|
|
|
const resolveSrc = (u: string) => (/^https?:\/\//.test(u) ? u : staticFile(u));
|
|
|
|
const ImageCaption: React.FC<BlockProps> = ({ data, colors, L, index, total, durationInFrames }) => {
|
|
const frame = useCurrentFrame();
|
|
const { fps } = useVideoConfig();
|
|
const { opacity, slide } = useSceneTransition(durationInFrames, L);
|
|
const cardSp = spring({ frame: frame - 2, fps, config: { damping: 18, stiffness: 90 } });
|
|
const cardScale = interpolate(cardSp, [0, 1], [0.9, 1]);
|
|
const float = Math.sin(frame / 26) * L.vmin(8);
|
|
const textSp = spring({ frame: frame - 10, fps, config: { damping: 18, stiffness: 110 } });
|
|
const textX = interpolate(textSp, [0, 1], [L.vmin(40), 0]);
|
|
|
|
const cardW = L.pick(L.vmin(820), L.vmin(720), L.vmin(820));
|
|
const cardH = L.pick(L.vmin(520), L.vmin(470), L.vmin(560));
|
|
|
|
const Card = (
|
|
<div style={{ transform: `translateY(${float}px) scale(${cardScale}) rotate(-1.5deg)`, width: cardW, height: cardH, borderRadius: L.vmin(28), overflow: "hidden", boxShadow: `0 ${L.vmin(40)}px ${L.vmin(80)}px ${hexToRgba("#1f2937", 0.28)}`, border: `${L.vmin(10)}px solid #ffffff`, background: mixHex(colors.backgroundColor, "#ffffff", 0.5) }}>
|
|
{data.imageUrl ? (
|
|
<Img src={resolveSrc(data.imageUrl)} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
|
) : (
|
|
<div style={{ width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", background: `linear-gradient(135deg, ${mixHex(colors.accentColor, "#ffffff", 0.55)}, ${mixHex(colors.secondaryColor, "#ffffff", 0.55)})`, color: hexToRgba(colors.textColor, 0.4), fontSize: L.vmin(70) }}>
|
|
🖼
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const Text = (
|
|
<div style={{ direction: "rtl", textAlign: L.isWide ? "right" : "center", transform: `translateX(${textX}px)`, maxWidth: L.isWide ? L.vmin(620) : L.vmin(900) }}>
|
|
<Kicker index={index} total={total} colors={colors} L={L} slide={slide} />
|
|
<div style={{ fontWeight: 800, fontSize: L.pick(L.vmin(64), L.vmin(58), L.vmin(56)), color: colors.textColor, lineHeight: 1.2, letterSpacing: -0.5 }}>{data.title}</div>
|
|
<div style={{ marginTop: L.vmin(16), fontWeight: 400, fontSize: L.pick(L.vmin(31), L.vmin(30), L.vmin(29)), color: hexToRgba(colors.textColor, 0.64), lineHeight: 1.7 }}>{data.caption}</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<AbsoluteFill style={{ opacity, fontFamily: FONT, backgroundColor: colors.backgroundColor }}>
|
|
<ThreeBackdrop colors={colors} />
|
|
<AbsoluteFill style={{ display: "flex", flexDirection: L.isWide ? "row-reverse" : "column", alignItems: "center", justifyContent: "center", gap: L.pick(L.vmin(70), L.vmin(40), L.vmin(48)), padding: L.vmin(70) }}>
|
|
{Card}
|
|
{Text}
|
|
</AbsoluteFill>
|
|
<ProgressDots index={index} total={total} colors={colors} L={L} />
|
|
<Vignette />
|
|
<Grain />
|
|
</AbsoluteFill>
|
|
);
|
|
};
|
|
|
|
export const ImageCaptionBlock: SceneBlock = {
|
|
id: "ImageCaption",
|
|
label: "تصویر و توضیح",
|
|
component: ImageCaption,
|
|
fields: [
|
|
{ key: "imageUrl", label: "تصویر", type: "image", default: "" },
|
|
{ key: "title", label: "عنوان", type: "text", default: "ویژگی شما" },
|
|
{ key: "caption", label: "توضیح", type: "text", default: "توضیح کوتاه دربارهٔ این تصویر یا ویژگی", multiline: true },
|
|
],
|
|
defaultDurationSec: 4,
|
|
minDurationSec: 2,
|
|
maxDurationSec: 8,
|
|
};
|