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
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.
+54 -11
View File
@@ -16,6 +16,29 @@ def q(s): return "'" + str(s).replace("'", "''") + "'"
ASPECTS = [("16x9", 1920, 1080, "16:9"), ("1x1", 1080, 1080, "1:1"), ("9x16", 1080, 1920, "9:16")] ASPECTS = [("16x9", 1920, 1080, "16:9"), ("1x1", 1080, 1080, "1:1"), ("9x16", 1080, 1920, "9:16")]
CTITLES = {"accentColor": "رنگ اصلی", "secondaryColor": "رنگ دوم", "backgroundColor": "پس‌زمینه", "textColor": "رنگ متن"} CTITLES = {"accentColor": "رنگ اصلی", "secondaryColor": "رنگ دوم", "backgroundColor": "پس‌زمینه", "textColor": "رنگ متن"}
SCENE_SECONDS = 3 # CharacterStory: per-scene duration
# CharacterStory: 13 explainer beats → 26 content fields (title + caption per scene).
# Keys s{N}_title / s{N}_text match the globally-unique Remotion props.
CS_BEATS = [
("داستان شما", "روایت خود را در سیزده صحنه بسازید"),
("معرفی", "شخصیت یا برند خود را معرفی کنید"),
("شروع ماجرا", "همه‌چیز از یک ایده آغاز شد"),
("یک چالش", "اما یک مشکل سر راه پیدا شد"),
("جست‌وجو", "به دنبال یک راه‌حل گشتیم"),
("قدم اول", "اولین قدم را برداشتیم"),
("یک مانع", "همه‌چیز آسان نبود"),
("نقطهٔ عطف", "و سپس همه‌چیز تغییر کرد"),
("ایده", "راه‌حل را پیدا کردیم"),
("اقدام", "دست به کار شدیم"),
("اوج داستان", "بزرگ‌ترین لحظه فرا رسید"),
("نتیجه", "و به هدف رسیدیم"),
("پایان", "همین حالا داستان خود را بسازید"),
]
CS_TEXTS = []
for _i, (_t, _c) in enumerate(CS_BEATS, 1):
CS_TEXTS.append((f"s{_i}_title", f"صحنهٔ {_i} — عنوان", _t))
CS_TEXTS.append((f"s{_i}_text", f"صحنهٔ {_i} — متن", _c))
# id, slug, name(fa), desc(fa), dur, [(textKey,title,value)], (accent,secondary,bg) # id, slug, name(fa), desc(fa), dur, [(textKey,title,value)], (accent,secondary,bg)
T = [ T = [
@@ -53,6 +76,8 @@ T = [
[("badge","نشان تخفیف","۵۰٪ تخفیف"),("headline","عنوان","فروش ویژهٔ پایان فصل"),("subtext","توضیح","فقط تا پایان همین هفته"),("cta","دکمه","همین حالا خرید کنید")],("#f59e0b","#fb7185","#140e1f")), [("badge","نشان تخفیف","۵۰٪ تخفیف"),("headline","عنوان","فروش ویژهٔ پایان فصل"),("subtext","توضیح","فقط تا پایان همین هفته"),("cta","دکمه","همین حالا خرید کنید")],("#f59e0b","#fb7185","#140e1f")),
("AppShowcase3D","fr-app-showcase","معرفی اپلیکیشن سه‌بعدی","نمایش سه‌بعدی و حرفه‌ای اپلیکیشن روی گوشی پرچم‌دار با نورپردازی استودیویی",6, ("AppShowcase3D","fr-app-showcase","معرفی اپلیکیشن سه‌بعدی","نمایش سه‌بعدی و حرفه‌ای اپلیکیشن روی گوشی پرچم‌دار با نورپردازی استودیویی",6,
[("appName","نام اپلیکیشن","اپلیکیشن شما"),("tagline","شعار","تجربه‌ای روان، سریع و زیبا"),("cta","دکمه","همین حالا دانلود کنید")],("#3b82f6","#8b5cf6","#f4f5f7")), [("appName","نام اپلیکیشن","اپلیکیشن شما"),("tagline","شعار","تجربه‌ای روان، سریع و زیبا"),("cta","دکمه","همین حالا دانلود کنید")],("#3b82f6","#8b5cf6","#f4f5f7")),
("CharacterStory","fr-character-story","داستان شخصیتی (۱۳ صحنه)","روایت داستان شما در سیزده صحنهٔ متحرک با شخصیت؛ تصویرسازی مدرن و مینیمال، صحنه‌ها کاملاً قابل ویرایش و انعطاف‌پذیر",39,
CS_TEXTS,("#cf8a76","#6f9d96","#ece4d6")),
] ]
# Optional Media (image) content elements per template — these surface in the # Optional Media (image) content elements per template — these surface in the
@@ -65,11 +90,16 @@ MEDIA = {
# Per-template text colour (default white for dark backgrounds; dark for light studios). # Per-template text colour (default white for dark backgrounds; dark for light studios).
TEXTCOLORS = { TEXTCOLORS = {
"AppShowcase3D": "#0f172a", "AppShowcase3D": "#0f172a",
"CharacterStory": "#2b3a55",
} }
# Templates that ship a distinct preview video PER aspect (so the detail page shows # Templates that ship a distinct preview video PER aspect (so the detail page shows
# the matching render, not the 16:9 cropped). Others reuse the single 16:9 preview. # the matching render, not the 16:9 cropped). Others reuse the single 16:9 preview.
PERASPECT_VIDEO = {"AppShowcase3D"} PERASPECT_VIDEO = {"AppShowcase3D", "CharacterStory"}
# Templates whose content is split across MANY scenes (key c1..cN), one editable
# scene card per beat. value = scene count; texts are assigned 2-per-scene in order.
MULTISCENE = {"CharacterStory": len(CS_BEATS)}
def swatch_svg(colors): def swatch_svg(colors):
rects = "".join(f'<rect x="{i*50}" y="0" width="50" height="40" fill="{c}"/>' for i, c in enumerate(colors)) rects = "".join(f'<rect x="{i*50}" y="0" width="50" height="40" fill="{c}"/>' for i, c in enumerate(colors))
@@ -104,17 +134,30 @@ for idx, (tid, slug, name, desc, dur, texts, (accent, sec, bg)) in enumerate(T):
"project_duration_sec,free_fps,choose_mode,resolution,render_engine,render_remotion_comp,is_published,sort) VALUES (" "project_duration_sec,free_fps,choose_mode,resolution,render_engine,render_remotion_comp,is_published,sort) VALUES ("
f"{q(pid)},{q(cid)},{q(aspstr)},{q(thumb)},{q(pvideo)},{w},{h},{q(aspstr)}," f"{q(pid)},{q(cid)},{q(aspstr)},{q(thumb)},{q(pvideo)},{w},{h},{q(aspstr)},"
f"{dur},30,'FLEXIBLE','FullHD','Remotion',{q(tid+'-'+asp)},TRUE,0);") f"{dur},30,'FLEXIBLE','FullHD','Remotion',{q(tid+'-'+asp)},TRUE,0);")
out.append( nscenes = MULTISCENE.get(tid, 1)
"INSERT INTO content.scenes (id,project_id,key,title,scene_color_svg,default_duration_sec,sort) VALUES (" if nscenes > 1:
f"{q(sid)},{q(pid)},'c1','صحنه ۱',{q(swatch_svg([accent,sec,bg,txt]))},{dur},0);") # one editable scene card per beat; 2 text fields (title+caption) each.
for pos, (k, title, val) in enumerate(texts): for sc in range(1, nscenes + 1):
skid = uid(f"s-{tid}-{asp}-{sc}")
out.append(
"INSERT INTO content.scenes (id,project_id,key,title,scene_color_svg,default_duration_sec,sort) VALUES ("
f"{q(skid)},{q(pid)},{q('c'+str(sc))},{q('صحنه '+str(sc))},{q(swatch_svg([accent,sec,bg,txt]))},{SCENE_SECONDS},{sc-1});")
for pos, (k, title, val) in enumerate(texts[(sc - 1) * 2: sc * 2]):
out.append(
"INSERT INTO content.scene_content_elements (id,scene_id,key,title,type,default_value,position_in_container,direction_layer_value) VALUES ("
f"{q(uid(f'ce-{tid}-{asp}-{k}'))},{q(skid)},{q(k)},{q(title)},'Text',{q(val)},{pos},1);")
else:
out.append( out.append(
"INSERT INTO content.scene_content_elements (id,scene_id,key,title,type,default_value,position_in_container,direction_layer_value) VALUES (" "INSERT INTO content.scenes (id,project_id,key,title,scene_color_svg,default_duration_sec,sort) VALUES ("
f"{q(uid(f'ce-{tid}-{asp}-{k}'))},{q(sid)},{q(k)},{q(title)},'Text',{q(val)},{pos},1);") f"{q(sid)},{q(pid)},'c1','صحنه ۱',{q(swatch_svg([accent,sec,bg,txt]))},{dur},0);")
for mpos, (k, title) in enumerate(MEDIA.get(tid, [])): for pos, (k, title, val) in enumerate(texts):
out.append( out.append(
"INSERT INTO content.scene_content_elements (id,scene_id,key,title,type,default_value,position_in_container,direction_layer_value) VALUES (" "INSERT INTO content.scene_content_elements (id,scene_id,key,title,type,default_value,position_in_container,direction_layer_value) VALUES ("
f"{q(uid(f'ce-{tid}-{asp}-{k}'))},{q(sid)},{q(k)},{q(title)},'Media','',{len(texts)+mpos},0);") f"{q(uid(f'ce-{tid}-{asp}-{k}'))},{q(sid)},{q(k)},{q(title)},'Text',{q(val)},{pos},1);")
for mpos, (k, title) in enumerate(MEDIA.get(tid, [])):
out.append(
"INSERT INTO content.scene_content_elements (id,scene_id,key,title,type,default_value,position_in_container,direction_layer_value) VALUES ("
f"{q(uid(f'ce-{tid}-{asp}-{k}'))},{q(sid)},{q(k)},{q(title)},'Media','',{len(texts)+mpos},0);")
for si, (k, hexv) in enumerate(colors): for si, (k, hexv) in enumerate(colors):
out.append( out.append(
"INSERT INTO content.shared_colors (id,project_id,element_key,title,icon,attr_value,default_color,sort) VALUES (" "INSERT INTO content.shared_colors (id,project_id,element_key,title,icon,attr_value,default_color,sort) VALUES ("
@@ -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 { Birthday3D, birthday3DSchema } from "./compositions/Birthday3D";
import { Promo3D, promo3DSchema } from "./compositions/Promo3D"; import { Promo3D, promo3DSchema } from "./compositions/Promo3D";
import { AppShowcase3D, appShowcase3DSchema } from "./compositions/AppShowcase3D"; import { AppShowcase3D, appShowcase3DSchema } from "./compositions/AppShowcase3D";
import { CharacterStory, characterStorySchema, characterStoryDefaults } from "./compositions/CharacterStory";
export interface TemplateDef { export interface TemplateDef {
/** Base id; the registered composition ids are `${id}-${aspect}`. */ /** Base id; the registered composition ids are `${id}-${aspect}`. */
@@ -251,4 +252,13 @@ export const TEMPLATES: TemplateDef[] = [
textColor: "#0f172a", textColor: "#0f172a",
}, },
}, },
{
id: "CharacterStory",
name: "داستان شخصیتی (۱۳ صحنه)",
description: "قالب داستان‌گویی منعطف با شخصیت متحرک؛ تا ۱۳ صحنهٔ قابل‌ویرایش (صحنه‌های خالی نمایش داده نمی‌شوند)",
component: CharacterStory,
schema: characterStorySchema,
durationSec: 39,
defaultProps: characterStoryDefaults,
},
]; ];
@@ -10,7 +10,14 @@ import {
getVideoTemplateImageSrc, getVideoTemplateImageSrc,
} from "@/lib/video-templates-catalog"; } from "@/lib/video-templates-catalog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { BrandedVideoPlayer } from "@/components/templates/BrandedVideoPlayer"; import dynamic from "next/dynamic";
// plyr-react references `document` at import time, which crashes server rendering
// (Next still SSRs client components for the initial HTML). Load it client-only.
const BrandedVideoPlayer = dynamic(
() => import("@/components/templates/BrandedVideoPlayer").then((m) => m.BrandedVideoPlayer),
{ ssr: false }
);
interface TemplateDetailPreviewProps { interface TemplateDetailPreviewProps {
template: VideoCatalogTemplate; template: VideoCatalogTemplate;