feat(remotion): premium CharacterStory template (13 flexible scenes) + fix detail-page SSR

- CharacterStory: refined flat-illustration character (gradient-shaded sweater,
  modern hair, calm minimal face), muted editorial palette (coral/teal/sand/navy),
  abstract environment (soft depth blobs, ground "stage", sparse particles,
  vignette + grain), scene-number kicker. Verified in 16:9/1:1/9:16 and all poses.
- seed: 13 editable scene cards (c1..c13, keys s{N}_title/s{N}_text) via new
  MULTISCENE path; per-aspect previews; muted defaults.
- assets: 3 thumbnails + 4 preview MP4s vendored into public/template-media.
- fix: load BrandedVideoPlayer (plyr-react) client-only via next/dynamic
  (ssr:false) — plyr touches `document` at import, which was 500-ing every
  template detail page during SSR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-22 16:58:48 +03:30
parent 863b9503b3
commit a3152ee84f
11 changed files with 319 additions and 12 deletions
@@ -0,0 +1,247 @@
import React from "react";
import {
AbsoluteFill,
Sequence,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
Easing,
} from "remotion";
import { z } from "zod";
import { colorSchema } from "../lib/branding";
import { FONT } from "../lib/fonts";
import { useLayout, type Layout } from "../lib/aspect";
import { hexToRgba, mixHex, rand } from "../lib/anim";
export const SCENE_COUNT = 13;
export const SCENE_SECONDS = 3;
// 13 scenes × (title + caption). Globally-unique keys so the flat studio→Remotion
// binding (GetRenderBindings) maps each to its own prop without collisions.
const sceneFields: Record<string, z.ZodString> = {};
for (let i = 1; i <= SCENE_COUNT; i++) {
sceneFields[`s${i}_title`] = z.string();
sceneFields[`s${i}_text`] = z.string();
}
export const characterStorySchema = z.object({ ...sceneFields, ...colorSchema });
type Props = z.infer<typeof characterStorySchema>;
// Default "how-it-works" explainer beats (Persian) — the user replaces these.
const DEFAULT_BEATS: Array<[string, string]> = [
["داستان شما", "روایت خود را در سیزده صحنه بسازید"],
["معرفی", "شخصیت یا برند خود را معرفی کنید"],
["شروع ماجرا", "همه‌چیز از یک ایده آغاز شد"],
["یک چالش", "اما یک مشکل سر راه پیدا شد"],
["جست‌وجو", "به دنبال یک راه‌حل گشتیم"],
["قدم اول", "اولین قدم را برداشتیم"],
["یک مانع", "همه‌چیز آسان نبود"],
["نقطهٔ عطف", "و سپس همه‌چیز تغییر کرد"],
["ایده", "راه‌حل را پیدا کردیم"],
["اقدام", "دست به کار شدیم"],
["اوج داستان", "بزرگ‌ترین لحظه فرا رسید"],
["نتیجه", "و به هدف رسیدیم"],
["پایان", "همین حالا داستان خود را بسازید"],
];
export const characterStoryDefaults: Props = (() => {
const o: Record<string, string> = {};
for (let i = 1; i <= SCENE_COUNT; i++) {
o[`s${i}_title`] = DEFAULT_BEATS[i - 1][0];
o[`s${i}_text`] = DEFAULT_BEATS[i - 1][1];
}
// Sophisticated muted palette: muted coral · soft teal · warm sand · deep navy.
o.accentColor = "#cf8a76";
o.secondaryColor = "#6f9d96";
o.backgroundColor = "#ece4d6";
o.textColor = "#2b3a55";
return o as unknown as Props;
})();
// ── Cute flat character with per-scene poses ─────────────────────────────────
type Pose = "wave" | "point" | "think" | "walk" | "present" | "celebrate" | "idle";
const POSE_CYCLE: Pose[] = ["wave", "present", "walk", "think", "walk", "point", "think", "celebrate", "present", "walk", "celebrate", "present", "wave"];
// Refined editorial figure: elegant proportions, gradient shading, minimal calm face.
const StoryCharacter: React.FC<{ pose: Pose; outfit: string; local: number; idn: number }> = ({ pose, outfit, local, idn }) => {
const bob = Math.sin(local / 8) * 2.2;
const walk = pose === "walk" ? Math.sin(local / 5) * 22 : 0;
const cel = pose === "celebrate" ? Math.abs(Math.sin(local / 6)) : 0;
// Left arm: +angle raises up-and-OUT; -angle swings across/to-face. Right mirrored.
let lA = 12 + walk, rA = -12 - walk;
if (pose === "wave") { lA = 148 + Math.sin(local / 4) * 14; rA = -16; }
else if (pose === "celebrate") { lA = 150 + cel * 12; rA = -150 - cel * 12; }
else if (pose === "point") { lA = 96; rA = 10; }
else if (pose === "present") { lA = 54; rA = -54; }
else if (pose === "think") { lA = -148; rA = -10; }
else if (pose === "idle") { lA = 14; rA = -14; }
const skin = "#e3b48d", skinD = "#cf9a72", hair = "#33303c", pants = "#3a4664", pantsD = "#2c3650";
const topL = mixHex(outfit, "#ffffff", 0.16), topD = mixHex(outfit, "#000000", 0.22);
const id = (s: string) => `cs${idn}_${s}`;
const arm = (ang: number, sign: number) => (
<g transform={`translate(${sign * 17} -50) rotate(${ang})`}>
<path d={`M-4.5 0 Q -6 18 -3.2 35 L 3.2 35 Q 6 18 4.5 0 Z`} fill={`url(#${id("top")})`} />
<circle cx={0} cy={39} r={5.6} fill={skin} />
</g>
);
const leg = (sign: number, ang: number) => (
<g transform={`translate(${sign * 8} -4) rotate(${ang})`}>
<path d={`M-6.5 0 Q -7 36 -4.5 66 L 4.5 66 Q 7 36 6.5 0 Z`} fill={`url(#${id("leg")})`} />
<path d={`M-5 66 Q 0 60 9 64 L 9 72 Q 0 75 -5 72 Z`} fill="#22283d" />
</g>
);
return (
<g transform={`translate(0 ${bob})`}>
<defs>
<linearGradient id={id("top")} x1="0" y1="0" x2="0.5" y2="1"><stop offset="0%" stopColor={topL} /><stop offset="100%" stopColor={topD} /></linearGradient>
<linearGradient id={id("leg")} x1="0" y1="0" x2="0.5" y2="1"><stop offset="0%" stopColor={pants} /><stop offset="100%" stopColor={pantsD} /></linearGradient>
<radialGradient id={id("skin")} cx="0.4" cy="0.35" r="0.85"><stop offset="0%" stopColor={skin} /><stop offset="100%" stopColor={skinD} /></radialGradient>
</defs>
<ellipse cx={2} cy={74} rx={34} ry={6.5} fill="rgba(34,40,58,0.16)" />
{arm(rA, 1)}
{leg(-1, walk * 0.4)}
{leg(1, -walk * 0.4)}
<path d="M-21 -56 Q 0 -62 21 -56 Q 24 -30 18 -2 Q 0 4 -18 -2 Q -24 -30 -21 -56 Z" fill={`url(#${id("top")})`} />
<path d="M4 -58 Q 22 -52 21 -56 Q 24 -30 18 -2 Q 9 1 6 -2 Z" fill="rgba(0,0,0,0.10)" />
{arm(lA, -1)}
<rect x={-5} y={-66} width={10} height={12} rx={4} fill={skinD} />
<circle cx={0} cy={-78} r={17} fill={`url(#${id("skin")})`} />
<path d="M-17 -80 Q -19 -98 0 -98 Q 19 -98 17 -80 Q 12 -90 0 -90 Q -10 -90 -14 -83 Q -17 -84 -17 -80 Z" fill={hair} />
<ellipse cx={-6} cy={-78} rx={1.6} ry={2.4} fill="#2a2632" />
<ellipse cx={6} cy={-78} rx={1.6} ry={2.4} fill="#2a2632" />
{pose === "think"
? <path d="M-4 -70 q 4 -1.5 8 0" stroke="#9a6a58" strokeWidth={1.8} fill="none" strokeLinecap="round" />
: <path d="M-4 -71 q 4 3 8 0" stroke="#9a6a58" strokeWidth={1.8} fill="none" strokeLinecap="round" />}
<circle cx={-10} cy={-74} r={2.6} fill={hexToRgba("#d98b7a", 0.4)} />
<circle cx={10} cy={-74} r={2.6} fill={hexToRgba("#d98b7a", 0.4)} />
</g>
);
};
// ── One story scene (a beat) ─────────────────────────────────────────────────
const StoryScene: React.FC<{ L: Layout; index: number; total: number; pose: Pose; title: string; text: string; accent: string; secondary: string; bg: string; textColor: string; durFrames: number }> = ({ L, index, total, pose, title, text, accent, secondary, bg, textColor, durFrames }) => {
const local = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
// in/out slide+fade (the scene enters from the start side, exits to the other)
const inP = spring({ frame: local, fps, config: { damping: 18, stiffness: 90 } });
const outP = interpolate(local, [durFrames - 12, durFrames], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const slideIn = interpolate(inP, [0, 1], [L.vmin(80), 0]);
const slideOut = interpolate(outP, [0, 1], [0, -L.vmin(120)]);
const opacity = Math.min(interpolate(local, [0, 10], [0, 1], { extrapolateRight: "clamp" }), 1 - outP);
// per-scene muted accents (alternating) for the abstract editorial backdrop
const hue = index % 2 ? accent : secondary;
const hue2 = index % 2 ? secondary : accent;
// layout: wide → character left, text right; tall/square → character top, text below
const charSize = L.pick(L.vmin(450), L.vmin(430), L.vmin(500));
const charX = L.pick(width * 0.3, width * 0.5, width * 0.5);
const charY = L.pick(height * 0.6, height * 0.46, height * 0.42);
const groundY = charY + L.vmin(76);
const drift = Math.sin(local / 40) * L.vmin(18);
const titleSp = spring({ frame: local - 6, fps, config: { damping: 16, stiffness: 110 } });
const titleY = interpolate(titleSp, [0, 1], [L.vmin(34), 0]);
const textOp = interpolate(local, [16, 32], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const kickerOp = interpolate(local, [2, 16], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const fa = (n: number) => String(n).padStart(2, "0").replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[+d]);
const lineW = interpolate(kickerOp, [0, 1], [0, L.vmin(46)]);
return (
<AbsoluteFill style={{ opacity }}>
{/* muted editorial backdrop */}
<AbsoluteFill style={{ background: `linear-gradient(165deg, ${mixHex(bg, "#ffffff", 0.45)} 0%, ${bg} 52%, ${mixHex(bg, hue, 0.16)} 100%)` }} />
{/* soft depth blobs (CSS-blurred) */}
<AbsoluteFill style={{ overflow: "hidden" }}>
<div style={{ position: "absolute", left: charX - L.vmin(410) + drift, top: charY - L.vmin(540), width: L.vmin(820), height: L.vmin(820), borderRadius: "50%", background: `radial-gradient(circle, ${hexToRgba(hue, 0.22)} 0%, transparent 66%)`, filter: `blur(${L.vmin(8)}px)` }} />
<div style={{ position: "absolute", right: -L.vmin(150) - drift, top: -L.vmin(120), width: L.vmin(560), height: L.vmin(560), borderRadius: "50%", background: `radial-gradient(circle, ${hexToRgba(hue2, 0.16)} 0%, transparent 64%)`, filter: `blur(${L.vmin(12)}px)` }} />
</AbsoluteFill>
<svg width={width} height={height} style={{ position: "absolute", inset: 0 }}>
{/* abstract ground mound the character stands on */}
<ellipse cx={charX} cy={groundY + L.vmin(248)} rx={L.vmin(360)} ry={L.vmin(298)} fill={mixHex(hue, "#ffffff", 0.5)} opacity={0.5} />
<path d={`M${charX - L.vmin(360)} ${groundY - L.vmin(2)} A ${L.vmin(360)} ${L.vmin(298)} 0 0 1 ${charX + L.vmin(360)} ${groundY - L.vmin(2)}`} fill="none" stroke={hexToRgba(hue, 0.2)} strokeWidth={L.vmin(2)} />
{/* sparse elegant particles */}
{Array.from({ length: 7 }).map((_, k) => {
const px = rand(index * 9 + k) * width;
const py = height * (0.12 + rand(index + k) * 0.5) + Math.sin((local + k * 24) / 30) * L.vmin(12);
return <circle key={k} cx={px} cy={py} r={L.vmin(3 + (k % 3) * 2)} fill={hexToRgba(k % 2 ? hue : hue2, 0.28)} />;
})}
{/* character */}
<g transform={`translate(${charX + slideIn + slideOut} ${charY}) scale(${charSize / 185})`}>
<StoryCharacter pose={pose} outfit={accent} local={local} idn={index} />
</g>
</svg>
{/* text block */}
<div style={{ position: "absolute", direction: "ltr", ...(L.isWide ? { right: width * 0.065, top: 0, bottom: 0, width: width * 0.4, display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "flex-end" } : { left: 0, right: 0, top: height * 0.085, alignItems: "center", display: "flex", flexDirection: "column", paddingInline: L.vmin(50) }) }}>
{/* kicker: scene number + accent rule */}
<div style={{ direction: "rtl", display: "flex", alignItems: "center", gap: L.vmin(12), opacity: kickerOp, transform: `translateX(${slideOut}px)`, marginBottom: L.vmin(16) }}>
<div style={{ width: lineW, height: L.vmin(3), borderRadius: 999, background: accent }} />
<div style={{ fontWeight: 800, fontSize: L.vmin(24), letterSpacing: 1, color: accent }}>
{fa(index + 1)} <span style={{ color: hexToRgba(textColor, 0.4), fontWeight: 600 }}>/ {fa(total)}</span>
</div>
</div>
<div style={{ direction: "rtl", textAlign: L.isWide ? "right" : "center", transform: `translate(${slideOut}px, ${titleY}px)`, fontWeight: 800, fontSize: L.pick(L.vmin(76), L.vmin(70), L.vmin(66)), color: textColor, lineHeight: 1.18, letterSpacing: -0.5, maxWidth: L.isWide ? "100%" : width * 0.86 }}>
{title}
</div>
<div style={{ direction: "rtl", textAlign: L.isWide ? "right" : "center", opacity: textOp, marginTop: L.vmin(18), fontWeight: 400, fontSize: L.pick(L.vmin(31), L.vmin(31), L.vmin(29)), lineHeight: 1.7, color: hexToRgba(textColor, 0.66), maxWidth: L.isWide ? "100%" : width * 0.82 }}>
{text}
</div>
</div>
{/* progress: scene k / total */}
<div style={{ position: "absolute", bottom: L.vmin(46), 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 ? accent : hexToRgba(textColor, 0.18), transition: "all .3s" }} />
))}
</div>
{/* finishing: vignette + subtle grain */}
<AbsoluteFill style={{ pointerEvents: "none", background: "radial-gradient(125% 108% at 50% 40%, 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 CharacterStory: React.FC<Props> = (props) => {
const { fps } = useVideoConfig();
const L = useLayout();
const p = props as unknown as Record<string, string>;
const accent = props.accentColor, secondary = props.secondaryColor, bg = props.backgroundColor, textColor = props.textColor;
const durFrames = Math.round(SCENE_SECONDS * fps);
const trans = 12;
// Active scenes = non-empty (empty-skip → flexible length within 13).
const scenes: Array<{ i: number; title: string; text: string }> = [];
for (let i = 1; i <= SCENE_COUNT; i++) {
const title = (p[`s${i}_title`] ?? "").trim();
const text = (p[`s${i}_text`] ?? "").trim();
if (title || text) scenes.push({ i, title, text });
}
if (scenes.length === 0) scenes.push({ i: 1, title: "", text: "" });
return (
<AbsoluteFill style={{ fontFamily: FONT, backgroundColor: bg }}>
{scenes.map((sc, idx) => (
<Sequence key={sc.i} from={idx * durFrames} durationInFrames={durFrames + trans}>
<StoryScene
L={L}
index={idx}
total={scenes.length}
pose={POSE_CYCLE[(sc.i - 1) % POSE_CYCLE.length]}
title={sc.title}
text={sc.text}
accent={accent}
secondary={secondary}
bg={bg}
textColor={textColor}
durFrames={durFrames + trans}
/>
</Sequence>
))}
</AbsoluteFill>
);
};
+10
View File
@@ -25,6 +25,7 @@ import { Nowruz3D, nowruz3DSchema } from "./compositions/Nowruz3D";
import { Birthday3D, birthday3DSchema } from "./compositions/Birthday3D";
import { Promo3D, promo3DSchema } from "./compositions/Promo3D";
import { AppShowcase3D, appShowcase3DSchema } from "./compositions/AppShowcase3D";
import { CharacterStory, characterStorySchema, characterStoryDefaults } from "./compositions/CharacterStory";
export interface TemplateDef {
/** Base id; the registered composition ids are `${id}-${aspect}`. */
@@ -251,4 +252,13 @@ export const TEMPLATES: TemplateDef[] = [
textColor: "#0f172a",
},
},
{
id: "CharacterStory",
name: "داستان شخصیتی (۱۳ صحنه)",
description: "قالب داستان‌گویی منعطف با شخصیت متحرک؛ تا ۱۳ صحنهٔ قابل‌ویرایش (صحنه‌های خالی نمایش داده نمی‌شوند)",
component: CharacterStory,
schema: characterStorySchema,
durationSec: 39,
defaultProps: characterStoryDefaults,
},
];