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:
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.
@@ -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")]
|
||||
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)
|
||||
T = [
|
||||
@@ -53,6 +76,8 @@ T = [
|
||||
[("badge","نشان تخفیف","۵۰٪ تخفیف"),("headline","عنوان","فروش ویژهٔ پایان فصل"),("subtext","توضیح","فقط تا پایان همین هفته"),("cta","دکمه","همین حالا خرید کنید")],("#f59e0b","#fb7185","#140e1f")),
|
||||
("AppShowcase3D","fr-app-showcase","معرفی اپلیکیشن سهبعدی","نمایش سهبعدی و حرفهای اپلیکیشن روی گوشی پرچمدار با نورپردازی استودیویی",6,
|
||||
[("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
|
||||
@@ -65,11 +90,16 @@ MEDIA = {
|
||||
# Per-template text colour (default white for dark backgrounds; dark for light studios).
|
||||
TEXTCOLORS = {
|
||||
"AppShowcase3D": "#0f172a",
|
||||
"CharacterStory": "#2b3a55",
|
||||
}
|
||||
|
||||
# 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.
|
||||
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):
|
||||
rects = "".join(f'<rect x="{i*50}" y="0" width="50" height="40" fill="{c}"/>' for i, c in enumerate(colors))
|
||||
@@ -104,6 +134,19 @@ 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 ("
|
||||
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);")
|
||||
nscenes = MULTISCENE.get(tid, 1)
|
||||
if nscenes > 1:
|
||||
# one editable scene card per beat; 2 text fields (title+caption) each.
|
||||
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(
|
||||
"INSERT INTO content.scenes (id,project_id,key,title,scene_color_svg,default_duration_sec,sort) VALUES ("
|
||||
f"{q(sid)},{q(pid)},'c1','صحنه ۱',{q(swatch_svg([accent,sec,bg,txt]))},{dur},0);")
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -10,7 +10,14 @@ import {
|
||||
getVideoTemplateImageSrc,
|
||||
} from "@/lib/video-templates-catalog";
|
||||
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 {
|
||||
template: VideoCatalogTemplate;
|
||||
|
||||
Reference in New Issue
Block a user